Modern Exception Handling Strategies
Exception handling looks simple on the surface: throw when something goes wrong, catch when you can fix it. But real applications rarely stay that neat. Errors come from multiple sources, and things get unpredictable.
That’s where a structured approach makes all the difference. Understanding the role of exceptions, when they help, when they hurt, and how to shape a consistent error pipeline turns scattered try/catch blocks into a reliable design.
In this article, we’ll break down how exceptions actually work, why we need global handling, which mechanism ASP has for that, including filters, middleware, exception handlers, where patterns like Result<T> offer a better alternative, and what the heck is ProblemDetails. By the end, you’ll see how these pieces form a unified and maintainable way to deal with failures.
Ready to dive in? Let’s start 💥.
Behold the Exception
When you define a method, there’s always a chance it will be used incorrectly.
For example, somebody will try to divide by zero.
static class Math
{
public static int Divide(int a, int b)
{
return a / b;
}
}Instead of checking every possible invalid case, returning special “magic” values, or silently ignoring errors, it’s more reliable to throw an exception.
Exceptions clearly communicate that something went wrong, stop the operation immediately, and ensure the issue can be handled in a consistent way.
static class Math
{
public static int Divide(int a, int b)
{
if (b == 0)
{
throw new DivideByZeroException("You can't divide by zero!");
}
return a / b;
}
}Exception Handling
Exception handling is essential because things will go wrong in your application: invalid input, missing files, network hiccups, etc.
Instead of letting the app crash or silently continue with broken data, exceptions give you a controlled way to detect problems and react to them.
int a = int.Parse(Console.ReadLine());
int b = int.Parse(Console.ReadLine());
try
{
var result = Math.Divide(a, b);
Console.WriteLine(result);
}
catch (DivideByZeroException)
{
Console.WriteLine("You can’t divide by zero.");
}It is a good practice to add try/catch:
- when the exception is part of the normal flow
- when you can fix the problem right here
- when you need to catch one exception and re-throw another one
Result *
Following functional and structured programming principles, many developers prefer to avoid exceptions and use the Resultpattern instead.
The idea is simple: instead of throwing an exception, a method returns an object that clearly tells you whether it succeeded and, if not, why.
class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public IEnumerable<ErrorCode> Errors { get; }
. . .
}
static class Math
{
public static Result<int> Divide(int a, int b)
{
if (b == 0) return Result.Error(ErrorCode.CannotDivideByZero);
return Result.Success(a / b);
}
}Why? It is more expressive; it forces you to handle outcomes explicitly instead of digging through nested calls to see whether you need try/catch block or not.
var result = Math.Divide(2, 0);
if (!result.IsSuccess)
{
Console.WriteLine(result.Errors);
return;
}
Console.WriteLine(result.Value);I’m not trying to convince you that one approach is better 🙃 — you can experiment with the Result pattern yourself.
Just keep in mind that if you choose this path, it’s best to commit to it from the start; otherwise, you’ll end up rewriting large parts of your app to stay consistent.
And honestly, exceptions don’t happen that often anyway, so in many cases, you won’t need a try/catch nearly as much as you might think.
Global Exception Handling
Handling exceptions is important, since you can’t let your app crash. At the same time, wrapping every single line in try/catch, is a bad idea, since:
- it’s unrealistic 😅
- result in noisy code
- duplicated logic
- will turn your code into an unreadable mess
One way to catch unhandled exceptions is to subscribe to the AppDomain.UnhandledException event:
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = (Exception)e.ExceptionObject;
Console.WriteLine($"Fatal error: {ex.Message}");
};
int a = int.Parse(Console.ReadLine());
int b = int.Parse(Console.ReadLine());
var result = Math.Divide(a, b);
Console.WriteLine(result);You can still see it in ASP to log exceptions that occurred before the server is fully configured and started, errors that occur outside the regular exception-handling pipeline (in background tasks), etc.
However, with it, the app is still crashing.
Another way to deal with it would be a global exception handler.
try
{
int a = int.Parse(Console.ReadLine());
int b = int.Parse(Console.ReadLine());
var result = Math.Divide(a, b);
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"Fatal error: {ex.Message}");
}You still use specific exception handling where it makes sense, but the global handler is your safety net for everything else. It keeps your code readable, consistent, and much easier to maintain.
Exception Handling in ASP
You often don’t see a “global exception handler” in ASP, WPF, WinForms, or other frameworks.
They already handle exceptions for you. So even if your server runs code like this, and someone attempts to divide by zero:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();The application will continue running. However, the client will receive…
Yeap 😅. Basically nothing — an empty response.
The global error handling guarantees the server remains stable for other users.
However, developers would like to customize a response. As a result, you can often see multiple try/catch blocks in each endpoint.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/divide", (int a, int b) =>
{
try
{
return a / b;
}
catch (Exception ex)
{
return new
{
type = ex.GetType().Name,
error = ex.Message,
};
}
});
app.Run();This means code duplication and just ugly code 🙃.
The great part is that we can fully customize the error-handling pipeline. So let’s take a look at the options available to us.
Filters
One option could be exception filters.
To create one, you implement IExceptionFilter or IAsyncExceptionFilter, or inherit from ExceptionFilterAttribute. Inside the filter, you can transform the exception into a consistent response:
public class GlobalExceptionFilter : Attribute, IExceptionFilter
{
public void OnException(ExceptionContext context)
{
var exception = context.Exception;
var result = new
{
type = exception.GetType().Name,
error = exception.Message,
};
context.Result = new JsonResult(result)
{
StatusCode = StatusCodes.Status500InternalServerError,
};
context.ExceptionHandled = true; // prevent further propagation
}
}They can be applied not only globally:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>(); // 👈 global level
});
var app = builder.Build();
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();But also useful when you want custom error handling logic that depends on which controller or action is being executed.
[ApiController]
[Route("[controller]")]
[ActionExceptionFilter] // 👈 controller level
public class PaymentsController : ControllerBase
{
[HttpGet("refund")]
[ActionExceptionFilter] // 👈 action level
public IActionResult Refund()
{
throw new Exception("Action-level filter exception");
}
}Now, if an exception occurs, your client will receive a consistent response:
{
"type": "DivideByZeroException"
"error": "Attempted to divide by zero.",
}The problem with exception filters is that they run inside the MVC pipeline only. Which means they do NOT catch exceptions from middlewares, routing, model binding, etc.
Middleware
Similar behavior could be achieved with middleware that catches everything in the HTTP pipeline.
public class GlobalExceptionMiddleware
{
private readonly RequestDelegate _next;
public GlobalExceptionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
context.Response.ContentType = MediaTypeNames.Application.Json;
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
var result = new
{
type = exception.GetType().Name,
error = exception.Message,
};
await context.Response.WriteAsJsonAsync(result);
}
}
}Which should be registered in the pipeline:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseMiddleware<GlobalExceptionMiddleware>(); // 👈
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();Alternatively, you can implement the middleware directly in the pipeline:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (Exception ex)
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = MediaTypeNames.Application.Json;
var result = new
{
type = ex.GetType().Name,
error = ex.Message,
};
await context.Response.WriteAsJsonAsync(result);
}
});
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();Keep in mind that you don’t need to handle every exception type (like NotFoundException , ValidationException, etc) in a single middleware. That quickly becomes messy. If necessary, break the logic into multiple middlewares to keep things clean and maintainable.
UseDeveloperExceptionPage
When your app runs in the Development environment, ASP.NET Core automatically adds UseDeveloperExceptionPage middleware.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// no need to explicitly register it
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage(); // 👈
}
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();Instead of an empty response, this middleware shows a detailed error page whenever an unhandled exception occurs. It includes the exception type, message, full stack trace, and even the exact line in your code that caused the failure:
System.DivideByZeroException: Attempted to divide by zero.
in \Program.cs:line 31
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)When developing in ASP.MVC and not just API, it is even more valuable, since it shows the entire page, highlights the exact MVC action, route values, model bindings, and view files involved in the error.
This is extremely helpful during debugging because you instantly see what went wrong and where.
However, this should not be used in Production for security reasons.
UseExceptionHandler
In Production instead of custom middleware, Microsoft suggests using .UseExceptionHandler().
UseExceptionHandler redirects unhandled exceptions to /error endpoint, where you can write your custom logic.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler(errorHandlingPath: "/error"); // 👈
}
// 👇
app.MapGet("/error", (HttpContext httpContext) =>
{
var exceptionFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
var ex = exceptionFeature?.Error;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
return new
{
type = ex.GetType().Name,
error = ex.Message,
};
});
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();.UseExceptionHandler() is part of the ASP built-in exception pipeline, and it correctly handles:
- exceptions during routing
- short-circuiting the pipeline
- clearing the response buffer
- rewriting the request to the error endpoint
- avoiding middleware ordering mistakes
A custom middleware rarely gets all of this right.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
If you don’t want to define a separate endpoint for it, you can implement exception handling logic directly in this middleware:
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Run(async httpContext =>
{
var exceptionFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
var ex = exceptionFeature?.Error;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
var result = new
{
type = ex.GetType().Name,
error = ex.Message,
};
await httpContext.Response.WriteAsJsonAsync(result);
});
});Or you can even get more customization options using ExceptionHandlerOptions:
app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = async (httpContext) =>
{
var exceptionFeature = httpContext.Features.Get<IExceptionHandlerFeature>();
var ex = exceptionFeature?.Error;
var result = new
{
type = ex.GetType().Name,
error = ex.Message,
};
await httpContext.Response.WriteAsJsonAsync(result);
},
StatusCodeSelector = (ex) =>
{
if (ex is DivideByZeroException)
{
return StatusCodes.Status400BadRequest;
}
return StatusCodes.Status500InternalServerError;
},
});IExceptionHandler
For those who are used to classes, you can define your logic in a separate class, similar to filters or middlewares, by inheriting IExceptionHandler:
class CustomExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var result = new
{
type = exception.GetType().Name,
error = exception.Message,
};
await httpContext.Response.WriteAsJsonAsync(result);
// true - this exception is handled
// false - if exception is not handler and you need to redirect to error endpoint
return true;
}
}After registering it with .AddExceptionHandler<T>(), the middleware pipeline uses .UseExceptionHandler("/error") to ensure all unhandled errors flow through this handler.
var builder = WebApplication.CreateBuilder(args);
// 👇
builder.Services.AddExceptionHandler<CustomExceptionHandler>();
var app = builder.Build();
// 👇
app.UseExceptionHandler("/error");
app.MapGet("/divide", (int a, int b) =>
{
return a / b;
});
app.Run();Notice, you can register many exception handlers:
- if an exception handler handles a request, it can return true to stop processing
- if an exception handler does not handle a request, it can return false to pass the exception to the next registered handler
- if an exception isn't handled by any exception handler, then control falls back to the default behavior from the middleware
Model Validation
Model validation in ASP Core lets you ensure incoming requests contain correct data before your logic executes.
You can apply validation using built-in attributes, or external libraries like FluentValidation:
public class DivideRequestDto
{
[Required]
public int Dividend { get; set; }
[Required]
[NotZero(ErrorMessage = "Divisor cannot be zero.")] // 👈 custom validation
public int Divisor { get; set; }
}For custom validation, you can create your own constraints:
public class NotZeroAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is int intValue && intValue == 0)
{
return new ValidationResult(ErrorMessage);
}
return ValidationResult.Success;
}
}Later, you can validate in the controller whether you receive a valid input, extract all validation errors, and throw an exception yourself:
[Route("api/[controller]")]
public class MathController : ControllerBase
{
[HttpPost("divide")]
public int Divide([FromBody] DivideRequestDto request)
{
if (!ModelState.IsValid) // 👈
{
IEnumerable<ModelError> allErrors = ModelState.Values.SelectMany(v => v.Errors);
string errorMessage = string.Join(", ", allErrors.Select(x => x.ErrorMessage));
throw new InvalidOperationException(errorMessage);
}
return request.Dividend / request.Divisor;
}
}That will be handled by one of your global exception handlers.
However, once you add [ApiController], model validation becomes automatic. ASP.NET Core intercepts invalid requests before they reach your action:
[ApiController] // 👈
[Route("api/[controller]")]
public class MathController : ControllerBase
{
[HttpPost("divide")]
public int Divide(/*[FromBody]*/ DivideRequestDto request)
{
return request.Dividend / request.Divisor;
}
}The [ApiController] does:
- automatic model state validation
- automatically use the binding source (
[FromBody],[FromQuery], etc) attributes based on HTTP method (POST,GETetc) - other cool stuff 😅
As a result, your response would look like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Divisor": [
"Divisor cannot be zero."
]
},
"traceId": "00-1dd38615cb20ac7814c8bf418d8517b9-03bc27be213b3dd5-00"
}If the default output is too verbose, you can fully customize the validation response via ConfigureApiBehaviorOptions. This lets you return cleaner or domain-specific formatting:
builder
.Services
.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
IEnumerable<ModelError> allErrors = context.ModelState.Values.SelectMany(v => v.Errors);
string errorMessage = string.Join(", ", allErrors.Select(x => x.ErrorMessage));
var error = new
{
type = "InvalidRequest",
error = errorMessage,
};
return new BadRequestObjectResult(error);
};
});This time, you will end up with something similar to what we had before:
{
"type": "InvalidRequest",
"error": "Divisor cannot be zero."
}Result
Although I’m not a big fan of the Result object and still prefer traditional exceptions in my C# code, I still find a place for it.
A Result wrapper gives you a predictable, uniform response structure for both successful and failed operations. This consistency makes client-side processing much simpler: every response looks the same, regardless of whether the action succeeded or threw an error.
In ASP, you can achieve this by adding a custom filter that wraps both successful results and exceptions. Here’s an example of the filter I used:
public class ResultWrapperFilter : ActionFilterAttribute, IExceptionFilter
{
// wrap successful result
public override void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is not null)
{
var objectResult = (context.Result as ObjectResult).Value;
var result = MyResult.SuccessResult(objectResult);
context.Result = new OkObjectResult(result);
}
}
// wrap error result
public void OnException(ExceptionContext context)
{
if (context.Exception is MyException ex)
{
var result = MyResult.FailedResult(ex.ErrorCode, ex.Message);
context.Result = CreateErrorResult(ex.ErrorCode, result);
context.ExceptionHandled = true;
}
IActionResult CreateErrorResult(WebAppErrorCode errorCode, WebAppResult<object> result)
{
return errorCode switch
{
MyErrorCode.EntityNotFound => new NotFoundObjectResult(result),
_ => new BadRequestObjectResult(result),
};
}
}
}Where my Result object looked something like this:
public record MyResult<TData>
{
public bool IsSuccessful { get; init; }
public TData Data { get; init; }
public IReadOnlyCollection<ErrorInfo> Errors { get; init; } = new List<ErrorInfo>();
}
public record ErrorInfo(int errorCode, string message);And a few static helpers for building results:
public record MyResult: MyResult<object>
{
public static MyResult<T> SuccessResult<T>(T data)
{
return new WebAppResult<T>
{
IsSuccessful = true,
Data = data,
};
}
public static MyResult<object> FailedResult(IEnumerable<ErrorInfo> errors)
{
return new MyResult<object>
{
IsSuccessful = false,
Errors = errors,
};
}
}This pattern ensures every controller action returns a consistent envelope.
HTTP/1.1 200 Ok
Content-Type: application/json
{
"isSuccessful": true,
"data": {
"id": 42,
"name": "Alice",
"email": "alice@example.com"
},
"errors": []
}
- or -
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"isSuccessful": false,
"data": null,
"errors": [
{
"errorCode": 19200, // custom error code
"message": "The provided user ID is invalid."
}
]
}While this example uses an MVC filter, you can apply the same idea at different layers.
In fact, this approach fits very well into aspect-oriented programming, where cross-cutting concerns — such as error handling or response shaping — are applied transparently.
If you’re using MediatR, you can implement similar behavior with pipeline behaviors to wrap handler results or transform exceptions into Result objects at the application level.
ProblemDetails
Turned out I was not the only developer who thought that it would be a good idea to return a consistent error response 😒.
Just as REST defines conventions for structuring your API using GET, POST, DELETE, and other HTTP methods, there is also a recommended standard for how APIs should return error responses. This convention is known as Problem Details.
The format is as follows:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "https://en.wikipedia.org/wiki/Division_by_zero", // (required) link to documentation for the problem type
"title": "System.DivideByZeroException", // (required) short, human-readable summary of the problem type
"status": 500, // (optional) the HTTP status code
"detail": "Division by zero is not allowed.", // (optional) a human-readable explanation of the problem.
"instance": "/divide?a=19&b=0", // (optional) a URI where the problem occured
// (optional) additional information
"invalid-params": [
{
"name": "b",
"reason": "cannot equal 0"
}
]
}The simplest way to produce this format is to return it directly from your endpoint instead of throwing an exception:
app.MapGet("/divide/{a:int}/{b:int}", (int a, int b) =>
{
if (b == 0)
{
// throw new DivideByZeroException("Division by zero is not allowed.");
return Results.Problem(
title: "DivideByZeroException",
detail: "Divison by zero is not allowed.",
type: "https://en.wikipedia.org/wiki/Division_by_zero",
statusCode: StatusCodes.Status500InternalServerError,
);
}
return Results.Ok(a / b);
});But wait a second — we’re talking about exceptions here 🤔. So let’s approach it properly 😅.
If you want all exceptions in your application to be transformed into Problem Details responses, you need :
- register the required services in DI
- enable one of the exception-handling middleware
This way, every unhandled exception will automatically be converted into a standardized Problem Details response format.
var builder = WebApplication.CreateBuilder(args);
// 1. 👇
builder.Services.AddProblemDetails();
var app = builder.Build();
// 2. 👇
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
{
app.UseExceptionHandler();
}
app.MapGet("/divide/{a:int}/{b:int}", (int a, int b) =>
{
if (b == 0)
{
throw new DivideByZeroException("Division by zero is not allowed.");
}
return a / b;
});
app.Run();By the way, these two modes produce different results.
When you use the development version with app.UseDeveloperExceptionPage(), the response becomes much more detailed because it’s designed to help you debug issues quickly:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "System.DivideByZeroException",
"status": 500,
"detail": "Division by zero is not allowed.",
"headers": { . . . },
"exception": {
"details": "System.DivideByZeroException: Division by zero is not allowed.",
"path": "/divide?a=19&b=0",
"endpoint": "HTTP: GET /divide",
"routeValues": {
"a": "19",
"b": "0"
}
},
"traceId": "00-5484a7c8533b37fe8a9496ed38cec3cd-125334e43892b6ec-00"
}Meanwhile, the production variant app.UseExceptionHandler() is more concise and may intentionally hide error details to avoid leaking sensitive information.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-fa965b171f115dd0b443b87847441395-dda771b7b331ed25-00"
}Customizing Problem Details
Problem Details is great, but remember — it’s only a convention, and it’s intentionally designed to be extended when needed.
ASP lets you customize how ProblemDetails object. You can do this using any of the following options:
- when registering in the DI
- by implementing a custom
IProblemDetailsWriter - invoking
IProblemDetailsServicemanually inside middleware to generate Problem Details yourself
If you don’t need any heavy customization and only want to tweak a few fields or add extra metadata, you can simply adjust the Problem Details during registration in the DI container:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = customize =>
{
context.ProblemDetails.Instance = $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
customize.ProblemDetails.Title = customize.Exception.GetType().Name;
customize.ProblemDetails.Extensions.Add("nodeId", Environment.MachineName);
context.ProblemDetails.Extensions.TryAdd("requestId", context.HttpContext.TraceIdentifier);
var activity = context.HttpContext.Features.Get<IHttpActivityFeature>()?.Activity;
context.ProblemDetails.Extensions.TryAdd("traceId", activity.Id);
};
});As a result, your responses will look like this:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "DivideByZeroException",
"status": 500,
"instance": "GET /divide?a=19&b=0",
"traceId": "00-33a45396c10009cff3c0cefaa666d84d-2d95e5d0e0efb4c1-00",
"requestId": "0HNHN532DGDB9:00000001",
"nodeId": "270299"
}A more advanced level of customization can be achieved by implementing your own IProblemDetailsWriter.
public class NotFoundProblemDetailsWriter : IProblemDetailsWriter
{
public bool CanWrite(ProblemDetailsContext context)
{
var statusCodesToHandle = [404];
return statusCodesToHandle.Contains(context.HttpContext.Response.StatusCode);
}
public async ValueTask WriteAsync(ProblemDetailsContext context)
{
var response = context.HttpContext.Response;
var exception = context.Exception;
var problemDetails = context.ProblemDetails;
problemDetails.Title = exception.GetType().Name;
await response.WriteAsJsonAsync(problemDetails);
}
}Just remember to register it in the DI container afterward.
builder.Services.AddTransient<IProblemDetailsWriter, NotFoundProblemDetailsWriter>();And by the way, you’re not limited to a single writer. You can register multiple handlers, for example, one per status code or per specific exception type, giving you full control over each scenario.
The final option is to resolve the Problem Details writer directly, which is useful if you’re still using a custom middleware or a custom exception handler.
In this approach, you can add your own metadata, wrap the exception, adjust any Problem Details fields, fully control how the response is shaped before sending it to the client, and even write problem details when the request is successful, and you are looking for consistency 🙃.
app.Use(async (httpContext, next) =>
{
var problemDetailsWriters = httpContext
.RequestServices
.GetRequiredService<IEnumerable<IProblemDetailsWriter>>();
try
{
await next();
}
catch (Exception ex)
{
var context = new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = ex,
AdditionalMetadata = new EndpointMetadataCollection(),
ProblemDetails =
{
Title = "Bad Input",
},
};
var writer = problemDetailsWriters.Single(w => w.CanWrite(context));
await writer.WriteAsync(context);
}
});Notice how the ProblemDetailsContext is created, and how multiple writers are resolved to handle different scenarios.
Summary
When something goes wrong in an application, the last thing you want is a random exception bubbling to the top. Good error handling isn’t about sprinkling try/catch everywhere — it’s about creating a predictable, unified way your system deals with failures.
Follow these core practices to build a robust exception pipeline:
- you can use exception filters for controller- or action-specific handling
- middlewares can help you to also catch HTTP pipeline errors, exceptions from routing, model binding, that filters can’t see
- get use of the built-in middleware like
.UseDeveloperExceptionPage()to quickly identify bugs during development - a production-ready approach suggested by Microsoft is relying on
.UseExceptionHandler()that is part of the exception handling pipeline and can help avoid common middleware pitfalls - implement
IExceptionHandlerfor custom, reusable exception-handling logic that can be registered in DI and plugged into the global pipeline - leverage
[ApiController]instead of manual validation inputs; customize model validation response, if needed - consider the Result pattern for API responses; wrapping both successes and failures ensures predictable, uniform results for clients.
- adopt ProblemDetails to standardize error responses and provide useful metadata in a consistent format
Let me know in the comments how you handle errors in your projects 💬
Clap if you found this useful 👋
Support me with a coffee ☕
Follow for more clean-code and C# insights ✅








