Handling and Throwing Exceptions in .NET: Complete Guide for Developers

Exception handling in .NET is essential for building applications that stay stable and reliable under failure conditions. It allows developers to detect errors early, manage them effectively, and maintain critical workflows, such as database transactions and API calls, without risking system integrity.

However, achieving this level of reliability requires more than basic try-catch blocks. Without a structured error-handling strategy, applications are vulnerable to unhandled exceptions, resource leaks, and inconsistent states. These risks increase significantly in database-driven systems, where a single failure can disrupt dependent services and compromise data integrity.

This guide covers how the .NET runtime processes exceptions, implements robust try-catch-finally constructs, and defines custom exception types. It also highlights how frameworks like dotConnect extend ADO.NET for more precise control over database exceptions and resilient data access.

What are exceptions in .NET?

In .NET, exceptions are runtime events that disrupt the normal flow of execution. They occur when something unexpected happens, like dividing by zero, accessing a null object, or failing to connect to a database.

These exceptions are handled by the Common Language Runtime (CLR), which uses a structured exception handling (SEH) model. When an exception is thrown, the CLR automatically unwinds the call stack, skipping over method calls until it finds a matching catch block. This allows errors to be handled at the right level of the application, rather than where they first occurred.

Every exception is an object that inherits from the base System.Exception class. These objects carry diagnostic details: message, stack trace, optional error codes, and InnerException for chained failures. The model is also extensible; you can define custom exceptions that align with business logic, making failures easier to trace and reason about across the system.

Next, we’ll break down how to catch, structure, and escalate exceptions, the core of resilient .NET development.

Handling exceptions in .NET

Effective exception handling in .NET Framework and modern .NET platforms is essential to building robust applications. It ensures that when errors occur, your software preserves system stability, protecting user experience and allowing developers to diagnose issues quickly.

At the heart of .NET exception handling is the try-catch-finally construct. This pattern lets you isolate risky code, respond to failures appropriately, and clean up resources regardless of the outcome. But using it properly requires more than just wrapping every other method call in a try block. It demands clarity, specificity, and restraint.

Try-catch block

The try-catch pattern is designed to separate business logic from error handling. Code that might fail is placed in the try block. Known exceptions should be caught explicitly, starting with the most specific types first, while a general catch (Exception) block can serve as a safety net for unexpected failures.

try
{
    // Code that may throw an exception
    int result = 10 / int.Parse("0");
}
catch (DivideByZeroException ex)
{
    // Handle divide by zero exception
    Console.WriteLine("An error occurred: " + ex.Message);
}
catch (Exception ex)
{
    // Handle any other exceptions
    Console.WriteLine("An unexpected error occurred: " + ex.Message);
}
                

Best practices:

  • Catch only what you can handle: Avoid swallowing exceptions silently or using empty catch blocks. Every exception caught should be logged, remediated, or rethrown with context.
  • Prefer specific exception types: Catching the base class can mask issues like OutOfMemoryException or StackOverflowException that should crash the process.
  • Use exceptions for exceptional cases only: Exceptions are expensive to throw and should represent exceptional cases, not alternate logic paths.
Pro tip
Use structured logging frameworks like Serilog or NLog to capture rich exception metadata, especially Exception.Data, InnerException, and dotConnect-specific fields like SqlState and Code. Logging these details alongside the server version accelerates root cause analysis and improves debugging precision.

The following example uses Serilog:

Log.Error(ex, "Database error occurred. SqlState: {SqlState}, Code: {Code}",
    (ex as DbException)?.SqlState,
    (ex as DbException)?.Code);
                

Logging the right metadata makes exception analysis faster and turns vague error reports into actionable insights.

Finally block

The finally block is executed regardless of whether an exception was thrown or caught. It’s typically used for cleanup operations, like closing files, disposing objects, or releasing database connections, that must happen no matter what.

try
{
    // Code that may throw an exception
}
catch (Exception ex)
{
    // Handle exception
}
finally
{
    // Cleanup code, e.g., closing files or releasing resources
}
                

Key guidance:

  • Use finally to ensure resource cleanup even if an exception short-circuits execution.
  • Avoid placing logic that could throw another exception inside the finally block: it can overwrite the original exception, complicating the diagnostics.

When used correctly, try-catch-finally is not just about survival, it’s about writing code that anticipates failure and handles it with precision.

How to throw exceptions in .NET

