Skip to main content

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):

PortResponsibilityInitial adapter
DatabaseTenant-scoped repositories + sanctioned non-tenant reads/writesNeon (postgres + Drizzle, via Hyperdrive)
EmailProviderTransactional/bulk send + webhook parsingResend
SmsProviderOutbound/inbound SMSTwilio (stub when unconfigured)
VoiceProviderIVR / outbound voice (TwiML)Twilio (stub when unconfigured)
AiProviderCompletion / structured JSON / embeddingsGemini
TranscriptionProviderSpeech-to-text for verbatim audioGemini multimodal (stub when unconfigured)
IdentityProviderSession/principal verification, orgs → tenants, API keysClerk (via @clerk/backend)
ObjectStoreMedia/exports/inbound files by referenceR2
QueuePortEnqueue async work (idempotency key + R2 refs)Cloudflare Queues
ValidationProviderOptional email/mobile address validationPostcoder 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 wires makeStubSms() / 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 Database and IdentityProvider adapters share the one per-request postgres client (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_account by auth_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.