There's a moment in every backend's life when a service method needs to say "this order doesn't exist." The tempting shortcut is to raise HTTPException(404) right there in the business logic — and now the domain layer imports FastAPI, business rules know about status codes, and services can never be reused outside a web request. Routers balloon into translation tables, duplicated across every endpoint, drifting out of sync one copy-paste at a time. Multiply that across dozens of endpoints and the codebase ends up with as many opinions about what a "not found" response looks like as there are routers that handle one.
The problem
In a layered backend, business rules should know nothing about HTTP — they should be callable from a web request, a CLI script, or a background job without dragging a web framework along for the ride. But errors ultimately have to leave the process as HTTP responses with a status code and a body the frontend can parse. Someone has to do that translation, and the naive answers all fail in the same way: they couple something that should stay generic (a business rule) to something that should stay local (a transport concern), or they duplicate the coupling across every place an error can surface.
The failure mode was concrete: a service raised whatever the standard library or a library dependency happened to raise, a router caught it (or didn't), and the eventual JSON body was whatever str(exception) produced. That's fine until the frontend needs to distinguish "this order doesn't exist" from "you're not allowed to see this order" from "the input was malformed" — three different situations that all looked identical to client code parsing a message string.
What a domain exception is
An exception is a way of saying "this function cannot fulfil its contract, and handling that is not my job." Two properties make it the right tool for this problem:
- It separates detection from handling. The code that discovers the problem (deep in a service) rarely knows what the response should look like (a JSON body? a retry? a log line?). Exceptions let the discovery site raise and let a boundary decide.
- It propagates until something handles it. A returned error code can be dropped on the floor by a caller that forgets to check it; an unhandled exception, by contrast, keeps bubbling up until a handler explicitly catches it. Failure is loud by default.
The classic misuse is exceptions as control flow — raising to break out of a loop, or catching broadly just to continue. The rule of thumb: an exception represents a broken contract, not a branch. "Product not found" when the caller asked for a specific product breaks a contract. "List returned zero products" is just a value, and should be returned, not raised.
A domain exception narrows that further: it's an exception type that belongs to the business layer, carries enough metadata to describe itself (a stable machine-readable code, a category of failure), and says nothing about how it will eventually be rendered to a client. It's the difference between "the order doesn't exist" as a fact about the business, and "return a 404" as a fact about HTTP.
The alternatives
- Raise a framework exception everywhere. Couples every layer to the web framework. A service can't be called from a script or a background job without dragging the framework along, and unit-testing business logic means importing and asserting on HTTP types that have nothing to do with the rule being tested.
- try/except in every router. Each endpoint catches the exceptions it expects and maps them to a status code inline. It works for a handful of endpoints, then rots: the mapping logic gets copy-pasted, two routers disagree on the status code for the same underlying error, and adding a new business error means hunting down every router that might call the code that raises it.
- Domain exceptions plus a small number of global handlers. Errors carry their own metadata (a code, a status); a handful of handlers registered once translate all of them at the boundary where the process actually talks HTTP. This is the one I settled on.
Languages with a strong functional lineage — Rust, F#, modern C# — often model recoverable failures as a Result/Either return type instead of raising at all. That's a legitimate alternative, but this codebase already uses exceptions consistently for every other unrecoverable business failure; introducing a second error-handling model alongside it would add a parallel convention to learn without fixing anything the exception-based approach actually gets wrong.
The decision
A self-describing base exception
Every domain error inherits from one small base carrying two class attributes: a stable, machine-readable code and an HTTP status:
class DomainError(Exception):
code: str = "DOMAIN_ERROR"
http_status: int = 400
def __init__(self, message: str):
self.message = message
super().__init__(message)
Subclasses are one-liners that override those two attributes, declared next to the business rule that raises them rather than in a central file that has to know about every module:
class InvalidCredentialsError(DomainError):
code = "AUTH_INVALID_CREDENTIALS"
http_status = 401
class InvalidTOTPError(DomainError):
code = "AUTH_INVALID_TOTP"
http_status = 401
class OrderNotFoundError(DomainError):
code = "ORDER_NOT_FOUND"
http_status = 404
class DuplicateProductError(DomainError):
code = "PRODUCT_DUPLICATE"
http_status = 400
Note what the domain layer knows here: a code, and yes, an HTTP status. Purists might argue that belongs exclusively to the presentation layer — in practice, it's treated as transport-agnostic classification metadata: "not found," "conflict," and "unauthorized" are already domain concepts, and HTTP is simply the one adapter this system currently has to interpret them. The domain still imports zero framework code; nothing about the class depends on how it will eventually be serialized.
A small number of handlers, from specific to catch-all
The translation to an actual HTTP response happens in a small, fixed set of handlers registered once, ordered from most specific to catch-all:
async def domain_error_handler(request, err: DomainError):
return JSONResponse(
status_code=err.http_status,
content={"error": {"code": err.code,
"message": err.message}},
)
async def value_error_handler(request, err: ValueError):
# 422, code VALIDATION_ERROR
...
async def unhandled_error_handler(request, err: Exception):
logger.exception("Unhandled error")
# 500, code INTERNAL_ERROR — details logged, never leaked
...
- Any
DomainErrorresolves to its ownhttp_status, with a body shaped{"error": {"code", "message"}}. Every business error, one shape. - A bare
ValueErrormaps to 422 with codeVALIDATION_ERROR— malformed input that slipped past schema validation. - Everything else maps to 500 with code
INTERNAL_ERROR. The stack trace goes to the log; the client gets a generic body. An unexpected exception here represents a bug, not a business failure — the client doesn't need to know which, but the log has to, so it's never serialized to strangers.
The one deliberate exception to "domain errors never know about HTTP": auth guards — session and CSRF checks — raise a framework exception directly. They already live in presentation-layer code, so routing them through a domain type first would just be indirection for its own sake.
What it buys the call sites
A route handler reads like a table of contents — validate input, call the service, shape the response — with no try/except anywhere:
@router.post("", status_code=201)
async def create_product(
payload: ProductCreate,
service: ProductService = Depends(get_product_service),
) -> ProductResponse:
product = await service.create_product(payload)
return ProductResponse.model_validate(product)
If the service raises DuplicateProductError, the registered handler turns it into a 400 with {"error": {"code": "PRODUCT_DUPLICATE", ...}} without the router knowing that error exists. The split between code and message isn't redundant — message is for a human, code is for a machine. The frontend switches on error.code: whatever text it shows for ORDER_NOT_FOUND can change, get translated, or be reworded entirely without touching backend code, because the frontend never parses the message string to decide what happened.
Adding a new business error costs three lines in the module that owns the rule. Zero routers change. Zero handlers change. That's the sign the abstraction is at the right altitude.
The trade-off worth naming: the catch-all handler can hide a typo. A misspelled attribute in a service becomes a clean 500 to the client — good for the user, but only safe because that handler always logs the full traceback before returning the generic body. Drop that log line and the pattern quietly buries bugs instead of surfacing them. And once a frontend switches on a code like ORDER_NOT_FOUND, renaming it is a breaking change — codes are API surface and need the same discipline as URL paths.
The outcome
Business errors now carry their own status and code as class attributes declared next to the rule that raises them, and translation to an HTTP response happens in one small set of ordered handlers instead of scattered try/except blocks. Adding a new business error is a three-line addition in the module that owns it; no router or handler needs to change. Every business failure now reaches the client as the same predictable shape — a code and a message — so frontend code can branch on a stable identifier instead of parsing human-readable text, and unhandled bugs are logged with a full traceback server-side while the client only ever sees a generic, non-leaking 500.