Ports & adapters
The hexagonal seam is the keystone of the back end. Every external capability —
database, email, SMS, voice, AI, transcription, identity, object storage, queueing, and
address validation — sits behind a TypeScript port (an interface in packages/ports).
Domain and route code import only ports; the concrete adapters live in
packages/adapters and are wired in exactly one module.
The ports
The Ports container assembled at the composition root exposes these seams (see
api/src/composition-root.ts and the interfaces in packages/ports/src):
| Port | Responsibility | Initial adapter |
|---|---|---|
Database | Tenant-scoped repositories + sanctioned non-tenant reads/writes | Neon (postgres + Drizzle, via Hyperdrive) |
EmailProvider | Transactional/bulk send + webhook parsing | Resend |
SmsProvider | Outbound/inbound SMS | Twilio (stub when unconfigured) |
VoiceProvider | IVR / outbound voice (TwiML) | Twilio (stub when unconfigured) |
AiProvider | Completion / structured JSON / embeddings | Gemini |
TranscriptionProvider | Speech-to-text for verbatim audio | Gemini multimodal (stub when unconfigured) |
IdentityProvider | Session/principal verification, orgs → tenants, API keys | Clerk (via @clerk/backend) |
ObjectStore | Media/exports/inbound files by reference | R2 |
QueuePort | Enqueue async work (idempotency key + R2 refs) | Cloudflare Queues |
ValidationProvider | Optional email/mobile address validation | Postcoder or no-op adapter |
Cron is the Worker's scheduled entrypoint, not a provider port. There is no Scheduler port,
Workflow binding, or Durable Object alarm job runner; durable progress lives in Postgres/R2 ledgers.
The rule is strict: no provider SDK type appears outside its adapter. The Neon
driver, the Resend SDK, the Twilio client and the Google Generative AI SDK are imported
only within their packages/adapters module.
The Database port is repository-based
The Database port (packages/ports/src/database.ts) is not a raw query interface —
it exposes forTenant(ctx), which returns a TenantDatabase whose transaction()
hands the callback a bundle of typed repositories (responses, surveys, alerts,
leads, compliance, verbatim, …). Domain code calls repository methods like
r.responses.capture(input) and never sees SQL, Drizzle, or a connection object:
export interface Database {
forTenant(ctx: TenantContext): TenantDatabase;
ping(): Promise<boolean>;
// …sanctioned non-tenant paths: publicSurvey, capturePublic, handleEmailBounce, …
}
export interface TenantDatabase {
transaction<T>(fn: (repos: TenantRepositories) => Promise<T>): Promise<T>;
}
forTenant is the only way to obtain a tenant-scoped handle, so a tenant query is
impossible without a verified TenantContext. A handful of explicitly-documented methods
on Database (e.g. publicSurvey, capturePublic, handleEmailBounce, publicBadge)
run outside any single tenant scope — they resolve the owning tenant from the survey or
token first, then pin RLS internally. See
Multi-tenancy & RLS.
Swapping a provider at the composition root
api/src/composition-root.ts is the only module that imports concrete adapters. Each
provider is built lazily and memoised per container, so a route that touches one provider
never constructs the others:
export function buildContainer(env: Env): Ports {
let db: Database | undefined;
const conn = () => (pg ??= createPgClient(dbConnectionString(env)));
return {
get db() { return (db ??= makeNeonDatabase(dbConnectionString(env), conn())); },
get email() { return (email ??= makeResendEmail({ apiKey: env.RESEND_API_KEY, … })); },
get sms() { return (sms ??= env.TWILIO_ACCOUNT_SID ? makeTwilioSms({…}) : makeStubSms()); },
get ai() { return (ai ??= makeGeminiAi({ apiKey: env.GEMINI_API_KEY, model: env.GEMINI_MODEL })); },
get identity() { return (identity ??= makeClerkIdentity({ client: conn(), secretKey: env.CLERK_SECRET_KEY, jwtKey: env.CLERK_JWT_KEY })); },
// …store, queue, validation
};
}
Two patterns are worth noting:
- Optional providers degrade to a loud stub. When Twilio (
TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/TWILIO_FROM) or a Gemini key is absent, the container wiresmakeStubSms()/makeStubVoice()/makeStubTranscription(), which refuse to send rather than silently dropping work. This is what lets local dev and tests run without telephony credentials. - Shared connection. The
DatabaseandIdentityProvideradapters share the one per-requestpostgresclient (conn()). The Clerk adapter verifies the session JWT against Clerk's JWKS (not the DB), then uses that connection only to resolve/provision the tenant (tenant.client_accountbyauth_org_id) and read custom roles (identity.member_role). API keys are likewise an app-DB lookup, independent of the IdP.
Because every provider is chosen here from env, the planned later moves (Neon → Azure
Postgres, Resend → Azure email, Gemini → Azure AI Foundry) are a new adapter + a
one-line edit here, never an application rewrite. Auth has already moved this way: the
identity seam now sits behind Clerk with no change to the middleware or domain layer,
which stay written against the ports.