While handling exceptions is essential, knowing when and how to throw them is equally critical. Throwing an exception is a deliberate signal that something has gone wrong, due to a failed precondition, an invalid state, or a critical business rule violation. In .NET, this is done using the throw keyword.

To throw an exception in .NET, you typically instantiate an exception type and pass a clear, descriptive message:

throw new InvalidOperationException("The operation is not allowed in the current state.");
                

This message becomes part of the exception object and should provide actionable context.

Creating and throwing a custom exception

Built-in exceptions cover many scenarios, but enterprise applications often benefit from defining .NET custom exception types. These allow developers to signal specific error types that align with the business logic, making the codebase more expressive and easier to maintain.

public class MyCustomException : Exception
{
    public MyCustomException(string message) : base(message) { }

    public MyCustomException(string message, Exception innerException)
        : base(message, innerException) { }
}
                

You can now use your custom exception in logic checks:

public void ValidateInput(string input)
{
    if (string.IsNullOrEmpty(input))
    {
        throw new MyCustomException("Input cannot be null or empty.");
    }
}
                

Rethrowing exceptions: throw; vs throw ex;

Sometimes you need to catch an exception, log or inspect it, and then rethrow it. But there's a subtle difference in how you do that.

Correct: Rethrow while preserving the stack trace
catch (Exception ex)
{
    Log(ex);
    throw; // ✅ Preserves the original stack trace
}
                

Incorrect: Rethrow that resets the stack trace
catch (Exception ex)
{
    Log(ex);
    throw ex; // ⚠️ Resets the stack trace, making debugging harder
}
                

Pro tip
Always use throw; to rethrow the original exception. Using throw ex; resets the stack trace, making it harder to trace the error’s origin, especially in production logs.

When not to throw exceptions

While exceptions are essential for signaling critical failures, overusing them can lead to bloated, fragile code. Here’s when not to throw an exception:

  • When handling expected behavior: Use conditional logic (e.g., if, TryParse, or TryGetValue) when a situation is expected and recoverable. Throwing exceptions in normal code paths is both expensive and misleading.
  • In performance-critical sections: Exception handling is computationally expensive. In tight loops or high-throughput operations, prefer pre-validation or Try patterns (e.g., TryParse).
  • As a replacement for business logic: Don't rely on exceptions to decide the outcome of regular processes. This makes the intent of your code harder to follow and maintain.
  • When a fallback exists and can be handled gracefully: If there’s a known recovery path, use it directly. For example, check File.Exists() before attempting to load.
  • Without a clear logging or recovery strategy: Exceptions that go unlogged or uncaught can crash your application silently. Always throw with a plan for handling it somewhere meaningful.

Throwing exceptions correctly is only part of writing resilient .NET code. When database interactions are involved, you need richer insights than what standard exceptions provide. That’s where dotConnect comes in.

Key points on exception handling with dotConnect

dotConnect extends the ADO.NET architecture in .NET by delivering richer diagnostics and better metadata. This is critical in complex systems where general errors like DbException don’t offer enough clarity. Let’s delve deeper.

ADO.NET-compatible exception hierarchy

dotConnect follows the standard .NET exception inheritance model. That means developers can rely on familiar structures while benefiting from deeper provider-level insights:

  • System.Exception
  • System.SystemException
  • System.Data.Common.DbException
  • Devart.Common.DbException
  • Devart.Data.[Provider].[Provider]Exception (e.g., OracleException, MySqlException)

Each layer adds more contextual detail. dotConnect-specific exceptions expose:

  • Error codes
  • SQL state (where applicable)
  • Error message and source
  • Server version info
  • Inner exceptions for chained diagnostics

This hierarchy supports both general catch blocks and fine-grained exception handling depending on your needs.

Provider-specific exception classes

Each dotConnect provider (Oracle, MySQL, PostgreSQL, and others) introduces a dedicated exception class with provider-aware properties. For example:


These classes expose valuable fields like:

  • Errors: A list of all server-returned errors
  • Code: Numeric error identifier
  • SqlState: Code used in MySQL and PostgreSQL for categorizing errors
  • Procedure / LineNumber: Indicator of the failure location, when available

This enables deeper insights and targeted recovery strategies, especially in high-reliability systems.

Pro tip
Instead of just logging the error message, use dotConnect’s detailed metadata, like SqlState, Code, and Procedure, to tag errors by severity or type. This lets you build smarter observability dashboards and apply targeted recovery strategies based on the error source.

Integrated support for common failures

