Your Errors Deserve a Domain Model
Errors are often treated as an afterthought. We put a lot of care into domain models, API boundaries, frontend state, and user flows — and then an error happens, and all of that structure disappears. The backend throws a generic exception, the API maps it to whichever HTTP status code feels close enough, the frontend guesses, and the user sees some version of “something went wrong.”
At my last startup I had the unusual chance to work across UX design, frontend architecture, backend architecture, and observability in a greenfield system. That’s what made it impossible for me to keep treating error handling as an afterthought. Every vague backend exception became a confusing UI state. Every inconsistent API response became duplicated frontend logic. Every swallowed stack trace made production issues harder to debug.
The idea we landed on was simple: errors deserve a domain model. Not just a status code, not just an exception class, but a shared model that crosses every boundary in the system. It defines what an error means, what the user should see, what the frontend should do, what the backend should guarantee, and what observability should track.
Agentic coding has only made this more urgent. Coding agents reflect the average patterns in the code they’ve seen, so without a clear project strategy they’ll mix thrown exceptions, ad hoc response bodies, string matching, swallowed stack traces, and generic error messages. If your application doesn’t have an error model, agentic coding will invent one accidentally, one handler at a time.
Meaning Gets Lost at Handoffs
Most bad error handling happens when meaning changes hands. Domain logic hands an exception to the API layer. The API layer hands a status code and message to the frontend. The frontend hands a generic error state to the user. Observability records a technical failure, but the product meaning is lost.
Imagine a user trying to invite a teammate. They enter an invalid email, the backend rejects it, and if the response maps cleanly to the email field, the frontend can show an inline message under that field. Now make it harder: the backend returns a validation error for a field the form doesn’t know about — maybe stale frontend state, maybe a schema mismatch. Or the email is valid but belongs to a deactivated user. Is that invalid input? Unauthorized? An invariant violation? A bug?
Without a shared model, those distinctions disappear. The backend returns a 400 with a message string, the frontend guesses, and the user gets a generic failure. The HTTP status code says the request was bad, but not what the product should have done.
This is why HTTP status codes alone aren’t an error strategy. Arguing whether a duplicate name should be 400, 409, or 422 often misses the more important product question: should this become a field error, an action-level dialog, an access denial, or a bug? Status codes are useful transport metadata. They aren’t a model.
Errors Are Relative to the Caller
A backend API doesn’t usually know whether invalid input is a user mistake. It only knows that its caller sent input it can’t accept. That caller might be a form, but it might also be frontend state, hidden metadata, a stale page, or another backend service.
Each layer needs to preserve the meaning of an error and remap it into the model of its own caller. It helps to translate errors deliberately instead of passing them through mechanically.
A small shared vocabulary makes that translation easier. I split errors into three kinds — rejections, outages, and bugs — and the rest of this post walks through each.
In our codebase, that vocabulary collapsed to a single discriminated union shared by server and client:
type DomainError = | { type: 'UNAUTHENTICATED' } | { type: 'UNAUTHORIZED'; reason: string } | { type: 'NOT_FOUND'; resource: string } | { type: 'INVALID_INPUT'; errors: { [field: string]: string } } | { type: 'INVARIANT_VIOLATION'; invariant: string } | { type: 'UNAVAILABLE'; service: string } | { type: 'BUG'; origin: string; digest: string };
Each variant has a small constructor and is pattern-matched exhaustively at every boundary.
Rejections
A rejection is expected product behavior: the system is saying no for a reason, and that reason should be explicit. Rejections should have stable types and structured payloads so each caller can translate them into its own model.
Invalid Input
Invalid input means the caller supplied values the callee can’t accept. That doesn’t always mean the user made a mistake. It means the request is invalid from the callee’s perspective.
At an API boundary, the caller is often the frontend. If the backend returns an invalid input error for email, and the current form has an email field the user controls, the frontend can remap it into an inline field error. If the backend returns an invalid input error for a field the form doesn’t know about, the frontend shouldn’t silently ignore it. That’s probably a contract bug, stale frontend state, or a mismatch between the UI and the API schema.
Not Found
Not found means the requested resource doesn’t exist in the caller’s accessible world. In multi-tenant systems, a resource owned by another organization should often look the same as a resource that doesn’t exist. Returning unauthorized can leak information.
Not found is common when rendering pages from shared links. A project was deleted. An invitation expired. A dashboard references a resource that no longer exists. At the UI boundary, not found usually maps to a page-level or component-level empty state. The user needs to know what’s missing, not internal details about why the lookup failed.
During actions, not found is more suspicious. If the resource disappeared after the page loaded, it might become an action-level dialog. If the frontend sent an ID that shouldn’t have been possible, it might become a bug.
Unauthenticated
Unauthenticated means the caller doesn’t have a valid identity. In a user-facing application, this usually means the session expired.
During page rendering, the right UX is usually a redirect to login with enough callback state to bring the user back afterward. During an action, the user may have filled out a form, clicked submit, and only then discovered that their session expired. The fallback is usually a dialog that asks them to log in again.
Unauthorized
Unauthorized means the caller is authenticated, but doesn’t have permission to perform the operation. This is about the actor. The user, role, token, organization, or policy doesn’t grant the capability.
At the UI boundary, unauthorized errors should often be predicted before the user acts. If the user can’t invite teammates, the invite button should be hidden, disabled, or shown with an explanation. If the user can’t view a page, the page should render an access-denied state. If unauthorized happens during an action, the fallback should explain that the user doesn’t have permission. It shouldn’t pretend the input was wrong.
Invariant Violation
Invariant violation means the caller may be allowed to perform the operation, and the input may be valid, but the current system state still says no. This is the rejection that tends to get muddled with invalid input or unauthorized.
A user without permission to delete projects gets an unauthorized error. A user with permission to delete projects who tries to delete a project that still has active deployments gets an invariant violation. A malformed email address is invalid input. A valid email address belonging to a deactivated user may be an invariant violation.
The actor-vs-state distinction matters because the UX is different. Invalid input belongs to a field. Unauthorized belongs to the permission model. Invariant violation belongs to the action. At the UI boundary, invariant violations should usually become dialogs that explain what condition prevented the action and, when possible, what can happen next.
Domain-Specific Rejections
Most products need a few additional rejection types. Quota exceeded is a common example. The request may be valid and the caller may be allowed to perform the operation in principle, but the current plan or usage limit prevents it. That deserves its own type if the product needs dedicated upgrade, billing, or usage-limit UX.
Add a subtype when the product needs distinct behavior. Don’t add one just because there’s another HTTP status code available.
Outages
An outage means the request may be valid, but the system can’t complete it right now. This includes dependency outages, network failures, gateway errors, timeouts, overloaded services, unavailable workers, and offline clients.
Outages aren’t product decisions, they’re operational signals.
From the user’s perspective, many outages look the same: the system can’t do the thing right now. The UX should usually allow retrying or tell the user to come back later. The client can retry automatically when the action is safe to repeat. If the problem persists, show a page or dialog that names the unavailable service at a useful level of abstraction.
Don’t expose internal topology. “Search is temporarily unavailable” is usually better than naming the exact database, queue, or pod that failed. Offline can be a frontend-only subtype. If the client already knows the network is unavailable, the UI can say so directly instead of pretending the server rejected the request.
Bugs
A bug means a developer assumption broke. The system reached a state the code wasn’t designed to handle. A response had an impossible shape. A supposedly unreachable branch was reached. A required value was missing. A frontend/backend contract drifted. A domain invariant that should have been enforced earlier was violated.
Bugs should fail loudly. That doesn’t mean showing stack traces to users. The user-facing message should be generic, but the internal record should preserve the stack trace, distributed trace context, and enough state to understand what failed.
The worst outcome is a catch block that swallows the exception, loses the stack trace, and lets the system continue with corrupted assumptions. Swallowing bugs doesn’t protect users. It hides the broken assumption from the people who can fix it.
Make the Model Hard to Bypass
The taxonomy is only useful if developers don’t have to remember it every time they handle an error. The goal is to make the right thing the easy thing.
Translate at the UI Boundary
The UI handles errors through a small set of helpers, one for each shape of failure: data fetches, single user actions, and form submissions. Each accepts a typed error and renders the right thing automatically.
The simplest case is data fetching. A page or component receives a Result, and if the fetch failed it returns one of two error components instead of its normal output:
// page-level data fetch if (org.err) return <ErrorPage {...org.err} />; // component-level data fetch {user.err ? <InlineError {...user.err} /> : <UserCard user={user.val} />}
ErrorPage is the full-page state, InlineError is the same content rendered as a card or list-item state for when one piece of a page fails without the rest. Both are powered by a single exhaustive match over PageResultError — the rejection union with invalid input excluded, since field errors don’t make sense outside a form. Adding a new variant breaks the build until every renderer handles it. Even the framework-level uncaught-error boundary renders the same ErrorPage with type: 'BUG', so a thrown render error and a returned bug result land in the same UX.
For things triggered by user actions — delete buttons, retries, anything that isn’t a form submission — the same content matrix is exposed as an imperative helper that opens a modal:
const res = await deleteThing(id); if (res.err) return showErrorDialog(res.err);
The bug branch shows the same digest and the same one-click pre-filled support email; only the chrome is different.
Forms are the tricky case because a submission can return either a field error that belongs inline under an input, or any other kind of error that should be rendered as a dialog. Our <Form> component wraps React Hook Form and Zod so developers write a schema and field components and the routing happens automatically. There are no try/catch blocks, no if (res.err) branches, and no calls to a toast or dialog helper inside any individual form.
Three things make that work. The <Form> component takes the Zod schema as a prop and uses it both to validate the input client-side and to type the data passed into onSubmit. Each field component (FormTextField, FormSelectField, FormCheckboxField, …) registers itself with the form context, reads its own error message from form state, and passes it into a shared InputLabel that draws the inline error under the input. And the wrapper handles the post-submit result with one branch and dispatches everything that isn’t a field error to showErrorDialog, so the form code never has to know about unauthorized, unavailable, invariant violation, or bug. A typical form definition is just a schema and some fields:
<Form schema={inviteSchema} onSubmit={sendOrgInviteAction} onSuccess={(d) => { toast.success(`Sent invite to ${d.email}`); router.refresh(); }} > <FormTextField name="email" label="Email" /> <FormSelectField name="role" label="Role" options={roleOptions} /> <FormSubmitButton label="Send Invite" /> </Form>
That’s the entire surface. No error rendering, no submit-state bookkeeping, no branching logic. The Zod schema validates client-side and types onSubmit, so local mistakes never round-trip and whatever INVALID_INPUT comes back is genuine server-state validation like a uniqueness check; the wrapper feeds each field’s message into setError, focuses the first failure, and routes anything that isn’t a field error through showErrorDialog.
Return Rejections, Throw Bugs
For any of that to work, the typed errors have to actually reach the UI. In Next.js, that is harder than it sounds: a thrown Error from a server action reaches the client as an opaque object with a digest and nothing else — no message, no cause, no fields, no type discriminator. The taxonomy only survives the round trip if it is encoded as data, so every server action returns Result<T, E> where the error is one of the rejection types above. Policy helpers benefit especially: a typed unauthorized result with an explicit reason lets application code distinguish “the actor can’t do this” from “the operation violates the current domain state”.
That rule lives as a single line in our agents file: never throw from business logic, return Result<T, E>. Coding agents follow that one line better than any prose explanation of the taxonomy.
Bugs are the exception. Throwing is appropriate for broken assumptions and impossible states, and unavoidable in backends that already use exceptions for HTTP control flow. The rule is to catch those throws at a clear outer boundary, preserve the diagnostic context, and turn them into bug results before they leak.
In our Python backend, that outer boundary is the framework’s exception-handler layer. Typed domain exceptions like InvalidInput, ConstraintViolation, NotFound, and ServiceUnavailable get registered handlers that emit a stable JSON body per type — 422 { validation_issues }, 409 { constraint }, 404 { resource }, 503 { service }. A single catch-all handler converts every other exception into { status_code: 500, message, request_id }, with a generic message in production and the original message in development. Stack traces stay in logs, only the request id reaches the client.
In our Next.js frontend, the equivalent is a single catchBug wrapper that turns thrown errors into Bug results. The full stack and cause stay server-side in logs, the result that travels to the client carries only an origin tag and a short digest code that the UI displays, to help correlate user reports with backend logs.
Remap Errors at Every Boundary
Once errors are typed and travel as data, the other key implementation pattern is boundary remapping. An API client shouldn’t blindly pass through every error response it receives. It should translate remote errors into the local error model of its caller. The trickiest case is invalid input, because a field error only makes sense if the caller can tie it back to one of its own form fields.
For that to be possible, every field error has to address the field by its location in the request, not by some local name the backend invented. The framework handles that for schema-level validation by default. The harder cases are validations that can’t run until state is loaded — uniqueness checks, cross-resource invariants, compatibility between two resources. We put those on the request model itself, as instance methods that take the loaded state as a parameter and raise InvalidInput with the same ("body", "<field>") location format the framework emits. The validators sit next to the field definitions, and the API client sees one location format whether the rejection came from the schema or a hand-written rule.
That uniformity is what makes the client-side mapping deterministic. The frontend declares invalidInputMapping: { inputValue: ['input_values', 0] }, the API client compares each remote loc against the mapping’s values, and a match becomes a typed INVALID_INPUT keyed on the local field name. Without that mapping, a 422 becomes a bug — the user can’t fix a field the UI doesn’t know exists, so a silent passthrough would hide stale state, schema drift, or a contract bug. The type system enforces the opt-in: the result type of each API call only narrows to InvalidInput<keyof mapping> when a mapping is provided.
The other status codes get translated similarly. Because our frontend server talks to the backend with a central service credential, a backend 401 or 403 is never the user’s fault — those become bugs. Network errors and gateway codes like 502 and 504 normalize to unavailable. A backend 500 becomes a bug too, with the backend’s request_id reused as the bug digest, so the user-facing digest is correlatable to the backend log line.
This is the practical meaning of “errors are relative to the caller.” The same union also travels outside JSON HTTP: a SCIM endpoint exhaustive-matches it and emits the corresponding RFC-7644 body, an OAuth callback that can’t return JSON serializes the error into a redirect query parameter, and a streaming chat route returns pre-stream errors as a normal HTTP response and mid-stream errors as JSON-encoded frames inside the body.
Wire Errors into Observability
The error model should connect to observability by design. Each error result carries a type, so logs and traces can be tagged with it instead of relying on string matching after the fact. That tagging is what makes the rest possible.
Rejections are expected behavior, but spikes can reveal confusing UX, broken clients, abuse, permissions regressions, or workflow edge cases. A surge in invalid input on one form usually points at a UI bug. A surge in unauthorized usually points at a permissions regression or a missing prediction in the UI. Outages should track unavailable dependencies, timeouts, retries, and worker heartbeats, with alerts scoped to the dependency rather than the calling service. Bugs should preserve stack traces, trace IDs or digests, request context, and domain context — and they should always alert, since they’re the unexpected category.
The Payoff
A good error model is a product contract that spans domain logic, API responses, frontend helpers, form abstractions, UI states, and observability. It prevents meaning from getting lost at handoffs, makes expected failures easy to handle, and makes unexpected failures hard to miss.
It also gives humans and coding agents a clear path: there’s a named type to return, a helper to call, a boundary that remaps it, and a UI component that knows how to render it. Without that structure, every new error handler becomes a small opportunity to invent a worse system.