Ports & Adapters

A port is an interface this codebase owns. An adapter is the class that implements it using someone else’s library. Code above the port never imports the library, so the library can be swapped, faked in a test, or wrapped without touching a caller. There is exactly one live example in this backend, and it is the one to copy.

The reference hexagon: AuthPort

ADR-0053 makes AuthPort the reference arrangement. It is three files and nothing in between:
A route reaches the library only through the container:
There is no entity, repository, or use case behind AuthPort, and that is deliberate. A port plus an adapter is a complete hexagon. users-domain once grew all three; none were ever called, and they were deleted (ADR-0053). Add a layer when something calls it, not before.

Adding a new seam

  1. packages/<name>-ports/ports/<name>.port.ts — the interface, with JSDoc.
  2. packages/<name>-adapter/<library>.adapter.ts — the implementation.
  3. Add both to apps/monolith/package.json, run bun install, and wire one field in DIContainer.
backend/bun/AGENTS.md § “How to Add a New Domain” has the step-by-step version, including the manifest and dependency edges that are easy to miss.

Translate errors at the boundary

This is the part most often skipped, and it has already caused one production bug. error.middleware.ts dispatches on type: a LocalizedError or an ApplicationError answers with its own statusCode, and anything else becomes a 500 with the message replaced by a generic one. So an adapter that catches a library error and rethrows a bare Error has quietly converted a meaningful failure into an internal server error. This is exactly what happens to login today. Better Auth signals all four of its credential failures by throwing APIError(UNAUTHORIZED, INVALID_EMAIL_OR_PASSWORD). AuthAdapter flattens them into Error('Email sign-in failed') (tests/unit/auth-adapter.test.ts pins this), so a wrong password still answers HTTP 500. Fixing it means rethrowing a named error the HTTP layer already maps, something like:
Tracked as task 7.6, still open — see backend/bun/openspec/changes/architecture-improvements/tasks.md. @bun-core/shared-kernel already carries the common ones, each with its status code — reach for these before writing a new class:
AuthPort has not been converted yet. Every method — including signIn.email, whose credential path is walked through above — still throws a generic Error on failure; getSession swallows its error and reports “not logged in” instead. A wrong password and a duplicate email on sign-up both answer 500 today. Do not copy any of it as-is; the fix is tracked as task 7.6 in the architecture-improvements change.

What not to copy from AuthPort

The port is real and live, but it also carries defects worth naming so they do not spread:
  • No useSession(). It returns the constant { data: null, isPending: false }. It is a client-side React idiom lifted into a server interface, and it means nothing here.
  • No reading request state from process.env. getSession() reads its cookie from process.env.BETTER_AUTH_COOKIE, a process-global that nothing sets, so it returns null on every call. One process variable also cannot represent concurrent requests from different users. Read sessions from the request, in middleware.
  • Do not treat session.id as a session id. getSession() returns Better Auth’s real one, but signIn.email() and signInAnonymous() fabricate the session object and put the user id there, because the sign-in response carries none.

Ports that exist today

ICache is worth reading alongside AuthPort: it is the one port with two implementations, so it shows why the seam earns its keep. A shared contract test runs the same assertions against both, which is how a divergence was caught — BunRedisCache.get() tested if (!value) and so reported a cached empty string as a miss, while InMemoryCache returned it. Losing Redis changed observable behaviour. Both now test value === null.