ConceptsArchitecture

Architecture

Flightdeck is one deployable Hono application (ADR-0001, FD-1). Inside it, every unit of ownership is a module with its own Postgres schema; the only code allowed to speak to the outside network at the transport layer lives in two edges/ directories. Everything else that looks like a “service call” is an in-process function call or an in-process event dispatch.

This page is the mechanics: how a request is composed, routed, and answered, and how work fans out internally versus over the wire.

The composition root

app/src/modules.ts is the single source of truth for what runs. It exports one array, MODULES, of { name, schema, create } descriptors. If a module is not in that array it does not run — the built-must-be-wired conventions rule cross-checks this file against the modules/ directory and fails CI on an orphan in either direction. This is the deliberate defense against “built but not wired,” the defect class this repo is designed against.

app/src/main.ts is the entry point that turns that registry into a live Hono app. createApp() does exactly this, in order:

  1. Reads DATABASE_URL and OPERATOR_API_TOKEN through requiredEnv, which throws Missing required environment variable: <name> on an undefined or empty value. There is no empty-string secret default anywhere (ADR-0009).
  2. Mounts the request-id middleware, then the operator front door — both before any module, so nothing is reachable around them.
  3. Walks MODULES. For each descriptor it enforces one-schema-per-module (Schema <x> claimed by two modules is a boot error), constructs the module with its injected dependencies, mounts its routes at /<name>, registers its event consumers, and collects its maintenance sweeps.
  4. Seals the event dispatcher (registering a consumer after this throws).
  5. Mounts /healthz, the OpenAPI routes (GET /openapi.json, GET /docs), and the public-surface alias mount.
  6. Installs the single error boundary (app.onError).

createApp() starts no timers and binds no socket. It only collects the sweeps each module registers. app/src/server.ts is the one file that binds a port and schedules sweeps, so any test can boot the whole app deterministically by calling createApp() directly.

Modules — schema, interface, events

Each modules/<name>/ owns exactly one Postgres schema, exposes an in-process interface (modules/<name>/src/index.ts) plus HTTP routes, and publishes or consumes events through the injected dispatcher. A module never imports another module’s internals — only its published interface and the shared @dscodotco/contracts package — enforced by the module-boundaries rule.

Dependencies are injected at construction, never imported ambiently. The create({ bus, now, databaseUrl, env }) signature is the whole contract: the event bus, an injected clock (now() — never Date.now() in domain code), the database URL, and env (which is requiredEnv, so any config read at construction time is fail-startup). Cross-module composition that would otherwise form a package cycle is inverted here too — for example comms receives a personsConsent reader and an ownerReader factory from the composition root rather than importing persons or identity.

See Reference -> Modules for the full registry with a one-line purpose each.

Edges — the only network boundaries

Exactly two directories may touch the outside network, and EventBridge/SQS may appear only here:

  • edges/webhooks-gateway/ — the universal inbound front door for every third-party callback.
  • edges/outbound-webhooks/ — merchant deliveries and federation: the dispatch ledger, retry, DLQ, and replay.

Both are registered in MODULES like any other module (schemas webhooks and outbound_webhooks); “edge” is a boundary role, not a separate runtime.

The request lifecycle

Every inbound HTTP request crosses the same middleware spine before it reaches a handler. The load-bearing detail is the public-surface alias: the OpenAPI spec’s paths are prefix-free (/v1/merchant/orders), but modules mount at /<name> (/merchant-api/v1/merchant/orders). The contract is the spec, and the deployment conforms to it (ADR-0014) — so app/src/public-surface.ts derives an alias table at boot and re-dispatches each spec path to its owning module route.

Two properties make this safe to reason about:

  • The alias cannot loop. Rewritten paths start with a module-name segment; spec paths never do, so a rewritten request never re-matches an alias. The alias table is fail-closed: a spec path served by zero modules, or by two, is a boot error, not a silent 404.
  • app.onError is the one place a throw becomes a response. A thrown AppError maps to its own HTTP status and a client-safe body; anything else becomes a 500 internal_error whose original message and stack go only to the server log and Sentry — never to the client. Both paths are keyed by the same correlation id (x-request-id, always echoed), so a client-facing report traces back to a real event. That id is the observability handle referenced throughout these docs.

Internal events versus outbound webhooks

These are two different planes and the distinction is the whole architecture.

Internal events are in-process. app/src/dispatcher.ts is a small sealed pub/sub. A publisher calls bus.publish(envelope) after its own transaction has committed; the dispatcher runs each registered consumer sequentially and, if any consumer throws, collects the failures and re-throws event <name>: consumers failed: <modules>. A consumer failure is surfaced, never swallowed — it bubbles into the already-committed request as a retryable 5xx (every money step is deterministically idempotent, so the retry replays rather than double-posts — see Idempotency).

Outbound webhooks are the network plane. edges/outbound-webhooks/ consumes a small allowlist of merchant-deliverable internal events, matches active subscriptions, signs the body, and POSTs it through an SSRF-guarded, DNS-pinned client with a retry ladder and a dead-letter path (see Security and below).

A concrete flow: a paid order

An order being paid is a good trace because it fans out to nearly every domain. When checkout captures a charge and persists it, it publishes payment.charged.v1 (the payload’s provider_charge_ref is a plain opaque string — no Stripe shape is assumed, so any provider behind the payments port can populate it) and commerce.order.placed.v1 (self-contained, carrying the full priced line items so no consumer calls back into the producer).

Consumers of commerce.order.placed.v1, all in-process:

  • comms sends the order-confirmation email.
  • fulfillment enqueues the order for fulfillment.
  • affiliates attributes a commission from the referral code.
  • merchant-billing captures the GMV fact for B2B invoicing.
  • marketing evaluates automation triggers.
  • the outbound-webhooks edge fans the event out to any merchant subscriptions — today the only merchant-deliverable event.

Inbound provider callbacks

Every third-party callback enters through one route, POST /v1/webhooks/:provider/:tenant, and maps to one internal event, webhook.received.v1. The gateway reads the raw body before parsing (parsing would normalize whitespace and break the HMAC), verifies the provider signature, dedups on (provider, provider_event_id), stores the raw payload, and publishes exactly one event. A duplicate delivery answers 200 { received: true, deduped: true } with no second publish. A stored delivery whose fan-out publish throws returns 502 publish_failed — never a 2xx that falsely claims delivery. The provider’s own event type (for example invoice.paid) rides inside the payload’s event_type; consumers filter on it cheaply. See Event model for the envelope and registration rules.

Why this shape

The parent platform’s pain was coordination surfaces nobody staffed: machine-to-machine grant migrations, cross-service event wiring, SDK regeneration lockstep, per-service infrastructure. A modular monolith with enforced boundaries deletes those surfaces by construction while keeping the one discipline that never broke there — module = schema = interface — at zero marginal cost in-process. 300 tenants is a tenancy number, not a service-count number.

Extraction is earned

A module may eventually become its own deployed service, but only behind a written brief with load and isolation evidence — nobody extracts speculatively. The event contract shape is deliberately the same shape an out-of-process bus would use, so promoting a module later does not require redesigning its events. Until that brief exists, “the module boundary” is an in-process contract enforced by lint rules, not a network call.