dotConnect is designed to handle the failure types developers encounter most in production. These include:

  • Connection errors: Bad credentials or unreachable servers
  • SQL syntax issues: Invalid queries or malformed commands
  • Constraint violations: Primary key, foreign key, or unique constraint failures
  • Timeouts: Often caused by long-running queries or deadlocks
  • Transaction conflicts: Issues with concurrent updates or isolation levels

By capturing these exceptions with meaningful metadata, dotConnect allows you to log, alert, retry, or gracefully degrade, depending on the context.

Sample exception handling pattern

Here’s a clean example that uses dotConnect for MySQL. It shows how to separate provider-level diagnostics from broader application logic:

try
{
    using (var connection = new MySqlConnection("your_connection_string"))
    {
        connection.Open();
        var command = new MySqlCommand("SELECT * FROM non_existent_table", connection);
        var reader = command.ExecuteReader();
    }
}
catch (MySqlException ex)
{
    Console.WriteLine($"MySQL Error: {ex.Code} - {ex.Message}");
}
catch (Exception ex)
{
    Console.WriteLine($"General Error: {ex.Message}");
}
                

When executed, this code returns two error messages: one for a MySQL error and one for a general error:

Separate error messages

Pro tip
dotConnect’s provider-specific exceptions (e.g., MySqlException, OracleException) expose rich metadata, such as Code, SqlState, Errors, and Procedure. This allows you to implement conditional recovery and smarter logging.

For instance, with Oracle, dotConnect allows catching database-specific exceptions like OracleException and inspecting error codes to apply targeted handling strategies:

catch (OracleException ex)
{
    if (ex.Code == 12154) // TNS: Could not resolve the connect identifier
    {
        // Retry with alternate connection string or log as configuration issue
    }
}
                

This level of detail makes dotConnect especially valuable in production systems, where distinguishing between a transient timeout and a misconfigured connection can be the difference between retrying and escalating.

Most popular .NET exceptions

Understanding the most common .NET Framework and .NET exception types helps developers quickly identify bugs, avoid common pitfalls, and write more defensive code. Below is a curated list of frequently encountered exceptions, along with what they mean and where they typically arise.

Exception Description Common scenarios
NullReferenceException Thrown when code tries to access a member on an object that is null. Accessing uninitialized objects or dereferencing null references.
ArgumentException Indicates that a method received an invalid argument. Passing incorrect values to method parameters.
ArgumentNullException A specialized ArgumentException; raised when a null value is passed where disallowed. API input validation; enforcing required parameters.
ArgumentOutOfRangeException Raised when a method receives a value outside its allowed range. Accessing invalid collection indexes or invalid enum values.
InvalidOperationException Signals that a method was called in an invalid state for the object. Modifying a collection during iteration; reusing closed streams.
IndexOutOfRangeException Thrown when accessing an array or collection index outside its bounds. Off-by-one errors in loops or hardcoded index assumptions.
DivideByZeroException Raised when dividing a numeric value by zero. Performing unguarded division with user input or variables.
FormatException Thrown when the format of a string is invalid for conversion. Parsing strings to numbers or dates without validation.
IOException Represents general I/O errors. File or network read/write operations that fail at runtime.
FileNotFoundException Raised when trying to access a file that doesn’t exist. Mistyped paths, missing assets in file operations.
UnauthorizedAccessException Indicates lack of permission to access a file or resource. Attempting to write to a read-only file or protected directory.
TimeoutException Thrown when an operation exceeds the defined time limit. Database queries, HTTP requests, or async tasks taking too long.
SqlException Specific to SQL Server; indicates that the database returned an error. ADO.NET operations like failed queries or invalid procedures.
DbException Represents a base class for database-related exceptions across providers. Used by ADO.NET and dotConnect for general database errors.
NotImplementedException Thrown to indicate that a method hasn’t been implemented yet. Placeholder methods during scaffolding or prototyping.
NotSupportedException Raised when a method or operation isn’t supported in the current context. Invoking unsupported features or API variations.

Why these exceptions matter

Understanding these exceptions goes beyond memorizing names. It helps you write smarter, more resilient code. By anticipating where failures are likely to occur, you can:

  • Build safer input validation routines
  • Write more meaningful unit tests
  • Catch and log errors with relevant granularity
  • Reduce debugging time and production outages

Many of these exceptions serve as early warnings, your application telling you it’s being used incorrectly or is operating outside expected conditions. Treat them as helpful signals, not just runtime nuisances.

Advanced exception handling

