0017: Use ports and adapters
The core calls ports, adapters implement them, and source dependencies point inward
- Status: Accepted
- Date: 2026-10-16
- Decision maker: Trevor Gerhardt
Context
Contributors need one model that tells them where new code goes. ADRs 0001, 0005, 0008, and 0011 each state part of a model, but no record names it or states its main rule.
The package boundaries did not show where storage code lives. Services and Auth repositories held Drizzle queries beside domain operations. MIDOT HTTP handlers decoded requests and mapped errors by hand, while Concourse used a declared HTTP API for the same work.
Decision
Concourse uses Hexagonal architecture, also called ports and adapters. It uses the Effect form of the pattern that Rat Stack describes.
| Term | Meaning in this workspace |
|---|---|
| Core | Code that decides what an operation means: Services and the domain modules of Auth |
| Port | A Context.Service contract that is named for its job, such as ProjectRepository |
| Adapter | A Layer that implements a port with one technology, such as Drizzle, SMTP, or Better Auth |
| Driving adapter | Code that calls the core: API routes, SDK callers, server actions, route handlers, the CLI |
| Driven adapter | Code that the core calls through a port: storage, mail, host capabilities, Better Auth |
| Composition root | The runtime module that selects adapters and provides them, such as apps/*/lib/runtime.ts |
| Shared kernel | @concourse/contracts, the browser-safe schemas and HTTP declarations that all sides share |
The dependency rule. Source dependencies point inward. The core does not import a storage library, a framework, or a vendor SDK. A driven adapter does not import the core. This is the rule of the Clean Architecture and of the Onion Architecture. We use the Hexagonal names because they match Effect services and Layers.
Storage ports live with their store. A repository port and its Drizzle adapter belong to the
package that owns the store: @concourse/db for application tables and @concourse/rsdb for
Rackspace databases. Rat Stack keeps its store ports in its database package in the same way. This
keeps ADR 0001. A repository returns domain facts, and the types
of those facts sit beside the port.
A port with one adapter stays in one class. The
module-shape standard keeps the contract and
its only implementation in one Context.Service class. A separate adapter Layer is necessary only
when a second adapter exists.
Vendor frameworks are adapters. Better Auth is a driven adapter inside @concourse/auth. Its
vendor wiring stays in Auth, and Concourse does not hide the Better Auth interface behind its own
port. Auth's own repositories live in Persistence like the others.
Driving adapters are thin. A driving adapter decodes input with a contracts schema, makes one call to a core port or an SDK client, and maps the result to a framework response. It can call a core port directly. The API is one driving adapter, for callers outside the process. Module shape owns the detailed rule.
One way to build an HTTP surface. Each HTTP surface is an HttpApi declaration in
@concourse/contracts. @concourse/api implements it and @concourse/sdk derives its client. An
RPC surface waits until an operation needs streaming or a second kind of client.
Framework glue stays in the app. Next.js and TanStack Start adapters stay in each app under
framework/ folders. A framework adapter package waits until a second app uses that framework.
The rules are checked. pnpm lint:boundaries enforces the dependency rule. The
architecture overview lists each role and the current exceptions.
Consequences
- A reader finds all storage code in
db,rsdb, and the Better Auth vendor wiring ofauth. dbandrsdbhold domain-shaped queries and the fact types that those queries return.- Tests replace a port with a small Layer. Production and tests use the same contracts.
- A new import that breaks the dependency rule fails CI. Known violations stay listed until they are fixed.
- MIDOT and Concourse use the same HTTP declaration, refusal mapping, and client derivation.
contractsimports no other workspace, sodbandrsdbcan decode stored facts with its schemas.
References
- ADR 0001: Persistence owns schema and writes
- ADR 0004: Browser-safe contracts drive the API and SDK
- ADR 0005: Services own domain operations
- ADR 0008: Compose backend operations with Effect
- ADR 0011: Separate RSDB access from domain behavior
- Issue #196: Move the RSDB queries out of Services
- Issue #198: Move MIDOT sign-in workflows into a core service