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:
- Reads
DATABASE_URLandOPERATOR_API_TOKENthroughrequiredEnv, which throwsMissing required environment variable: <name>on an undefined or empty value. There is no empty-string secret default anywhere (ADR-0009). - Mounts the request-id middleware, then the operator front door — both before any module, so nothing is reachable around them.
- Walks
MODULES. For each descriptor it enforces one-schema-per-module (Schema <x> claimed by two modulesis a boot error), constructs the module with its injected dependencies, mounts its routes at/<name>, registers its event consumers, and collects its maintenance sweeps. - Seals the event dispatcher (registering a consumer after this throws).
- Mounts
/healthz, the OpenAPI routes (GET /openapi.json,GET /docs), and the public-surface alias mount. - 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.onErroris the one place a throw becomes a response. A thrownAppErrormaps to its own HTTP status and a client-safe body; anything else becomes a500 internal_errorwhose 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:
commssends the order-confirmation email.fulfillmentenqueues the order for fulfillment.affiliatesattributes a commission from the referral code.merchant-billingcaptures the GMV fact for B2B invoicing.marketingevaluates automation triggers.- the
outbound-webhooksedge 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.