Consistent API Error Handling in ASP.NET Core with ProblemDetails
Open the network tab on a typical internal API and trigger a few failures. You'll often find three different error shapes from the same service: a raw string from one endpoint, an ad-hoc { "error": "..." } object from another, and an HTML error page where someone forgot the API even had that path. Every consumer of that API now writes three error parsers — and the mobile team quietly hates you.
There's a standard for this: Problem Details (RFC 7807, updated by RFC 9457), and ASP.NET Core supports it natively. Here's how I set up error handling on every API I build.
The shape
A Problem Details response is a JSON object with a media type of application/problem+json:
One shape for every failure: clients write one error handler, support staff get a traceId they can hand to you, and you get to stop bikeshedding error formats forever.
Step 1: Turn it on everywhere
Three registrations give you consistent ProblemDetails for all non-success responses:
AddProblemDetails registers the writer; the two middleware calls make sure both exceptions and empty status code responses come out in the standard shape. With [ApiController], model validation failures already return the validation flavor (ValidationProblemDetails) with a per-field errors dictionary — so your 400s are covered too.
Step 2: Map domain exceptions with IExceptionHandler
.NET 8 introduced IExceptionHandler, which finally gives global exception mapping a clean home (no more custom middleware boilerplate):
Handlers run in registration order; returning false passes the exception to the next handler or the default. This keeps the mapping between your domain's failure modes and HTTP semantics in exactly one file.
Step 3: Decide what exceptions are for
Middleware only solves the plumbing. The design question remains: when should code throw? The convention that has served my teams well:
- Expected, per-request outcomes — "order not found", "insufficient stock" — are results, not exceptions. Return them (
Results<Ok<T>, NotFound>in minimal APIs, or a result type from your services) and map to 4xx. - Broken invariants and infrastructure failures — database down, config missing, bug — throw. They surface as 500s via the handler, get logged with full detail, and page someone if frequent.
Exceptions as control flow for routine outcomes is both a performance tax and a readability lie; 500s for routine "not found" cases wreck your alerting signal. Draw the line deliberately.
Step 4: Don't leak, don't hide
Two failure modes to avoid, one per direction:
Leaking internals. exception.Message for unexpected exceptions can contain connection strings, file paths, SQL fragments. The default UseExceptionHandler behavior is safe (generic 500 body); keep it that way in production and put the detail in your logs, correlated by traceId. Only domain exceptions you wrote — with messages you authored for users — belong in detail.
Hiding everything. The opposite sin: a blanket try/catch that returns 200 with { "success": false }. HTTP status codes are your API's contract with caches, retry policies, monitors, and every generic HTTP client on earth. Use them.
The payoff
This whole setup is maybe forty lines of code, and it's some of the highest-leverage code in the service: every client gets one error contract, every incident comes with a trace ID, validation errors are machine-readable, and adding a new domain failure mode is a one-line addition to a switch expression. Error handling is the part of the API your consumers meet on their worst day — make it the most predictable part.
Where to go next
- Minimal APIs vs Controllers in ASP.NET Core — ProblemDetails works identically in both.
- Nullable Reference Types: A Practical Migration Guide — eliminating the exceptions that shouldn't exist at all.
- Real-World Unit Testing in .NET — asserting your error contract stays stable.