The API: one Lambda per route family
apps/api is OpsDNAβs backend HTTP surface. It is not a monolithic server: every file in apps/api/src/entries/ compiles to its own AWS Lambda function β roughly 26 HTTP entries plus 9 background workers. Each Lambda bundles only the route groups, handlers, and middleware it actually serves.
Why per-entry Lambdas?
The rationale is documented at the top of apps/api/src/server.ts:
- Tree-shaken bundles β esbuild strips everything an entry doesnβt import, keeping cold starts small (enforced by
check:lambda-budgetsinbun run verify). - Scoped IAM β each Lambdaβs execution role gets only the permissions its handlers need (e.g. only the identity-resolve Lambda may
rds-db:connectasidentity_writer). - Fault isolation β a startup bug in one Lambda cannot take down another.
The shared contract survives the split because all HttpApiGroup definitions live in one package; apps/api/src/api.ts composes them all purely for documentation.
Request path
The four-layer pattern
Every HTTP entry follows the same effect-http composition (see the header comment in apps/api/src/server.ts):
- Groups β
src/groups/<name>.ts: typedHttpApiGroupendpoint definitions built oneffect/unstable/httpapiand shared contract schemas frompackages/*-api-contract. - Handlers β
src/handlers/<name>.ts: implementations asHttpApiBuilder.group(api, "name", handlers)layers. - Middleware β
src/middleware/: auth seams (workos-jwt,api-token,plaid-jws). - Entries β
src/entries/<name>.ts: pick groups + handlers + middleware, then:
// apps/api/src/entries/kpi.ts (abridged)
const appLayer = makeAppLayer(withApiFailureProtocol(KpiApi), {
handlers: [kpiHandlersLayer],
middleware: [
makeObservedApiFailureGroupRuntimeLayer('kpi', {
servedContract: 'envelope',
observationProfile: 'terminal',
}),
KpiWorkOsJwtMiddlewareLayer,
grantTenantResolverLayerFor(getApiPersistenceDatabase),
],
});
export const handler = toLambdaHandler(appLayer);lambda-adapter.ts and toLambdaHandler
src/lambda-adapter.ts translates API Gateway HTTP API v2 events to Fetch Requests and back (base64-encoding non-text response bodies). toLambdaHandler in src/server.ts wraps that with:
- Keep-warm short-circuit β an EventBridge ping sends
{ warmer: true }; the adapter returns200 "warm"before touching the handler, auth, or the DB. A real API Gateway event never carrieswarmer. - Tracing lifecycle β
ensureTracing()before the request,flushTracing()after (drain the batch span processor before Lambda freezes the container). - Root span β every invocation runs under an
http.requestspan. If the portal Worker sent a W3Ctraceparent, the Lambda continues that trace, so a browser session appears as one connected Honeycomb trace: portal SSR β Lambda β DB. - Bounded failure logging β the framework logger is disabled (
disableLogger: true); the API failure observer is the only production failure logger and never renders raw causes or stacks. See Failure boundary. - Request-phase diagnostics β per-phase timings (
tracing_init_ms,handler_ms,serialization_ms,otel_flush_ms) emitted as one structured summary line.
Long path parameters silently 404. find-my-way (the router under HttpRouter.toWebHandler) rejects any path parameter longer than maxParamLength. Content-addressed refs like journal-entry:<kind>:<source-ref>:v1:<64-hex>:<uuid> are ~180 chars, so server.ts raises the cap to 1024. If you invent an even longer ref format, the router returns RouteNotFound before your handler ever runs.
Where to go next
- Adding an endpoint β the contract β handler β entry workflow.
- Database access β RDS Proxy, IAM tokens, and the three role-scoped pools.
- Background workers β the 9 SQS/EventBridge Lambdas.
- Failure boundary β ADR-0041βs observable failure protocol.
- The stacks that provision all of this live in /infra; data access patterns in /persistence.