Skip to content

The layers of the API

2 min read · main part only; folded sections are optional

After this you can answer: Why does a procedure never touch SQL, and where does the business logic live?

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"]
  1. 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.
  2. 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 like ConflictError.
  3. Query (queries/<feature>.ts): reads and writes Postgres or Redis. No business rules.
  4. Queue (queues/<feature>.ts): publishes messages to SNS. Like a query, no rules, and only services call it.

Booking, layer by layer:

LayerFile and functionDoes
procedureprocedures/bookings.ts → createBookTicketInput, rate limit, call service
serviceapps/api/src/services/bookings.ts → bookTicketreplay or 409, clear caches
queryapps/api/src/queries/bookings.ts → createBookingTxthe 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 → toWhat happensWhat allows it
API ECS service → API apponly when computeEnabled = trueeach api task runs the Fastify server on port 4000
Browser → API appcalls /trpc/* with the sid cookie
  • url same origin, so no CORS question; every mutation must carry the exact Origin (CSRF check, mutationAllowed)mutationAllowed
Web app → API appserver-side rendering of /events calls the api
API app → Sessionsreads the session of every request
  • code createContext reads the sid cookie and asks queries/sessions.tscreateContext
API app → Events list cacheevents.list asks the cache first
API app → Rate limiterlog-in and booking take a token first
API app → Inventory gatea booking asks the gate before the database transaction
API app → RDS Postgresonly when dataEnabled = truequeries/*.ts run the SQL; a booking locks the event row (SELECT … FOR UPDATE)
API app → Transactional outboxthe booking and its outbox row are written in one transaction
  • code createBookingTx inserts the booking and the BookingConfirmedMessage row togethercreateBookingTx
See the API app on the map →

CloudWatch → Log groups → /minitickets/dev/api: each request line carries correlationId, responseTime and, for events.list, a cache field.

SymptomCauseWhere to look
A rule works in one route but not anotherThe rule was copied into a procedureMove it into the service
The ALB keeps sending traffic while Postgres is downBy design: health checks use /health/ready in apps/api/src/app.ts
A test needs a database to test a ruleBusiness logic leaked into a queryThe service should decide
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.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What is the only job of a procedure?
  2. Which layer decides that a reused idempotency key is a 409?
  3. Which code may touch Redis, and which may publish to SNS?
  4. Why do the booking checks break the layer rule, and where?
  5. 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/