Skip to course content
Free FastAPI backend course

FastAPI for AI Backend Development

Unit 08.02: Keeping the framework out of your logic

A domain exception knows what went wrong. The router decides how to say it over HTTP.

Called directly, with no HTTP anywhere

A service function raising a domain error, called outside any request.

The code calls it both ways and shows the mapping.

class InvoiceNotFound(Exception):
    """A domain error. Knows nothing about HTTP."""


INVOICES = {"INV-1": {"id": "INV-1", "amount": 240.0}}


def fetch_invoice(invoice_id: str) -> dict:
    """A service function: no FastAPI import, no status codes."""
    invoice = INVOICES.get(invoice_id)
    if invoice is None:
        raise InvoiceNotFound(invoice_id)
    return invoice


print("called directly, with no HTTP anywhere:")
print("  ", fetch_invoice("INV-1"))
try:
    fetch_invoice("INV-9")
except InvoiceNotFound as exc:
    print(f"   raised {type(exc).__name__}({exc})")

MAPPING = {"InvoiceNotFound": 404, "PermissionDenied": 403,
           "ValidationFailed": 422}
print(f"\nthe router maps domain errors to statuses: {MAPPING}")

# The service decides what went wrong; the router decides how to say it over
# HTTP. That split is what lets the same service back an API, a worker and a
# command-line tool.

The function is called directly, with no client, no app and no event loop. That is what "testable without HTTP" means concretely, and it makes the logic tests fast enough to run on every save.

The mapping from domain error to status lives in the router, in one place, so every endpoint translates the same exception the same way.

The mistake this prevents

The mistake is returning None or a tuple of (result, error) from services to avoid exceptions. Every caller then has to check, most will forget one path, and the failure becomes a NoneType error somewhere unrelated.

Takeaway

Services raise domain exceptions; routers map them to status codes in one place. The service is then callable with no HTTP involved at all.