A Consistent Error Format
By the end of this lesson
Define one error response shape every endpoint honours.
Every endpoint fails. If each one fails in its own format, a client has to write a separate error handler for each, and a handler written before your newest endpoint existed will not understand it.
One shape, used everywhere, turns that into a single piece of code on the client: read the status, read the machine-readable code, show the message, attach any field errors to the form. A new endpoint needs no new client work, which is the whole return on the effort.
There is a standard shape for this, so you do not have to invent one. Problem Details is an IETF specification — originally RFC 7807, now RFC 9457 — that defines a JSON document with a small set of known members: type, title, status, detail and instance. ASP.NET Core has first-class support for it, which is why validation failures already come back in that form.
The specification also allows extension members, and that is where your own fields go: a stable error code, a field errors object, a trace identifier. You get an agreed baseline that tooling understands, plus the parts specific to your API.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{ "type": "https://errors.example.com/validation_failed",
"title": "One or more fields are invalid.",
"status": 400,
"instance": "/api/employees",
"code": "validation_failed",
"traceId": "0HN7A1M4K2P9Q",
"errors": { "email": ["Email is required."] } }
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{ "type": "https://errors.example.com/email_in_use",
"title": "An employee already exists with that email address.",
"status": 409,
"instance": "/api/employees",
"code": "email_in_use",
"traceId": "0HN7A1M4K2P9R" }
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{ "type": "https://errors.example.com/internal_error",
"title": "The request could not be completed.",
"status": 500,
"instance": "/api/orders/1042",
"code": "internal_error",
"traceId": "0HN7A1M4K2P9S" }- The content type is application/problem+json rather than application/json. That is part of the specification, and it lets a client recognise a problem document without inspecting the body.
- The members are the same in all three. A client reads code and traceId from every error response without asking which endpoint produced it.
- code is the part a client branches on. It is stable, lowercase and never translated, so "email_in_use" can be handled specifically while the human text is free to change.
- title is for a person. Rewording it is not a breaking change, which is exactly why clients must not compare against it.
- errors appears only where there are field errors. Optional members are a better contract than members that are always present and usually empty.
- The 500 says nothing about what broke. The traceId is how support connects this response to the log entry that does.
public static class ApiError
{
public static IResult Create(
int status,
string code,
string message,
HttpContext http,
IDictionary<string, string[]>? fieldErrors = null)
{
var problem = new ProblemDetails
{
Status = status,
Title = message,
Type = $"https://errors.example.com/{code}",
Instance = http.Request.Path
};
problem.Extensions["code"] = code;
problem.Extensions["traceId"] = http.TraceIdentifier;
if (fieldErrors is not null)
{
problem.Extensions["errors"] = fieldErrors;
}
return Results.Problem(problem);
}
}
// At the call site
return ApiError.Create(
StatusCodes.Status409Conflict,
"email_in_use",
"An employee already exists with that email address.",
http);- One helper is what makes the shape real. A documented convention that each endpoint implements by hand drifts within a month; a helper is the path of least resistance.
- ProblemDetails.Extensions holds the members the specification does not define. They appear as ordinary top-level properties in the JSON.
- TraceIdentifier is per request and already appears in the framework's logs, so quoting it in a support ticket leads straight to the right entry.
- Instance records which path produced the error, which matters when a client is calling several endpoints and reporting one failure.
- Unhandled exceptions need the same treatment, through app.UseExceptionHandler and a handler that maps the exception to this shape. Without that, one unexpected failure returns something no client recognises.
What belongs in an error response, and what must stay out:
- Include: a machine-readable code
- Stable, specific, and part of your contract. Changing a code is a breaking change, which is the discipline that makes it dependable.
- Include: a human-readable message
- Written for the developer integrating with you, and safe to show a user where that makes sense. Free to be reworded.
- Include: field errors where relevant
- Keyed by the field name the caller sent, so a form can place each message next to the right input.
- Include: a correlation or trace identifier
- The bridge between a caller's report and your logs. It turns "it failed yesterday" into one log entry.
- Exclude: internal detail
- Stack traces, SQL, exception types, file paths, connection strings and server names. They help an attacker map your system and help a legitimate caller with nothing.
Summary
- One error shape across every endpoint lets a client write one error handler
- Problem Details is the standard shape, with extension members for your own fields
- Clients branch on a stable code; the human message stays free to be reworded
- Include a trace identifier so a caller's report maps to a log entry, and keep stack traces, SQL and internal names out
- Route unhandled exceptions through the same shape, or the pattern breaks where it matters most
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Codes against messages
A client needs to react specifically when an email address is already in use — by focusing the email field and showing a tailored message.
Explain why matching on the human message is fragile, and what the client should match on instead.
Show solution
The message exists to be read by people, which means it will be reworded, softened, shortened or translated. Every one of those edits breaks a client that compares against the text, and none of them looks like a breaking change to whoever makes it.
The client should match on the code — email_in_use — which you have committed not to change without treating it as a contract change.
This gives both sides what they need: your team can improve the wording at any time, and the client has something stable to branch on.
Try it yourself
Audit your own errors
Collect the error responses from four endpoints in an API you work on: a validation failure, a not found, a permission failure and an unhandled exception.
Put the four bodies side by side. Could one client function handle all four? Does any of them contain detail a caller should not see?
Show solution
The unhandled exception is usually the one that breaks the pattern, because it was produced by the framework rather than by your code. That is the gap an exception handler mapping to your shape is there to close.
If the four bodies differ, the fix is not a note in the documentation. It is one helper and one exception handler, so the consistent shape is what an endpoint produces by default rather than what a developer remembers to produce.
Saved in this browser only.