Skip to content

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.

TermMeaning in this workspace
CoreCode that decides what an operation means: Services and the domain modules of Auth
PortA Context.Service contract that is named for its job, such as ProjectRepository
AdapterA Layer that implements a port with one technology, such as Drizzle, SMTP, or Better Auth
Driving adapterCode that calls the core: API routes, SDK callers, server actions, route handlers, the CLI
Driven adapterCode that the core calls through a port: storage, mail, host capabilities, Better Auth
Composition rootThe 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 of auth.
  • db and rsdb hold 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.
  • contracts imports no other workspace, so db and rsdb can decode stored facts with its schemas.

References