Skip to Content
API (apps/api)Architecture

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-budgets in bun 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:connect as identity_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):

  1. Groups β€” src/groups/<name>.ts: typed HttpApiGroup endpoint definitions built on effect/unstable/httpapi and shared contract schemas from packages/*-api-contract.
  2. Handlers β€” src/handlers/<name>.ts: implementations as HttpApiBuilder.group(api, "name", handlers) layers.
  3. Middleware β€” src/middleware/: auth seams (workos-jwt, api-token, plaid-jws).
  4. 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 returns 200 "warm" before touching the handler, auth, or the DB. A real API Gateway event never carries warmer.
  • 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.request span. If the portal Worker sent a W3C traceparent, 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

Last updated on