Implement a Global Exception Handling in ASP.NET
Let's together learn how to become a better .NET C# engineer when using Global Exception Handling
I know you have already used many forms of exception handling in .NET C# mostly try-catch, and sometimes something a bit more clever. That is great. But here is the problem I want to put on the table for every .NET C# engineer reading this:
Is everyone on your team following the same pattern?
If your team grows from 3 to 15 engineers, will the exception handling still be consistent?
When an error occurs in production, how long does it take your team to find which layer swallowed the exception?
Do your API consumers, frontend, mobile, third-party integrators, receive a consistent error format across all your endpoints?
Are your error responses accidentally leaking stack traces or internal details to the outside world?
Are you confident that every unhandled exception in your codebase is being properly logged?
Does everyone understand the pattern you implemented?
If a new engineer joins today, can they maintain this code or only you?
Can you write a single test that verifies error handling behavior across your entire API?
These are not rhetorical. They touch six real dimensions that separate amateur exception handling from a production-grade solution: observability, API contract consistency, security, testability, team scalability, and logging discipline. By the end of this article, you will have addressed all of them. Today, we are not just talking about global exception handling in ASP.NET Core. We are talking about maintainability, something you should care deeply about as you grow in seniority. After all, we write code that must survive change over time.
Imagine you are running a large, busy insurance company. Every day, thousands of requests come in: people buying policies, canceling them, or checking their status. Now, imagine if every single clerk in your office had to personally know exactly what to do when the fire alarm goes off, when the coffee machine breaks, or when a customer starts shouting. They would be overwhelmed, and your business would grind to a halt.
In software, this is exactly what happens when we scatter error handling logic everywhere. We need a specialized team, a safety net, that catches these problems so your “clerks” (your endpoints) can focus on selling insurance.
In this article, we will explore how error handling in ASP.NET Core has evolved, moving from simple try-catch blocks to the modern, elegant IExceptionHandler.
The Context: Insurance Policy API
To make this practical, let’s assume we are building an API for an insurance company. We need an endpoint to Create a Policy.
Don’t worry about the code; I’ll present a more concise version here to avoid including too many files in this article. You can access the repository on my GitHub here.
Disclaimer: I am a strong advocate for a clear and expressive separation between domain model and data model. I like to emphasize that there are domains that should be as close as possible to the business layer, that is, that speak the language of the business, while, on the other hand, I like to separate the data layer, the data model, that is, how we will represent an HTTP response, a table in a relational database, a collection in a document database, etc. Therefore, I am informing you that I will use terms such as domain model and data model (or DTOs).
We have a simple Data Model (DTO) for the request. Don’t worry
namespace DomainDrivenDesignInsurance.Application.Commands;
public class IssuePolicyCommandRequest : ICommand<IssuePolicyCommandResponse>
{
public Guid PolicyId { get; set; }
public string PolicyHolderName { get; set; } = string.Empty;
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal Value { get; set; }
}
public class IssuePolicyCommandResponse
{
public Guid PolicyId { get; set; }
public string PolicyHolderName { get; set; } = string.Empty;
public decimal TotalPremium { get; set; }
}
And a Domain Model representing the policy. In this model we have the operations that reflect the business, such as issuing a policy, canceling a policy, adding a claim, etc.:
public class Policy : IAggregateRoot
{
public Guid Id { get; private set; }
public Guid InsuredId { get; private set; }
public string PolicyHolderName { get; private set; } = string.Empty;
public Guid BrokerId { get; private set; }
public PolicyStatus Status { get; private set; }
public Period Period { get; private set; } = NullObjectPeriod.Instance;
public Money TotalPremium { get; private set; } = NullMoney.Instance;
public static Policy Issue(Guid id, Guid insuredId, string placeHolderName, Guid brokerId, Period period, IEnumerable<Coverage> coverages)
{
if (string.IsNullOrWhiteSpace(placeHolderName)) throw new ArgumentNullException(nameof(placeHolderName));
if (period == null) throw new InvalidPeriodPolicyException(nameof(period));
if (coverages == null || !coverages.Any()) throw new EmptyCoverageException(”Policy must have at least one coverage”);
var p = new Policy
{
PolicyHolderName = placeHolderName,
Id = id == Guid.Empty ? Guid.NewGuid() : id,
InsuredId = insuredId,
BrokerId = brokerId,
Status = PolicyStatus.Active,
Period = period
};
p.AddCoverageRange(coverages);
p.TotalPremium = p.CalculateTotalPremium();
// raise domain event
p.AddDomainEvent(new PolicyIssued(p.Id, p.InsuredId, p.BrokerId));
return p;
}
// More operations
}
In our business logic, things can go wrong. We might encounter specific domain errors:
PolicyOutOfValidityPeriodException: The start date is after the end date.
InvalidPeriodPolicyException: The policy can’t be issued with inconsistent dates (start date being more than end date).
PolicyAlreadyCancelledException: Trying to modify a canceled policy.
HighClaimsRatioException: The risk is too high to insure.
Let’s see how we can handle these errors.
The Simple Try-Catch
The most intuitive way to handle errors is to wrap your code in a try-catch block right inside your controller. This works for one or two simple scenarios. But it fails as the codebase grows and the number of domain exceptions increases. How do you organize them? What happens when a new exception type is introduced and three different controllers need to handle it?
[HttpPost]
public async Task<IActionResult> CreatePolicy([FromBody] CreatePolicyRequest request)
{
try
{
var policy = await _policyService.CreateAsync(request);
return Ok(policy);
}
catch (PolicyOutOfValidityPeriodException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, “Something went wrong”);
return StatusCode(500, “Internal Server Error”);
}
}
This looks manageable. Now imagine the API grows. You add a CancelPolicy endpoint and a AddClaim endpoint. Each one also needs to handle domain exceptions. Here is what your codebase starts to look like:
[HttpPatch(“{id}/cancel”)]
public async Task<IActionResult> CancelPolicy(Guid id)
{
try
{
await _policyService.CancelAsync(id);
return NoContent();
}
catch (PolicyAlreadyCancelledException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (PolicyOutOfValidityPeriodException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, “Something went wrong”);
return StatusCode(500, “Internal Server Error”);
}
}
[HttpPost(“{id}/claims”)]
public async Task<IActionResult> AddClaim(Guid id, [FromBody] AddClaimRequest request)
{
try
{
await _claimService.AddAsync(id, request);
return Created();
}
catch (HighClaimsRatioException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (PolicyAlreadyCancelledException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, “Something went wrong”);
return StatusCode(500, “Internal Server Error”);
}
}Notice what is happening. PolicyAlreadyCancelledException is handled in two different controllers. _logger.LogError is duplicated everywhere. If tomorrow you need to change the error response format, you have to hunt down every single catch block across the codebase. With 15 controllers and 4 exception types each, that is 60 places to get right, and 60 places to get wrong.
This is a DRY violation at the infrastructure level. Your controllers are spending time on cross-cutting concerns instead of focusing on business logic.
Custom Middleware
To avoid scattering various try-catch blocks across our commands, queries, and event handlers, ASP.NET Core has an incredible middleware pipeline concept, which allows us to organize this mess of exceptions and try-catch blocks throughout our codebases. We simply create a middleware that has the sole responsibility of handling exceptions globally. In this way, we minimize the possibility of errors in our codebase.
using DomainDrivenDesignInsurance.Domain.Exceptions;
namespace DomainDrivenDesignInsurance.API.Middleware;
public class GlobalExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;
public GlobalExceptionHandlingMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch(Exception exception)
{
_logger.LogError(exception, “An unhandled exception occurred.”);
await HandleExceptionAsync(context, exception);
}
}
public sealed record ErrorDetails(int StatusCode, string Message, Dictionary<string, string[]>? Errors);
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = “application/json”;
var response = exception switch
{
PremiumCalculationViolationException premiumEx => new ErrorDetails(StatusCodes.Status400BadRequest, “Premium calculation error”, premiumEx.Errors),
ValidationException validationEx => new ErrorDetails(StatusCodes.Status400BadRequest, “Validation failed”, validationEx.Errors),
NotFoundException notFoundEx => new ErrorDetails(StatusCodes.Status404NotFound, “Resource not found”, notFoundEx.Errors),
UnauthorizedException unauthorizedException => new ErrorDetails(StatusCodes.Status401Unauthorized, “Unauthorized access”, unauthorizedException.Errors),
ForbiddenException forbiddenException => new ErrorDetails(StatusCodes.Status403Forbidden, “Access forbidden”, forbiddenException.Errors),
InvalidPeriodPolicyException invalidPeriodEx => new ErrorDetails(StatusCodes.Status400BadRequest, invalidPeriodEx.Message, invalidPeriodEx.Errors),
_ => new ErrorDetails(StatusCodes.Status500InternalServerError, “An internal server error occurred”, null)
};
context.Response.StatusCode = response.StatusCode;
await context.Response.WriteAsJsonAsync(response);
}
}
public static class GlobalExceptionHandlingMiddlewareExtensions
{
public static IApplicationBuilder UseGlobalExceptionHandlingMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<GlobalExceptionHandlingMiddleware>();
}
}Don’t forget to add the middleware to the request pipeline in your Program.cs:
app.UseGlobalExceptionHandlingMiddleware();This is much better. Your controllers are clean and focused on business logic. The three questions from the introduction about team scalability, consistent patterns, and new engineer onboarding are now addressed in a single place. But we still have two open problems.
The first one is structural. Notice that in the switch block, we currently handle six exception types. That number grows fast. In a real insurance platform, you could easily have exceptions for PolicyNotRenewableException, ClaimAlreadySettledException, InsufficientCoverageException, and dozens more as the domain evolves.
Now ask yourself: every time a new domain exception is introduced, who owns this file? Who reviews the pull request to make sure the new case was added correctly? What happens if a developer creates BrokerSuspendedException, throws it deep inside a command handler, but forgets to register it here? The API silently returns a 500. Your client gets no useful information. Your on-call engineer opens Grafana at 2am trying to understand why a broker cannot issue policies.
This middleware solved the duplication problem, but it introduced a new one: it is a single class that must know about every exception in the system. That is a violation of the Open/Closed Principle. Adding a new exception type should not require modifying existing, tested infrastructure code.
The second problem is the response format itself. We are returning our own ErrorDetails record, which is an arbitrary structure we invented. Now ask yourself:
What does your frontend developer do when
CreatePolicyreturns{“error”: “...”}butCancelPolicyreturns{“message”: “...”}andAddClaimreturns{“err”: “...”}?How does your API gateway or load balancer know whether a 400 is a validation error or a business rule violation?
If a third-party integrator consumes your API today and you change the error shape tomorrow, how do they find out?
This is not a minor concern. Inconsistent error formats are one of the most common sources of friction between backend and frontend teams, and a silent contract break that no compiler will catch.
A Note on RFC 9457 (Problem Details)
The IETF recognized this problem across the entire industry and published RFC 9457, which defines a standard JSON structure called “Problem Details” for HTTP APIs. Every field has a defined purpose:
Field Description type A URI identifying the error type. Should be a stable, documented URL. title A short, human-readable summary of the problem type. status The HTTP status code. detail A human-readable explanation specific to this occurrence. instance A URI identifying the specific request that caused the error.
When every API speaks this language, your frontend knows exactly where to look for the error type, your API gateway can route based on type, and your third-party integrators have a stable contract. The format is extensible too: you can add custom fields like policyId or claimNumber without breaking the standard.
IProblemDetailsService
Starting from .NET 7, ASP.NET Core ships with native support for RFC 9457 through IProblemDetailsService. This interface, registered via the built-in DI container, handles the serialization of ProblemDetails objects into RFC-compliant HTTP responses. You no longer need to manage ContentType, status codes, or JSON serialization manually.
To make it available, register it in Program.cs before building the app:
builder.Services.AddProblemDetails();
// ...
app.UseProblemDetailGlobalExceptionHandlingMiddleware();Now we can upgrade our middleware to delegate the response writing to IProblemDetailsService:
using DomainDrivenDesignInsurance.Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
namespace DomainDrivenDesignInsurance.API.Middleware;
public class ProblemDetailGlobalExceptionHandlingMiddleware(
RequestDelegate next,
IProblemDetailsService problemDetailsService,
ILogger<ProblemDetailGlobalExceptionHandlingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch(Exception exception)
{
logger.LogError(exception, “An unhandled exception occurred.”);
await HandleExceptionAsync(context, exception);
}
}
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.StatusCode = exception switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
EmptyCoverageException => StatusCodes.Status400BadRequest,
HighClaimsRatioException => StatusCodes.Status400BadRequest,
InvalidCoverageException => StatusCodes.Status400BadRequest,
InvalidPeriodPolicyException => StatusCodes.Status400BadRequest,
ValidationException => StatusCodes.Status400BadRequest,
PolicyAlreadyCancelledException => StatusCodes.Status400BadRequest,
PolicyOutOfValidityPeriodException => StatusCodes.Status400BadRequest,
PremiumCalculationViolationException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};
await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = $”https://api.insurance.com/errors/{exception.GetType().Name.Replace(“Exception”, string.Empty).ToLowerInvariant()}”,
Status = context.Response.StatusCode,
Title = exception.GetType().Name.Replace(“Exception”, string.Empty),
Detail = exception.Message
}
});
}
}
public static class ProblemDetailGlobalExceptionHandlingMiddlewareExtensions
{
public static IApplicationBuilder UseProblemDetailGlobalExceptionHandlingMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<ProblemDetailGlobalExceptionHandlingMiddleware>();
}
}Notice the use of TryWriteAsync instead of a direct write. The Try prefix is intentional: if the response has already started (headers already sent), it returns false and skips writing rather than throwing. This prevents a secondary exception from masking the original one.
Also notice the Type field. It is now a URI pointing to a stable, documented endpoint in your API domain, not a .NET class name. RFC 9457 requires type to be a URI reference. Using a .NET fully qualified type name like DomainDrivenDesignInsurance.Domain.Exceptions.PremiumCalculationViolationException is an implementation detail that leaks internal structure to your clients and breaks the standard.
Execute the API with dotnet run and access it with the following curl command. Setting value to 0 triggers PremiumCalculationViolationException:
curl -X ‘POST’ \
‘http://localhost:5259/v1/api/policy’ \
-H ‘accept: application/json’ \
-H ‘Content-Type: application/json’ \
-d ‘{
“policyHolderName”: “string”,
“startDate”: “2025-11-22T16:34:45.577Z”,
“endDate”: “2025-11-22T16:34:45.577Z”,
“value”: 0
}’You will see this response:
{
“type”: “https://api.insurance.com/errors/premiumcalculationviolation”,
“title”: “PremiumCalculationViolation”,
“status”: 400,
“detail”: “Calculated premium cannot be zero or negative. Please review coverages and their sum insured amounts.”,
“traceId”: “00-feeacfba6d89511978c55fe4d83611e9-e3f4b6223af7be2c-00”
}The response is now RFC 9457 compliant. Your API consumers have a stable type URI they can use to identify the error programmatically, a title that reflects the actual problem type, and a detail field with a human-readable explanation.
However, the switch block is still here. Every new domain exception still requires a modification to this class. The Open/Closed Principle violation we identified earlier has not been solved. We improved the response format, but the structural problem remains. That is exactly what IExceptionHandler was designed to fix.
The new way: IExceptionHandler
ASP.NET Core 8 introduced IExceptionHandler, a composable alternative to the monolithic middleware. Instead of one class that knows about every exception in the system, you register multiple small handlers, each with a single responsibility. The pipeline calls them in registration order and stops at the first one that returns true.
The interface is simple:
public interface IExceptionHandler
{
ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken);
}TryHandleAsync returns true if the handler dealt with the exception and false if it did not, passing control to the next handler in the chain. This is the mechanism that enables composability.
Building the handlers
Let’s start with a handler responsible for insurance domain exceptions:
using DomainDrivenDesignInsurance.Domain.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace DomainDrivenDesignInsurance.API.ExceptionHandlers;
public class InsuranceDomainExceptionHandler(
ILogger<InsuranceDomainExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not (
PolicyAlreadyCancelledException or
PolicyOutOfValidityPeriodException or
InvalidPeriodPolicyException or
HighClaimsRatioException or
EmptyCoverageException or
PremiumCalculationViolationException or
InvalidCoverageException))
{
return false;
}
logger.LogWarning(exception, "Domain rule violation: {ExceptionType}", exception.GetType().Name);
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = $"https://api.insurance.com/errors/{exception.GetType().Name.Replace("Exception", string.Empty).ToLowerInvariant()}",
Status = StatusCodes.Status400BadRequest,
Title = exception.GetType().Name.Replace("Exception", string.Empty),
Detail = exception.Message
}
}, cancellationToken);
return true;
}
}Notice LogWarning instead of LogError. A PolicyAlreadyCancelledException is an expected business rule violation, not a system failure. Using LogError for every exception inflates your error dashboards and makes it harder to distinguish real problems from expected domain rejections. Reserve LogError for the unexpected.
Now the fallback handler, which catches everything that no previous handler claimed:
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace DomainDrivenDesignInsurance.API.ExceptionHandlers;
public class FallbackExceptionHandler(
ILogger<FallbackExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
logger.LogError(exception, "Unhandled exception of type {ExceptionType}", exception.GetType().Name);
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = "https://api.insurance.com/errors/internal-server-error",
Status = StatusCodes.Status500InternalServerError,
Title = "Internal Server Error",
Detail = "An unexpected error occurred. Please try again later."
}
}, cancellationToken);
return true;
}
}The fallback always returns true and always uses LogError, because anything that reaches it is genuinely unexpected.
Security: what you expose in error responses
Notice that the FallbackExceptionHandler deliberately does not pass exception.Message to the Detail field. It uses a hardcoded generic message instead: "An unexpected error occurred. Please try again later." This is intentional. When an unhandled exception reaches the fallback, you have no guarantee about what that message contains. It could be a database connection string, an internal file path, a SQL statement, or a stack frame reference. Sending that to the client is an information disclosure vulnerability.
The InsuranceDomainExceptionHandler, on the other hand, does pass exception.Message directly. This is acceptable because domain exceptions are constructed by your own domain layer with messages that are deliberately written for external consumption: "Calculated premium cannot be zero or negative" or "Policy is already cancelled". Your domain controls the message. The infrastructure layer does not.
The rule is simple: only pass exception.Message to Detail when you own the exception and you wrote the message yourself. For every exception that comes from a third-party library, the runtime, or the framework, use a generic message and log the real one server-side.
Registration and execution order
Register both handlers in Program.cs and replace the old middleware with UseExceptionHandler:
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<InsuranceDomainExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();
// ...
app.UseExceptionHandler();The order of AddExceptionHandler calls defines the pipeline order. InsuranceDomainExceptionHandler runs first. If it returns false (not a domain exception), FallbackExceptionHandler runs next. The fallback always returns true, so no exception escapes unhandled.
How this resolves the OCP violation
Tomorrow your team adds BrokerSuspendedException. What changes? You create one new class:
public class BrokerExceptionHandler(
ILogger<BrokerExceptionHandler> logger,
IProblemDetailsService problemDetailsService) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (exception is not BrokerSuspendedException)
return false;
logger.LogWarning(exception, "Broker access violation: {ExceptionType}", exception.GetType().Name);
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Type = "https://api.insurance.com/errors/brokersuspended",
Status = StatusCodes.Status403Forbidden,
Title = "BrokerSuspended",
Detail = exception.Message
}
}, cancellationToken);
return true;
}
}And register it:
builder.Services.AddExceptionHandler<InsuranceDomainExceptionHandler>();
builder.Services.AddExceptionHandler<BrokerExceptionHandler>();
builder.Services.AddExceptionHandler<FallbackExceptionHandler>();InsuranceDomainExceptionHandler was not touched. FallbackExceptionHandler was not touched. The existing handlers are closed for modification and the system is open for extension. That is the Open/Closed Principle applied to infrastructure code.
This structure also answers two questions from the introduction directly: does everyone understand the pattern you implemented? and if a new engineer joins today, can they maintain this code?
Think about what a new engineer sees when they open this codebase. There is a folder called ExceptionHandlers/. Inside it, every file has a name that describes exactly what it handles: InsuranceDomainExceptionHandler, BrokerExceptionHandler, FallbackExceptionHandler. Each file is a single class with a single method. There is no hidden switch, no central registry to decode, no shared state between handlers. A developer who has never seen this codebase before can find the handler for BrokerSuspendedException in under ten seconds, understand it in under a minute, and modify it without fear of breaking anything else. That is what maintainability looks like in practice.
Testing exception handlers in isolation
The last question from the introduction was: can you write a single test that verifies error handling behavior across your entire API? With IExceptionHandler, you can go further than that. You can test each handler as a plain class, with no HTTP server, no WebApplicationFactory, and no request pipeline involved.
Here is a test for InsuranceDomainExceptionHandler using xUnit and NSubstitute:
using DomainDrivenDesignInsurance.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace DomainDrivenDesignInsurance.Tests.ExceptionHandlers;
public class InsuranceDomainExceptionHandlerTests
{
private readonly InsuranceDomainExceptionHandler _handler;
public InsuranceDomainExceptionHandlerTests()
{
var logger = Substitute.For<ILogger<InsuranceDomainExceptionHandler>>();
var problemDetailsService = Substitute.For<IProblemDetailsService>();
_handler = new InsuranceDomainExceptionHandler(logger, problemDetailsService);
}
[Fact]
public async Task TryHandleAsync_WhenDomainException_ReturnsTrueAndSetsBadRequest()
{
var context = new DefaultHttpContext();
var exception = new PolicyAlreadyCancelledException("Policy is already cancelled.");
var result = await _handler.TryHandleAsync(context, exception, CancellationToken.None);
Assert.True(result);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
}
[Fact]
public async Task TryHandleAsync_WhenUnknownException_ReturnsFalse()
{
var context = new DefaultHttpContext();
var exception = new InvalidOperationException("Something unexpected happened.");
var result = await _handler.TryHandleAsync(context, exception, CancellationToken.None);
Assert.False(result);
}
}The handler is a plain class. DefaultHttpContext gives you a real HttpContext in memory. There is no routing, no middleware pipeline, no integration overhead. You assert on the return value and on the HTTP status code set directly on the context. Testing FallbackExceptionHandler and BrokerExceptionHandler follows the exact same pattern. Each handler lives in isolation, fails in isolation, and can be tested in isolation. That was not possible with a monolithic middleware switch.
Comparison
Here is a summary of the four approaches covered in this article:
The progression from try-catch to IExceptionHandler is not about replacing working code for the sake of it. Each step in this article solved a real problem that the previous step introduced. Try-catch works until it duplicates. Custom middleware centralizes until it becomes a god class. IProblemDetailsService standardizes the response format but does not address the structural problem. IExceptionHandler closes all remaining gaps.
Going back to the nine questions from the beginning of this article: by adopting IExceptionHandler with IProblemDetailsService, you now have a single place to observe exceptions with appropriate severity levels, a stable RFC 9457 contract for all API consumers, no internal details leaking through responses, independently testable handlers, a structure that scales as the team grows, and guaranteed logging for every unhandled exception.


