The Concrete Side of the Hexagon
If @Port declared the contract the domain refuses to live without, @Adapter from @frenchexdev/ddd-adapter is the concrete class that satisfies it on the infrastructure side. The two decorators are exact duals — every rule that says a port must not is mirrored by a rule that says an adapter must — and together they make Cockburn's hexagonal architecture compilable.
What @Adapter Reifies
Cockburn's Ports and Adapters never quite tells you that the adapter is the easier of the two. The port forces the design conversation: which methods does the domain need, what types do they accept and return, what is the timing model. Once the port exists, the adapter is mechanical — pick one implementation strategy (Postgres, an in-memory map, a Kafka producer, an HTTP client) and translate the port's signatures into calls against the strategy's library. The mechanical nature is exactly what makes adapters proliferate, and exactly what makes the analyzer's rules valuable: an adapter that drifts into asynchrony, or sneaks a framework type into its method signatures, or quietly forgets to implement one of the port's methods will compile until it does not.
@Adapter reifies the binding between a concrete implementation and the port it satisfies. Two required arguments: name (the identifier the DI container will use to bind this adapter) and implementsPort (the class name of the port). The decorator's metadata is what the codegen will read to emit DI bindings and the workspace-wide registry — application code never reaches for an adapter by class reference; it asks the container for the port by name, the container returns the adapter, and the indirection is what makes the implementation strategy replaceable.
The pattern's strength is its symmetry with @Port. A port forbids infrastructure imports; an adapter requires them. A port forbids concrete classes; an adapter requires one. A port's analyzer refuses framework types in method signatures; an adapter's analyzer refuses them too, because even on the infrastructure side a public method that returns FastifyReply is no longer an adapter, it is a thinly disguised HTTP handler. The duality is the architecture.
The Runtime: ddd-adapter
decorator.ts keeps the runtime surface to two fields. AdapterOptions requires name: string and implementsPort: string. The decorator wraps both in an AdapterMetadata with decoratorKind: 'Adapter' and version: 1, stamps __adapter and __adapterMetadata onto the anchor class, and exits. As with every adapter in this corpus, the decorator does not implement anything — the implementation is the class's own job.
In our invented architecture, a Postgres adapter for the CustomerRepositoryPort looks like this:
import { Adapter } from '@frenchexdev/ddd-adapter';
import type { CustomerRepositoryPort } from '../ports/customer-repository.port.js';
import type { Customer, CustomerId } from '../domain/customer.js';
import type { Pool } from 'pg';
@Adapter({
name: 'pg:CustomerRepository',
implementsPort: 'CustomerRepositoryPort',
})
export class PgCustomerRepositoryAdapter implements CustomerRepositoryPort {
constructor(private readonly pool: Pool) {}
async findById(id: CustomerId): Promise<Customer | null> {
const r = await this.pool.query('SELECT id, email, plan FROM customers WHERE id = $1', [id]);
return r.rowCount === 0 ? null : toDomainCustomer(r.rows[0]);
}
async save(customer: Customer): Promise<void> {
await this.pool.query(
'INSERT INTO customers (id, email, plan) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, plan = $3',
[customer.id, customer.email, customer.plan],
);
}
async delete(id: CustomerId): Promise<void> {
await this.pool.query('DELETE FROM customers WHERE id = $1', [id]);
}
}import { Adapter } from '@frenchexdev/ddd-adapter';
import type { CustomerRepositoryPort } from '../ports/customer-repository.port.js';
import type { Customer, CustomerId } from '../domain/customer.js';
import type { Pool } from 'pg';
@Adapter({
name: 'pg:CustomerRepository',
implementsPort: 'CustomerRepositoryPort',
})
export class PgCustomerRepositoryAdapter implements CustomerRepositoryPort {
constructor(private readonly pool: Pool) {}
async findById(id: CustomerId): Promise<Customer | null> {
const r = await this.pool.query('SELECT id, email, plan FROM customers WHERE id = $1', [id]);
return r.rowCount === 0 ? null : toDomainCustomer(r.rows[0]);
}
async save(customer: Customer): Promise<void> {
await this.pool.query(
'INSERT INTO customers (id, email, plan) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET email = $2, plan = $3',
[customer.id, customer.email, customer.plan],
);
}
async delete(id: CustomerId): Promise<void> {
await this.pool.query('DELETE FROM customers WHERE id = $1', [id]);
}
}The shape of that class is everything the analyzer is going to check. It is a concrete class (DDD-ADAPTER-003). It declares name and implementsPort (DDD-ADAPTER-001, DDD-ADAPTER-002). Its class name ends with Adapter (DDD-ADAPTER-004, info severity). It is alone in its file (DDD-ADAPTER-005). Every method returns Promise<T> (DDD-ADAPTER-006). No method exposes Request, Response, FastifyReply or other framework types (DDD-ADAPTER-007). The pg import is welcome here — it would have been forbidden in the port file, but the adapter is precisely where infrastructure dependencies belong.
isAdapterClass is the type guard used by the registry codegen and the DI wiring; same pattern as elsewhere — the class is its own typed token.
The Analyzer: ddd-adapter-analyzer
The spec at spec.ts declares pattern ADAPTER under parent requirement HexagonalAdapterConformanceRequirement with priority: 'Critical'. Seven acceptance criteria, seven rules DDD-ADAPTER-001 through DDD-ADAPTER-007. The spec's comment is explicit about the duality: Adapter invariants are the dual of Port's.
Three pairings are worth pointing out. DDD-PORT-002 says port must not be a concrete class; DDD-ADAPTER-003 says adapter must be a concrete class. DDD-PORT-006 says port must not expose framework types; DDD-ADAPTER-007 says adapter must not expose framework types — same forbiddenTypeNames list, same error code shape, opposite end of the boundary. DDD-PORT-004 says port methods should be async; DDD-ADAPTER-006 mirrors that. Read the two spec files side by side and the hexagonal contract is fully spelled out.
import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';
export const adapterAnalyzerSpec = defineAnalyzerSpec({
patternId: 'ADAPTER',
featureId: 'ADAPTER-ANALYZER',
priority: 'Critical',
// ...
rules: [
{ kind: 'require-decorator-arg', code: 'DDD-ADAPTER-001', severity: 'error',
decoratorName: 'Adapter', argName: 'implementsPort', targetAC: 'declares-implements-port',
message: '@Adapter must declare implementsPort (the Port class name)' },
{ kind: 'require-decorator-arg', code: 'DDD-ADAPTER-002', severity: 'error',
decoratorName: 'Adapter', argName: 'name', targetAC: 'declares-name',
message: '@Adapter must declare a name (used for DI registration)' },
{ kind: 'require-concrete-class', code: 'DDD-ADAPTER-003', severity: 'error',
decoratorName: 'Adapter', targetAC: 'is-concrete-class',
message: 'Adapter must be a concrete class, not an interface or abstract class' },
{ kind: 'require-name-suffix', code: 'DDD-ADAPTER-004', severity: 'info',
suffix: 'Adapter', targetAC: 'name-suffix',
message: 'Adapter type should end with "Adapter" suffix by convention' },
{ kind: 'single-per-file', code: 'DDD-ADAPTER-005', severity: 'error',
decoratorName: 'Adapter', targetAC: 'single-adapter-per-file',
message: 'File declares {count} @Adapter classes — split one adapter per file' },
{ kind: 'require-async-methods', code: 'DDD-ADAPTER-006', severity: 'warning',
targetAC: 'methods-async',
message: 'Adapter method {method} should return Promise<T> for boundary uniformity' },
{ kind: 'forbid-framework-types', code: 'DDD-ADAPTER-007', severity: 'error',
forbiddenTypeNames: ['Request', 'Response', 'NextFunction', 'FastifyRequest', 'FastifyReply', 'Context'],
targetAC: 'no-framework-types',
message: 'Adapter method exposes framework type {type} — accept a DTO instead' },
],
});import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';
export const adapterAnalyzerSpec = defineAnalyzerSpec({
patternId: 'ADAPTER',
featureId: 'ADAPTER-ANALYZER',
priority: 'Critical',
// ...
rules: [
{ kind: 'require-decorator-arg', code: 'DDD-ADAPTER-001', severity: 'error',
decoratorName: 'Adapter', argName: 'implementsPort', targetAC: 'declares-implements-port',
message: '@Adapter must declare implementsPort (the Port class name)' },
{ kind: 'require-decorator-arg', code: 'DDD-ADAPTER-002', severity: 'error',
decoratorName: 'Adapter', argName: 'name', targetAC: 'declares-name',
message: '@Adapter must declare a name (used for DI registration)' },
{ kind: 'require-concrete-class', code: 'DDD-ADAPTER-003', severity: 'error',
decoratorName: 'Adapter', targetAC: 'is-concrete-class',
message: 'Adapter must be a concrete class, not an interface or abstract class' },
{ kind: 'require-name-suffix', code: 'DDD-ADAPTER-004', severity: 'info',
suffix: 'Adapter', targetAC: 'name-suffix',
message: 'Adapter type should end with "Adapter" suffix by convention' },
{ kind: 'single-per-file', code: 'DDD-ADAPTER-005', severity: 'error',
decoratorName: 'Adapter', targetAC: 'single-adapter-per-file',
message: 'File declares {count} @Adapter classes — split one adapter per file' },
{ kind: 'require-async-methods', code: 'DDD-ADAPTER-006', severity: 'warning',
targetAC: 'methods-async',
message: 'Adapter method {method} should return Promise<T> for boundary uniformity' },
{ kind: 'forbid-framework-types', code: 'DDD-ADAPTER-007', severity: 'error',
forbiddenTypeNames: ['Request', 'Response', 'NextFunction', 'FastifyRequest', 'FastifyReply', 'Context'],
targetAC: 'no-framework-types',
message: 'Adapter method exposes framework type {type} — accept a DTO instead' },
],
});A failing example trips DDD-ADAPTER-007 — accept a FastifyRequest directly and the analyzer refuses, because the adapter has stopped being an adapter and started being a route handler:
@Adapter({ name: 'http:SubscribeApi', implementsPort: 'SubscribeApiPort' })
export class HttpSubscribeApiAdapter implements SubscribeApiPort {
async subscribe(req: FastifyRequest): Promise<void> { // <-- framework type in public method
/* ... */
}
}
// DDD-ADAPTER-007 [error] Adapter method exposes framework type FastifyRequest —
// accept a DTO instead
// AC: ADAPTER-ANALYZER/no-framework-types@Adapter({ name: 'http:SubscribeApi', implementsPort: 'SubscribeApiPort' })
export class HttpSubscribeApiAdapter implements SubscribeApiPort {
async subscribe(req: FastifyRequest): Promise<void> { // <-- framework type in public method
/* ... */
}
}
// DDD-ADAPTER-007 [error] Adapter method exposes framework type FastifyRequest —
// accept a DTO instead
// AC: ADAPTER-ANALYZER/no-framework-typesThe remedy is the small extra step that keeps the hexagonal pattern hexagonal: parse the request in a tiny edge layer, build a DTO, and call the adapter's port-shaped method with the DTO. The adapter stays clean. The router becomes the only place where framework types live.
The Codegen: ddd-adapter-codegen
The codegen spec at spec.ts declares two templates: a per-adapter DI binding and a workspace-wide registry. The codegen never emits an adapter implementation — that work belongs to the developer, and the port-side adapter-stub template (in ddd-port-codegen) already scaffolds it.
templates/di-binding.ts emits a container-agnostic binding. The file declares a MinimalDIContainer interface — any object with a bind<T>(token: string, ctor): void method — and a bindAdapter(container) function that registers the adapter against the port type's name. The minimal interface is the deliberate choice: the corpus does not pick InversifyJS or tsyringe or NestJS's container; any project's container conforms to the minimal shape and the binding compiles.
// AUTO-GENERATED by ddd-adapter-codegen:di-binding — do not edit.
import type { CustomerRepositoryPort } from '../../ports/customer-repository.port.js';
import { PgCustomerRepositoryAdapter } from '../../adapters/pg-customer-repository.adapter.js';
export interface MinimalDIContainer {
bind<T>(token: string, ctor: new (...args: unknown[]) => T): void;
}
export function bindAdapter(container: MinimalDIContainer): void {
container.bind<CustomerRepositoryPort>('CustomerRepositoryPort', PgCustomerRepositoryAdapter);
}
export const ADAPTER_ID = 'pg:CustomerRepository' as const;
export const ADAPTER_PORT = 'CustomerRepositoryPort' as const;// AUTO-GENERATED by ddd-adapter-codegen:di-binding — do not edit.
import type { CustomerRepositoryPort } from '../../ports/customer-repository.port.js';
import { PgCustomerRepositoryAdapter } from '../../adapters/pg-customer-repository.adapter.js';
export interface MinimalDIContainer {
bind<T>(token: string, ctor: new (...args: unknown[]) => T): void;
}
export function bindAdapter(container: MinimalDIContainer): void {
container.bind<CustomerRepositoryPort>('CustomerRepositoryPort', PgCustomerRepositoryAdapter);
}
export const ADAPTER_ID = 'pg:CustomerRepository' as const;
export const ADAPTER_PORT = 'CustomerRepositoryPort' as const;templates/registry.ts emits the workspace-wide registry. The shape is distinctive: a Map<string, new (...args) => unknown> keyed by port name, not an as const array. The map shape is what makes the registry useful at runtime — application bootstrap iterates the map and binds each entry; the same shape works for swapping adapters in tests by re-keying the same port. A separate ADAPTER_NAMES literal tuple plus a typed AdapterRegistryEntry union complete the type surface.
// AUTO-GENERATED by ddd-adapter-codegen:registry — do not edit.
import { HttpSubscribeApiAdapter } from '../../adapters/http-subscribe-api.adapter.js';
import { PgCustomerRepositoryAdapter } from '../../adapters/pg-customer-repository.adapter.js';
import { SmtpNotifierAdapter } from '../../adapters/smtp-notifier.adapter.js';
export const ADAPTER_REGISTRY = new Map<string, new (...args: unknown[]) => unknown>([
['SubscribeApiPort', HttpSubscribeApiAdapter],
['CustomerRepositoryPort', PgCustomerRepositoryAdapter],
['NotifierPort', SmtpNotifierAdapter],
]);
export const ADAPTER_NAMES = ['http:SubscribeApi', 'pg:CustomerRepository', 'smtp:Notifier'] as const;
export type AdapterRegistryEntry =
{ port: 'SubscribeApiPort'; adapter: typeof HttpSubscribeApiAdapter }
| { port: 'CustomerRepositoryPort'; adapter: typeof PgCustomerRepositoryAdapter }
| { port: 'NotifierPort'; adapter: typeof SmtpNotifierAdapter };// AUTO-GENERATED by ddd-adapter-codegen:registry — do not edit.
import { HttpSubscribeApiAdapter } from '../../adapters/http-subscribe-api.adapter.js';
import { PgCustomerRepositoryAdapter } from '../../adapters/pg-customer-repository.adapter.js';
import { SmtpNotifierAdapter } from '../../adapters/smtp-notifier.adapter.js';
export const ADAPTER_REGISTRY = new Map<string, new (...args: unknown[]) => unknown>([
['SubscribeApiPort', HttpSubscribeApiAdapter],
['CustomerRepositoryPort', PgCustomerRepositoryAdapter],
['NotifierPort', SmtpNotifierAdapter],
]);
export const ADAPTER_NAMES = ['http:SubscribeApi', 'pg:CustomerRepository', 'smtp:Notifier'] as const;
export type AdapterRegistryEntry =
{ port: 'SubscribeApiPort'; adapter: typeof HttpSubscribeApiAdapter }
| { port: 'CustomerRepositoryPort'; adapter: typeof PgCustomerRepositoryAdapter }
| { port: 'NotifierPort'; adapter: typeof SmtpNotifierAdapter };The acceptance criterion registry-is-order-independent pins the determinism, the di-binding-targets-port-type criterion pins the binding contract, and the spec's idempotence invariant lets a CI pipeline diff *.registry.generated.ts across builds to detect unintended adapter additions or removals without re-running the analyzer.
Cross-Links
@Adapter is the dual of @Port and the two patterns are inseparable.
- Every adapter implements one
@Port; the decorator names the port by class string, and the registry cross-references the workspace's known ports. - The conformance suite declared on the port (
conformanceSuite: 'CustomerRepositoryConformance') is the test every adapter under that port must run before shipping. - An adapter at a context boundary often pairs with an
@ACL: the ACL translates types, the adapter executes the call. - The DI binding feeds whatever container the workspace uses, including the application-service-level wiring in
@ApplicationServiceand@CommandHandler.
Back to the series index.