API Design · Conventions

Typed Errors and a
Consistent Response Schema

raise Exception('Order not found.') looks completely fine until a second person writes the string slightly differently somewhere else, and now the same failure produces two different codes depending on which file threw it.

Error handling has a clean layer split: the Domain and Application layers raise a plain Exception, never a framework-specific HTTP exception like FastAPI's HTTPException, and the Interface layer — the router handler — is the only place that catches an error and converts it into an HTTP status code. That separation keeps Domain and Application free of any HTTP dependency at all, and concentrates the one messy job, "translate this into a status code," in exactly one place.

# domain/order.py — inside the Aggregate
if self._status == "cancelled":
    raise Exception(OrderErrorMessage.ORDER_ALREADY_CANCELLED)

# application/command/order_command_service.py
if not order:
    raise Exception(OrderErrorMessage.ORDER_NOT_FOUND)

Why the Message Is an Enum Key, Not a Free-Form String

class OrderErrorMessage(str, Enum):
    ORDER_NOT_FOUND = "Order not found."
    ORDER_ALREADY_CANCELLED = "This order has already been cancelled."
    ORDER_PAID_NOT_CANCELLABLE = "A paid order cannot be cancelled."
    ORDER_ITEMS_REQUIRED = "An order must have at least one item."

The Interface layer's conversion works by comparing str(exc) against these enum values at runtime:

# The Interface layer's mapping
(OrderErrorMessage.ORDER_NOT_FOUND, 404, OrderErrorCode.ORDER_NOT_FOUND)
#  ↑ the enum member (checked by the type checker)      ↑ this value is compared against str(exc) at runtime

If someone bypasses the enum and writes the raw string directly instead, two things break silently. A typo in a hand-written raise Exception('Order not fund.') produces no error at all when the file is linted or type-checked, because it's just a string literal. And separately, the Interface layer's comparison against OrderErrorMessage.ORDER_NOT_FOUND now fails to match, so that error falls through to an unhandled 500 instead of the 404 it was supposed to become. Routing every raise site through the enum member — raise Exception(OrderErrorMessage.ORDER_NOT_FOUND) — means the exact same typo, now a misspelled member name, is instead an error ruff and mypy catch before the code ever ships, instead of surfacing as a wrong status code weeks later.

Codes Are a Second, Independent Axis

If the HTTP status code is the category, the error code is the precise cause — and it needs to be independent of the message text, because the client is expected to branch on code, not on parsing the message string, which can be translated or edited without warning.

class OrderErrorCode(str, Enum):
    ORDER_NOT_FOUND = "ORDER_NOT_FOUND"
    ORDER_ALREADY_CANCELLED = "ORDER_ALREADY_CANCELLED"
    ORDER_PAID_NOT_CANCELLABLE = "ORDER_PAID_NOT_CANCELLABLE"
    ORDER_ITEMS_REQUIRED = "ORDER_ITEMS_REQUIRED"

Codes are SCREAMING_SNAKE_CASE, unique across the whole project (add a domain prefix if two domains would otherwise collide), and every entry in a domain's error-message enum has exactly one code mapped to it — a 1:1 relationship, not a many-to-one shortcut.

Where the Conversion Actually Happens

async def get_order(
    param: GetOrderRequestParam,
    order_query_service: OrderQueryService = Depends(get_order_query_service),
) -> GetOrderResponseBody:
    try:
        return await order_query_service.get_order(param)
    except Exception as exc:
        raise convert_to_http_error(
            str(exc),
            [
                (OrderErrorMessage.ORDER_NOT_FOUND, 404, OrderErrorCode.ORDER_NOT_FOUND),
                (OrderErrorMessage.ORDER_ALREADY_CANCELLED, 400, OrderErrorCode.ORDER_ALREADY_CANCELLED),
            ],
        ) from exc

An error with no entry in this mapping table becomes a 500 Internal Server Error — which is the correct default, not a gap to patch over. An unmapped error means either a genuinely unexpected failure, or a domain error the router handler forgot to declare — either way, surfacing it as an opaque 500 rather than guessing at a status code is the honest behavior.

One Response Shape, Everywhere

class ErrorResponse(BaseModel):
    statusCode: int
    code: str
    message: str
    error: str


ErrorResponse(statusCode=404, code="ORDER_NOT_FOUND", message="Order not found.", error="Not Found")

Four fields, every time: statusCode is the HTTP status; code is the stable value the client actually branches on; message is for display, sourced from the error-message enum; error is the HTTP status text. A validation failure gets a fixed code regardless of which field failed:

{
  "statusCode": 400,
  "code": "VALIDATION_FAILED",
  "message": ["order_id must be a string"],
  "error": "Bad Request"
}

The Same Discipline on the Success Side

List responses use the plural of the domain object as the key — orders, users, payments — never a generic result, data, or items, alongside a count that reflects the total after filters, not just the current page's size:

{
  "orders": [
    { "order_id": "abc123", "status": "pending", "total_amount": 30000 }
  ],
  "count": 42
}

A single-record response is returned as the domain object directly — never wrapped in a generic envelope like {"success": True, "data": {...}}. The HTTP status code already tells the client whether the request succeeded; an envelope duplicates that information and adds an unwrapping step to every client's code for no benefit.

On the Repository side, this same "no generic key" discipline extends one layer further: a single-record lookup isn't a separate method at all. Callers pass take=1 to the same list-lookup method and pull the record out of the returned tuple:

orders, _ = await self.order_repository.find_orders(order_id=order_id, take=1, page=0)
order = orders[0] if orders else None

if not order:
    raise Exception(OrderErrorMessage.ORDER_NOT_FOUND)

Keeping a separate find_one would duplicate the dynamic filter-condition logic between two methods; unifying it into one path keeps there being exactly one place to add a new optional filter later.

Documenting the Contract This Implies

Every non-2xx status a handler can actually return should be declared in the API documentation, cross-checked against that handler's own error-mapping table — not just the success response. This is the single most common way API docs quietly rot: the docs UI renders, the endpoint appears "documented," but nothing tells a client what a 404 or 409 from that specific endpoint actually looks like, because only the happy path was ever written down.

Further reading in the repo

docs/architecture/error-handling.md — the full error-message/error-code enum pattern · docs/architecture/api-response.md — pagination, response shape, and the OpenAPI completeness bar