When I first built an API, I crammed everything into a single router function. Take the request, pull from the DB, run the math, check the conditions, save it back, return the response. One function does it all, so at first it’s fast. But the moment you need to run that same calculation from a background job, you suddenly have no idea how to reach the logic you buried in the router. Copy it, or call the router by force. Neither is a real answer.
So I split it into three layers: router, service, repository. Each one’s responsibility comes down to a single line.
The router only sees HTTP
The router’s job ends at receiving a request and returning a response. It parses path parameters, query strings, and request bodies; it passes down things it gets from dependency injection, like the session; and it wraps the result in the agreed response shape. That’s it.
What the router must not do: call a DB repository directly, or hold complex business logic. The moment you put either of those in the router, that logic is trapped in that one endpoint.
@router.post("/{account_id}/refresh-balance", response_model=ApiResponse[AccountBalanceRefreshPublic])
async def refresh_balance(account_id: int, body: AccountBalanceSyncRequest, session: Session, session_user: SessionUser):
repo = AccountRepository(session=session)
account = await repo.find(account_id)
if not account or account.user_id != session_user.user_id:
raise HTTPException(status_code=404, detail="Not found.")
# The actual logic is delegated to the service
new_balance = await refresh_account_balance(session=session, account=account, ...)
return ApiResponse(data=AccountBalanceRefreshPublic(account_id=account.id, balance=new_balance))
The router body is short. Check the permission, call the service, wrap the response. What the real calculation is lives inside refresh_account_balance.
The service holds the logic, and both the router and the job use it
Business rules go in the service layer. It reads or changes data, and ties several pieces of logic together into one complete feature. Here’s the key: the service is shared code that the router and a background job can both call in exactly the same way.
Look at the balance-refresh logic above. It’s called from an API request, and after the trading worker processes an order it calls the same balance-sync function. Because the logic lives in the service, the two entry points share one function. No copy-paste, and fixing one side fixes both.
# The background job calls the same service
@celery_app.task(name="trades.execute_kis_orders", bind=True)
async def execute_kis_orders_task(self, trade_id: int) -> dict:
from app.domain.modules.account.account_balance_service import sync_account_balance
# ... order processing (omitted) ...
await sync_account_balance(session=self.session, account=account, ...)
return {"trade_id": trade_id, "status": "done"}
There doesn’t have to be exactly one service file per module. You split logic into responsibility-sized units and add files as shared or complex logic shows up. Conversely, for a module that only does simple reads and CRUD, you can skip the service file and let the router hit the repository directly. It’s not a rule for the sake of a rule — you split when there’s a reason to split.
Data access goes through the repository only
The service doesn’t write raw SQL either. Queries like select or session.execute are left to the repository, and the service only calls repository methods. Defining which rows to read and write, under which conditions, is the repository’s job. Setting transaction boundaries, or making in-memory edits to fields on a model you fetched, is fine for the service to do. That’s part of the business flow.
Router → service is the default; jobs only for the heavy stuff
One spot that trips people up. Can’t the router dispatch a background job directly? The default flow is router → service, not router → job. Routing a read or calculation that’s fine to do synchronously through a job just adds the round-trip overhead of the message queue and makes response time unpredictable.
There’s only one case where you delegate to a job: a heavy async task that’s triggered by the request but must not hold the response open — something like sending an order to an external system. There the router dispatches the job and finishes without waiting for the result (fire-and-forget). Everything else calls the service.
What you actually get from splitting
Reuse. Because the logic lives in the service, the router and the job call the same function. Testing. A service function can be called with just arguments, no HTTP, so unit testing is easy. Maintenance. To fix a bug you fix one place, and when you read the router, “what it takes in and what it gives back” is clear at a glance.
When each layer does just one thing, the time you spend wondering where to look disappears. That’s the real payoff of separating layers.