Request lifecycle
Almost every feature in Surplus is some variation of "read or write data through the API." Because of that, the request lifecycle is the most valuable thing to understand in the whole codebase. Learn it once and feature code becomes predictable.
The key idea: the client and server share a contract, and every endpoint passes through the same four layers. No hand-written REST, no ad-hoc fetch calls.
The four layers
Let's follow one real request — listing hubs — through each layer.
Layer 1 — The contract
The contract defines what an endpoint looks like: its HTTP method, path, and the Zod schemas for its input and output. It lives in packages/contracts and is imported by both the client and the server, which is why they can never disagree about an endpoint's shape.
// packages/contracts/src/hubs.ts
list: oc
.route({ method: 'GET', path: '/hubs' })
.input(paginatedRequest(hubsFilterSchema))
.output(paginatedResponse(hubListSchema)),Layer 2 — The route handler
The server implements each contract entry as a route handler. This is where HTTP concerns live: authentication middleware, permission checks, and reading the validated input. The handler does no database work itself — it delegates to a service.
// apps/server/src/routes/hubs/list.route.ts
export const list = os.hubs.list
.use(authMiddleware())
.handler(async ({ input, context }) => {
return services.hubs.list({
limit: input.limit,
offset: input.offset,
filters: input.filters,
orderBy: input.orderBy,
orderDirection: input.orderDirection,
})
})authMiddleware() rejects unauthenticated or suspended users before the handler runs. Pass roles — authMiddleware('admin') — to restrict an endpoint further. Authorization covers this in detail.
Layer 3 — The service class
Service classes are the public surface of the domain logic. Each method opens a database transaction and calls into a focused service function. Services receive their dependencies (the database client, S3, Redis, Stripe) by injection — they never reach for global configuration.
// packages/services/src/hubs/index.ts
async list(params: {
filters?: HubsFilter
limit: number
offset: number
}) {
return this.deps.primaryDb.transaction(async (tx) => list(tx, params))
}Layer 4 — The service function
The service function is where the actual database work happens, inside the transaction it was handed. It is pure domain logic: no HTTP, no knowledge of who called it, just data in and data out. This is the layer you can test and reason about in isolation.
// packages/services/src/hubs/list.ts (shape)
export async function list(tx: DbTransaction, params: { /* ... */ }) {
// build and run the Drizzle query against `tx`, return rows
}Why four layers?
Each layer has exactly one job, which makes the codebase easy to navigate and change:
| Layer | Owns | Knows nothing about |
|---|---|---|
| Contract | The endpoint's shape and validation | How it's implemented |
| Route handler | HTTP, auth, input/output | SQL, transactions |
| Service class | Transactions, dependency injection | HTTP, cookies |
| Service function | Domain logic and queries | Who called it |
When you add a feature, you touch the same four files in the same order. When you read a feature, you always know where to look.
The round trip, end to end
Here's the full journey of list hubs, from a component to the database and back:
Browser component
→ useSuspenseQuery(orpc.hubs.list.queryOptions(...)) // typed call
→ HTTP GET /hubs (cookie attached, x-request-id set)
→ Hono middleware pipeline (see below)
→ ORPC matches the contract → route handler
→ authMiddleware() validates the session
→ services.hubs.list(...) opens a transaction
→ list(tx, ...) runs the query
← rows
← typed, validated response
← JSON over HTTPS
← TanStack Query caches the result
← component rendersThe client side is set up once in apps/client/src/helpers/orpc.ts, which wraps the shared contract in an ORPC client and TanStack Query utilities. Every call sends cookies (credentials: 'include') and an x-request-id so the whole client-to-server chain shares one correlation id.
What wraps every request
Before a request reaches a route handler, it passes through the server's middleware pipeline in apps/server/src/index.ts. In order:
- Apitally (staging/production only) — request logging with sensitive fields masked.
- Security headers — HSTS and related headers via Hono's
secureHeaders. - Request context — generates or accepts an
x-request-idand runs the rest of the request inside anAsyncLocalStoragescope, so logs, spans, and the error mapper all share the same context automatically. - Rate limiting — a global per-IP limit backed by Redis.
- CORS — an environment-scoped allowlist (only
*.sharingexcess.comin production), with credentials enabled. - The ORPC handler — matches the contract, runs the procedure, and wraps it in:
- an error-mapping interceptor that converts typed domain errors into the right HTTP status and attaches the
requestIdto the payload, and - an observability span named
orpc.{procedure}.
- an error-mapping interceptor that converts typed domain errors into the right HTTP status and attaches the
Cookies are applied through a side-channel on the ORPC context so authentication can set or clear the session cookie without the service layer knowing about HTTP at all.
Where this takes you
- API and contracts — how the contract generates the public OpenAPI spec.
- Frontend architecture — how the client fetches and caches data.
- The API server — a closer look at the server app.