Unit 08.01: The service layer, and what belongs in it
Three layers, and the constraint on the middle one is what pays.
Router, service, repository
What each layer is responsible for and what it may import.
The code lists them.
LAYERS = [
("router", "parse, validate, call a service, shape the response",
"imports FastAPI"),
("service", "the actual rules and decisions",
"imports NOTHING from FastAPI"),
("repository", "reads and writes storage",
"imports the database driver only"),
]
print(f"{'layer':12} {'responsibility':50} constraint")
for layer, responsibility, constraint in LAYERS:
print(f"{layer:12} {responsibility:50} {constraint}")
print("""
The middle constraint is the one that pays. A service that imports no FastAPI
can be called from a worker, a scheduled job, a migration script or a test --
without a request object, a test client or an event loop.
When the service needs to signal a failure, it raises its own exception and
the router translates it to a status code.
""")
The service importing no FastAPI is the constraint that does the work. It can then be called from a worker, a scheduled job, a migration script or a plain test - without a request object, a test client or an event loop.
When a service needs to signal failure it raises its own exception, and the router translates that to a status code. The service never knows what a 404 is.
The mistake this prevents
The mistake is letting the service raise HTTPException because it is convenient. The service now depends on FastAPI, cannot be called from a worker without one, and has an opinion about HTTP that does not belong to it.
Takeaway
Routers handle HTTP, services hold rules and raise their own exceptions, repositories touch storage. The service importing no FastAPI is what makes the logic portable.
