Adapter packages
How a third-party service becomes a package named by its capability, with the vendor inside, the contract in training and tests at three levels.
A third-party service never appears in an app. It lives behind a package named by what it does, auth, payment, database, queue, emails, never by the vendor behind it. This page is the rule those packages follow. @workspace/auth is the worked example.
When a package is justified
A dedicated package is worth it when at least two of these hold:
| Factor | Question |
|---|---|
| Multi-app | Will more than one app consume it? |
| Paid third party | Can pricing, compliance or an acquisition force a change? |
| Migration plausible | Could the vendor change within a year? |
| Tests | Do consumers need to run without the real service? |
Clerk, Stripe, Postgres and pg-boss all qualify. Radix UI does not: it is a UI primitive, imported directly by @workspace/ui.
Naming
The package is the capability. The vendor is an implementation detail of its package.json.
| Package | Vendor inside | Not called |
|---|---|---|
auth | Clerk | clerk |
payment | Stripe | stripe |
database | Postgres + Drizzle | drizzle, supabase |
queue | pg-boss | pg-boss |
emails | Resend | resend |
Renaming the package when the vendor changes would touch every consumer for no behavioural change. The full package taxonomy, <subject>, ui-*, <subject>-assets and capability names, is on Architecture.
The contract lives in training
The package does not declare the interface it implements. The port is declared once in @workspace/training/application/ports, the package implements it, and the app wires the implementation at its composition root.
training/application/ports declares SessionTokenService, WebhookVerifier, Queue…
packages/auth, payment, queue implement createHmacSessionTokenService(), createClerkWebhookVerifier()…
apps/api (composition root) wires router({ sessionTokens: createHmacSessionTokenService(…) })Consequences:
- The dependency arrow goes from the adapter to
training, never the other way. The core never imports an adapter. - A consumer types against the port, so its tests take the fake from
@workspace/training/testingand never load the vendor. - The adapter owns its literal.
authexportsCLERK_PROVIDER = "clerk",paymentexportsSTRIPE_PROVIDER, and the API composes itsWebhookProviderunion from them. The domain only seesstring.
Where a port makes no sense, because the thing is a React surface rather than a service, the package declares a vendor-free type itself: AuthPort, AuthSession, AuthComponents in auth. The rule is the same: no vendor type crosses the package boundary.
One import site per vendor
Only the package imports the vendor SDK. Inside it, one module per runtime holds the import so a bundle never pulls the wrong side:
| Entry | Imports | Loaded by |
|---|---|---|
@workspace/auth/clerk | @clerk/clerk-react | Vite app, lazily |
@workspace/auth/nextjs | @clerk/nextjs | Next.js client |
@workspace/auth/server/nextjs | @clerk/nextjs/server | Next.js server |
@workspace/auth/server | @clerk/backend | Node |
The root entry exports only the store-backed hooks and the types, so an app can depend on the package without bundling Clerk until it mounts the provider.
Apps never list the vendor in their own package.json. Where a lint rule can enforce it, it does; otherwise review does.
Entry points by runtime
Every adapter package exposes subpath exports, never a single barrel, cut along the same lines:
| Entry | Content |
|---|---|
. or ./client | Browser side: hooks, providers, vendor-free types |
./server | Node side: port implementations, webhook handlers |
./nextjs, ./server/nextjs, ./service-nextjs | The Next.js variants when the vendor has a Next SDK |
./config | XxxConfig, xxxConfigSchema, parseXxxConfig |
./types | Types alone, no runtime |
./testing, ./server/testing | Test helpers of the package, see below |
./styles.css | Tailwind @source directive when the package ships components |
payment adds ./ui/* for its donate components, queue exposes ./drivers/pg-boss, database exposes ./repositories/drizzle and ./schema. The shape bends to the package; the split between browser, server and testing does not.
Configuration is vanilla
A package never reads process.env or import.meta.env, never knows a prefix such as VITE_ or NEXT_PUBLIC_, and never decides that a missing value disables a feature. It exposes a plain config type, a zod schema and a parser:
// packages/auth/src/config.ts
export const authConfigSchema = z.object({
publishableKey: z.string().startsWith('pk_'),
});
export function parseAuthConfig(input: unknown): AuthConfig {
return authConfigSchema.parse(input);
}The app reads its env with literal access, normalises it, maps it to the config and parses:
// apps/app/src/lib/env.ts
const appEnvSchema = z
.object({
VITE_CLERK_PUBLISHABLE_KEY: z.preprocess(blankAsUndefined, authConfigSchema.shape.publishableKey),
})
.transform((vars) => ({
auth: parseAuthConfig({ publishableKey: vars.VITE_CLERK_PUBLISHABLE_KEY }),
}));Treating the blank value of an .env.example as unset, naming the faulty variable in the error, and the optional-feature decision all belong to the app. Next.js only inlines process.env.NEXT_PUBLIC_X read literally, which is one more reason the mapping cannot live in a package. See Environments.
Tests at three levels
| Level | Where | What it gives |
|---|---|---|
| Port fake | @workspace/training/testing | An in-memory port for consumer tests: createFakeQueue, the ingested-events fake. No vendor loaded. |
| Package test helpers | @workspace/<package>/server/testing, /testing | Factories in the vendor's own shape and the pieces that exercise the real adapter: signClerkWebhookRequest, the in-memory Stripe simulator, the e2e user pool. |
| Live suite | Opt-in script, real sandbox account | Keeps the simulator honest: pnpm test:stripe-live in apps/api. |
The vendor-shaped factories stay in the package so that literals like "clerk" or a UserJSON never leak into the domain factories. They follow the factory convention: (faker, overrides).
A package with a React surface adds a fourth piece, a simulated implementation of the same surface, driven by the host: createSimulatedAuth loads the real store without the vendor, for component tests and the dev scenarios without a backend.
Dependencies
apps ──► packages/<capability> ──► training (ports)
│
└──► vendor SDK (only here)- The adapter depends on
trainingfor its ports and onsharedfor logger, time and errors. It never depends on another adapter. - The adapter never depends on a
ui-*package or on an app. When it ships components, they come from@workspace/uiprimitives. server-onlymarkers live at the app's Next.js consumer boundary, not in the package.
Changing vendor
Swapping the vendor means rewriting the inside of the package. The entry names, the port implementations' signatures, the config type and the test helpers' names stay, so consumers do not move. If a vendor-specific hook had to be exposed, such as the Clerk password hooks under @workspace/auth/clerk, it is the one entry whose consumers change.
Switching is not a change of import path in the apps. That was the earlier design, and it required a second package per vendor; the current one keeps a single package and lets the composition root be the only place that knows which implementation runs.
Architecture
Package naming and dependency rules, target graph, the training core layout, the player kit, the API wire, the apps, and the database and i18n strategies.
API contract
One source for the HTTP wire, from route tables to response schemas, and what the api, the clients and the OpenAPI document derive from it.