dotnet-error-handling
Installation
SKILL.md
ASP.NET Core error handling
An API has exactly two ways to report that something went wrong, and they must never blur together:
- Expected failures - validation is rejected, the row is not there, a uniqueness rule is broken. These are part of the contract, so they are return values: a
Result/Result<T>or a closed error union the caller branches on. - Unexpected failures - a dependency is down, an invariant is violated, a bug throws. These are exceptions, and they are caught in exactly one place: a global handler.
The language-level call - when to throw versus when to return - is csharp. This skill is only about how a failure reaches the wire. Floor is .NET 8 / C# 12.
Model expected failures as return values
- An application or domain operation that can fail in a foreseeable way returns its outcome instead of throwing. Two shapes both work; pick one per codebase and stay with it:
- a
Result<T>holding either a value or one-or-more errors (IsSuccess,Value,Errors); - a closed union -
abstract record Error(string Code, string Message);withsealed record NotFound(...) : Errorand friends - resolved by aswitchexpression.
- a
- Throwing to signal an ordinary outcome (not found, invalid input, conflict) is the thing to avoid: it is slower on the failure path, it hides the failure from the method signature, and it pushes a
try/catchto every call site.
One place maps an error to a status
- Convert the domain error to an HTTP status in a single helper - an
Error -> IResultswitch, or aresult.Match(onOk, onError)extension - so an identical failure yields an identical status and body across every endpoint. Handlers call the helper; they do not each pick a status code. - House mapping, kept in one file: invalid input 400, unauthenticated 401, forbidden 403, missing resource 404, conflict/uniqueness 409, broken domain rule 422, anything unmapped 500.