Basic try-catch blocks work for most situations. But in enterprise-grade applications, especially those using asynchronous code or multiple error conditions, you need more precise tools. Two powerful features in the .NET exception model address these scenarios: exception filters and AggregateException.

Exception filters

Exception filters in .NET allow you to conditionally catch exceptions based on runtime logic, without cluttering your catch block. They improve readability and make your intent explicit.

try
{
    // Code that may throw an exception
}
catch (Exception ex) when (ex.InnerException != null)
{
    // Handle only if there's an inner exception
    Console.WriteLine($"Chained error: {ex.InnerException.Message}");
}
                

You may use exception filters when you want to:

  • Catch specific cases of an exception type
  • Log certain exceptions but only handle a subset
  • Avoid unnecessary exception processing overhead

Unlike checking conditions inside a catch block, filters ensure the block only runs when your condition is met, reducing noise and improving performance.

Handling multiple exceptions with AggregateException

When running asynchronous or parallel operations, failures can occur in multiple threads or tasks simultaneously. .NET groups these into a single AggregateException, which preserves all individual exceptions in a collection.

try
{
    Task.WaitAll(
        Task.Run(() => throw new InvalidOperationException("Task 1 failed")),
        Task.Run(() => throw new FormatException("Task 2 failed"))
    );
}
catch (AggregateException ae)
{
    foreach (var ex in ae.InnerExceptions)
    {
        Console.WriteLine($"Caught: {ex.GetType().Name} - {ex.Message}");
    }
}
                

You can also use .Handle() to selectively process and suppress specific .NET exception types:

ae.Handle(ex =>
{
    if (ex is FormatException)
    {
        // Handle and suppress
        return true;
    }
    return false; // Unhandled exceptions are rethrown
});
                

When to use AggregateException?

Use AggregateException handling in scenarios where multiple operations may fail independently, such as:

  • Running parallel data processing or I/O tasks
  • Sending multiple API or microservice requests concurrently
  • Executing Task.WhenAll() where each task may throw its own exception

Real-world scenario

This pattern becomes especially valuable in distributed or data-rich applications. For example, imagine a service that loads a user's profile, order history, and product recommendations in parallel. If the recommendation engine times out, you might still return the profile and orders, logging the failure without crashing the entire response.

By catching and inspecting each inner exception individually, you gain fine-grained control: critical errors can halt execution, while recoverable ones can be handled gracefully, enhancing reliability without compromising the user experience.

Centralized exception handling

In production-grade .NET applications, particularly when managing exception handling in ASP.NET Core, you need a centralized approach. This ensures that unhandled errors are logged consistently and translated into meaningful responses for clients.

Instead of wrapping every controller action in try-catch, you can use built-in middleware to catch unhandled exceptions across the entire request pipeline.

Example: global exception middleware in ASP.NET Core

app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var error = context.Features.Get<IExceptionHandlerPathFeature>()?.Error;

        // Example: Log structured details here
        Log.Error(error, "Unhandled exception occurred at {Path}", context.Request.Path);

        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsync(JsonSerializer.Serialize(new
        {
            error = "An unexpected error occurred. Please try again later."
        }));
    });
});
                

As a result, you will get the following error message:

Global exception

Why is centralized exception handling important?

Centralized exception handling improves consistency, control, and maintainability across your application. It helps you to:

  • Return consistent response formats to clients
  • Capture and log unhandled exceptions in one place
  • Eliminate repeated try-catch logic across the codebase
  • Integrate cleanly with observability tools (e.g., Serilog, Application Insights)
Pro tip
Combine this with structured logging and dotConnect’s rich exception metadata (Code, SqlState, ServerVersion) to automatically tag errors by source and severity.

Conclusion

Robust exception handling is fundamental to building reliable .NET applications. It’s not just about catching errors, but writing code that anticipates failure, preserves context, and enables systems to recover or fail predictably.

This guide covered exception handling in .NET, including best practices, custom errors, and how dotConnect enhances diagnostics. Used well, these patterns don’t just prevent crashes; they make your codebase cleaner, more maintainable, and ready for real-world complexity.

If your application interacts with a database, now is the time to go deeper. dotConnect combines ADO.NET compatibility with precise error diagnostics, typed exceptions, and support for real-world failure scenarios; from timeouts to transaction conflicts.

Start by auditing your exception handling. Look for overly broad catches, missing validations, or ignored provider-specific errors. Then use dotConnect to close those gaps and strengthen your system’s reliability where it matters most.

Connect to data effortlessly in .NET

Streamline your .NET projects with feature-rich ADO.NET providers