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:
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
packages/<name>-ports/ports/<name>.port.ts— the interface, with JSDoc.packages/<name>-adapter/<library>.adapter.ts— the implementation.- Add both to
apps/monolith/package.json, runbun install, and wire one field inDIContainer.
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:
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:
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 fromprocess.env.BETTER_AUTH_COOKIE, a process-global that nothing sets, so it returnsnullon every call. One process variable also cannot represent concurrent requests from different users. Read sessions from the request, in middleware. - Do not treat
session.idas a session id.getSession()returns Better Auth’s real one, butsignIn.email()andsignInAnonymous()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.