The layers of the API
After this you can answer: Why does a procedure never touch SQL, and where does the business logic live?
Picture it
Section titled “Picture it”flowchart TB R["HTTP request /trpc/bookings.create"] --> P["procedure<br/>validate input with Zod"] P --> S["service<br/>business rules, no HTTP, no SQL"] S --> Q["query<br/>Postgres, Redis"] S --> U["queue<br/>SNS"]How it works
Section titled “How it works”- Procedure (
procedures/<feature>.ts): validates the input with a shared Zod schema, picks the user from the session, calls one service function and returns. No logic. - Service (
services/<feature>.ts): decides. “Is this a repeat booking? Is the key reused?” It knows no HTTP and no SQL; it throws plain errors likeConflictError. - Query (
queries/<feature>.ts): reads and writes Postgres or Redis. No business rules. - Queue (
queues/<feature>.ts): publishes messages to SNS. Like a query, no rules, and only services call it.
In MiniTickets
Section titled “In MiniTickets”Booking, layer by layer:
| Layer | File and function | Does |
|---|---|---|
| procedure | procedures/bookings.ts → create | BookTicketInput, rate limit, call service |
| service | apps/api/src/services/bookings.ts → bookTicket | replay or 409, clear caches |
| query | apps/api/src/queries/bookings.ts → createBookingTx | the locked transaction |
The input schemas live in packages/shared, so the browser form and the server check the same
rules. The web app imports only the router’s TypeScript type, never server code.
One deliberate exception: the three booking checks (event exists, not started, enough seats) sit in the query, because they must run on the locked row inside the transaction, and a service never holds a transaction.
| From → to | What happens | What allows it |
|---|---|---|
| API ECS service → API apponly when computeEnabled = true | each api task runs the Fastify server on port 4000 |
|
| Browser → API app | calls /trpc/* with the sid cookie |
|
| Web app → API app | server-side rendering of /events calls the api |
|
| API app → Sessions | reads the session of every request |
|
| API app → Events list cache | events.list asks the cache first |
|
| API app → Rate limiter | log-in and booking take a token first |
|
| API app → Inventory gate | a booking asks the gate before the database transaction |
|
| API app → RDS Postgresonly when dataEnabled = true | queries/*.ts run the SQL; a booking locks the event row (SELECT … FOR UPDATE) |
|
| API app → Transactional outbox | the booking and its outbox row are written in one transaction |
|
See it in AWS
Section titled “See it in AWS”CloudWatch → Log groups → /minitickets/dev/api: each request line carries correlationId,
responseTime and, for events.list, a cache field.
aws logs tail /minitickets/dev/api --since 10m
The router that joins all procedures: apps/api/src/trpc/router.ts → appRouter.
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| A rule works in one route but not another | The rule was copied into a procedure | Move it into the service |
| The ALB keeps sending traffic while Postgres is down | By design: health checks use /health | /ready in apps/api/src/app.ts |
| A test needs a database to test a rule | Business logic leaked into a query | The service should decide |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Why not put the SQL straight into the procedure?
Then the same rule ends up in every procedure that needs it, and they drift apart. It also ties business rules to HTTP, so you cannot test or reuse them without a server.
Where do HTTP status codes come from, if services know no HTTP?
Services throw plain errors such as NotFoundError. One middleware, the error mapper, turns each
kind into a tRPC code, which becomes the HTTP status. The Errors chapter shows it.
What is the cost of the strict layers?
More files and a little more code for small features. The booking checks had to break the rule once, on purpose, because they need the transaction.
Advanced: Health, readiness and the correlation id
GET /health only says the process is up; GET /ready also pings Postgres and Redis. Every
request gets a correlation id, returned as x-correlation-id and written on every log line; the
booking service puts it into the outbox message so the worker’s lines carry it too. The app is
built in apps/api/src/app.ts → buildApp.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What is the only job of a procedure?
- Which layer decides that a reused idempotency key is a 409?
- Which code may touch Redis, and which may publish to SNS?
- Why do the booking checks break the layer rule, and where?
- Where do the input schemas live, and why there?
Redraw it from memory: request → procedure → service → query and queue, with one job per box
Try this: open apps/api/src/procedures/bookings.ts and follow bookings.create down to the SQL, naming the layer of each file
Read this book online: https://book.theversehub.in/