Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Hexagonal at the Type Level

A port is the contract the domain refuses to live without. The domain says I will call something that does this, the infrastructure delivers an implementation, and on the day the implementation changes — a new database, a new payment provider, a new message broker — only the adapter is rewritten. @Port from @frenchexdev/ddd-port reifies that contract so the discipline survives the type system, the build, and the next refactor.


What @Port Reifies

Cockburn's Hexagonal Architecture (the Ports and Adapters paper, 2005) inverted a long habit of software design. Traditional layered architectures had the domain depend on the infrastructure — the order service reached down into the database driver, the price calculator imported the file system. Cockburn's flip: the domain declares the interfaces it needs, and the infrastructure implements them. Dependencies point inward. The domain remains pure; the infrastructure becomes replaceable.

The pattern has two faces. Inbound ports (also called driving ports) are the contracts external actors call — a CLI handler, an HTTP route, a message-bus subscriber will invoke an inbound port to ask the domain to do something. Outbound ports (also called driven ports) are the contracts the domain itself calls to reach the outside world — a repository, a notifier, a payment-gateway client. Both directions matter, both are typed, and the distinction is part of the architecture, not a footnote.

@Port reifies the contract with three arguments. name is the port's identifier. direction is the closed union 'inbound' | 'outbound' — a typo is a compile error, and the analyzer and codegen both treat the two directions differently. conformanceSuite is an optional reference to the name of a test class that every adapter must pass; declared, it makes the port's contract not just a TypeScript interface but a tested-against contract, and the analyzer can refuse adapters that do not run that suite.

The pattern's discipline is harsh on purpose. A port that imports the file system is no longer a port — it has reached down into infrastructure and broken the inversion. A port whose methods return Response or FastifyReply is no longer a port — it has leaked a framework type into the domain. The analyzer's eight invariants are designed to refuse those quiet drifts before they accumulate.


The Runtime: ddd-port

decorator.ts declares the typed surface. PortDirection is the closed union 'inbound' | 'outbound'. PortOptions requires name and direction, accepts an optional conformanceSuite. The decorator wraps the options in a PortMetadata with decoratorKind: 'Port' and version: 1, stamps __port and __portMetadata onto the anchor type, and exits.

The anchor is the part that takes some getting used to. A port is not a class. It is a TypeScript interface (most common) or an abstract class (sometimes useful for default behaviour). The @Port decorator targets the anchor type, which is the structural definition the codegen will read. Because TypeScript decorators apply to classes and not interfaces, the canonical shape in this corpus is an abstract class — an interface declared as an abstract class whose methods are abstract, so the decorator has a real symbol to attach metadata to:

import { Port } from '@frenchexdev/ddd-port';
import type { Customer, CustomerId } from '../domain/customer.js';

@Port({
  name: 'CustomerRepository',
  direction: 'outbound',
  conformanceSuite: 'CustomerRepositoryConformance',
})
export abstract class CustomerRepositoryPort {
  abstract findById(id: CustomerId): Promise<Customer | null>;
  abstract save(customer: Customer): Promise<void>;
  abstract delete(id: CustomerId): Promise<void>;
}

The signature decisions in that snippet are not stylistic. Every method returns Promise<T> — the analyzer's methods-async invariant will surface a warning on a synchronous method, because crossing the boundary in two different ways (sync for memory, async for network) inevitably produces leaky abstractions. The types in the signatures (Customer, CustomerId) are domain types — the analyzer's no-framework-types invariant would reject Promise<FastifyReply>. The module's imports include no pg, no fs/promises, no infrastructure/ — the analyzer's no-infrastructure-import invariant would catch that, and the catch is the whole point. The port is the place where infrastructure is held out.

The conformanceSuite: 'CustomerRepositoryConformance' string is the link to the contract every adapter must satisfy. Declaring it asks the codegen to emit a conformance-runner scaffold; declaring it without writing the suite is a DDD-PORT-008 warning. Declaring it with a non-empty string and no matching test file is what the next milestone of the analyzer will catch as a cross-file invariant.

isPortClass is the type guard used by the codegen and the workspace tooling to walk exports.


The Analyzer: ddd-port-analyzer

The spec at spec.ts declares pattern PORT under parent requirement HexagonalPortInterfaceRequirement with priority: 'Critical'. Eight acceptance criteria, eight rules DDD-PORT-001 through DDD-PORT-008 — the densest analyzer in the strategic set so far, because the hexagonal discipline has the most ways to silently fail.

Three of the eight rules are the conformance-pressure invariants worth dwelling on. DDD-PORT-003 (forbid-infrastructure-import) refuses any port file that imports modules matching infrastructure/, /infra/, fs/promises, pg, mysql, mongoose — the analyzer carries the forbidden module patterns in the rule, so the list is editable from the spec rather than hard-coded in an analyzer rewrite. DDD-PORT-006 (forbid-framework-types) refuses port methods that expose Request, Response, NextFunction, FastifyRequest, FastifyReply, Context — the leaks that would otherwise make the port a thinly disguised HTTP handler. DDD-PORT-004 (require-async-methods) raises a warning, not an error, on sync methods — the team may have a reason (a port that wraps a pure computation), but the warning makes the choice deliberate.

import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';

export const portAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'PORT',
  featureId: 'PORT-ANALYZER',
  priority: 'Critical',
  // ...
  rules: [
    { kind: 'require-decorator-arg',       code: 'DDD-PORT-001', severity: 'error',
      decoratorName: 'Port', argName: 'direction', targetAC: 'declares-direction',
      message: '@Port must declare a direction (inbound or outbound)' },
    { kind: 'forbid-concrete-class',       code: 'DDD-PORT-002', severity: 'error',
      decoratorName: 'Port', targetAC: 'is-interface-or-abstract',
      message: 'Port must be a TS interface or abstract class, not a concrete class' },
    { kind: 'forbid-infrastructure-import', code: 'DDD-PORT-003', severity: 'error',
      forbiddenModulePatterns: ['infrastructure/', '/infra/', 'fs/promises', 'pg', 'mysql', 'mongoose'],
      targetAC: 'no-infrastructure-import',
      message: 'Port file imports infrastructure module {module} — ports must be infrastructure-agnostic' },
    { kind: 'require-async-methods',       code: 'DDD-PORT-004', severity: 'warning',
      targetAC: 'methods-async',
      message: 'Port method {method} should return Promise<T> for boundary uniformity' },
    { kind: 'require-name-suffix',         code: 'DDD-PORT-005', severity: 'info',
      suffix: 'Port', targetAC: 'name-suffix',
      message: 'Port type should end with "Port" suffix by convention' },
    { kind: 'forbid-framework-types',      code: 'DDD-PORT-006', severity: 'error',
      forbiddenTypeNames: ['Request', 'Response', 'NextFunction', 'FastifyRequest', 'FastifyReply', 'Context'],
      targetAC: 'no-framework-types',
      message: 'Port method exposes framework type {type} — abstract via DTO instead' },
    { kind: 'single-per-file',             code: 'DDD-PORT-007', severity: 'error',
      decoratorName: 'Port', targetAC: 'single-port-per-file',
      message: 'File declares {count} @Port classes — split one port per file' },
    { kind: 'require-conformance-suite-file', code: 'DDD-PORT-008', severity: 'warning',
      decoratorName: 'Port', suiteOptionKey: 'conformanceSuite', targetAC: 'conformance-suite-link',
      message: 'conformanceSuite must be a non-empty string, got {value}' },
  ],
});

A failing example trips DDD-PORT-003 — import pg in a port file and the analyzer refuses, because the port has just reached down to the infrastructure it was supposed to abstract:

// ports/customer-repository.port.ts — INVALID
import type { PoolClient } from 'pg'; //  <-- infrastructure leak
import { Port } from '@frenchexdev/ddd-port';

@Port({ name: 'CustomerRepository', direction: 'outbound' })
export abstract class CustomerRepositoryPort {
  abstract findByConnection(c: PoolClient, id: string): Promise<Customer | null>;
}

// DDD-PORT-003 [error] Port file imports infrastructure module pg —
// ports must be infrastructure-agnostic
//   AC: PORT-ANALYZER/no-infrastructure-import

The Codegen: ddd-port-codegen

The codegen spec at spec.ts declares three templates. The adapter stub scaffolds a NotImplemented implementation, the registry splits ports by direction with typed unions, and the conformance template emits a runner scaffold for ports that declare a conformanceSuite.

templates/adapter-stub.ts takes a PortDescriptor (carrying className, fromModule, and the parsed methods: { name, params, returnType }[]) and emits a concrete class <NameWithoutPortSuffix>AdapterStub whose methods all throw NotImplemented. The pattern is the same as the ACL translator stub: a missing implementation must fail loudly on first call, not produce a quiet wrong answer.

// AUTO-GENERATED by ddd-port-codegen:adapter-stub — do not edit.
import type { CustomerRepositoryPort } from '../../ports/customer-repository.port.js';

export class CustomerRepositoryAdapterStub implements CustomerRepositoryPort {
  async findById(id: CustomerId): Promise<Customer | null> {
    throw new Error('NotImplemented: CustomerRepositoryAdapterStub.findById');
  }
  async save(customer: Customer): Promise<void> {
    throw new Error('NotImplemented: CustomerRepositoryAdapterStub.save');
  }
  async delete(id: CustomerId): Promise<void> {
    throw new Error('NotImplemented: CustomerRepositoryAdapterStub.delete');
  }
}

templates/registry.ts is the most type-rich of the three. It sorts ports by className, splits them by direction, emits INBOUND_PORTS and OUTBOUND_PORTS as as const literal tuples, and — most importantly — emits typed unions InboundPort and OutboundPort plus a PortRegistry type that is the closed shape of all known ports. Application code that needs to dispatch by direction gets a closed union to switch on, not a string array.

// AUTO-GENERATED by ddd-port-codegen:registry — do not edit.
import type { CustomerRepositoryPort } from '../../ports/customer-repository.port.js';
import type { NotifierPort } from '../../ports/notifier.port.js';
import type { SubscribeApiPort } from '../../ports/subscribe-api.port.js';

export const INBOUND_PORTS  = ['SubscribeApiPort'] as const;
export const OUTBOUND_PORTS = ['CustomerRepositoryPort', 'NotifierPort'] as const;

export type InboundPort   = SubscribeApiPort;
export type OutboundPort  = CustomerRepositoryPort | NotifierPort;
export type PortRegistry  = { inbound: InboundPort; outbound: OutboundPort };

templates/conformance.ts is the conditional template. For a port that declares no conformanceSuite, it returns null and the pipeline emits nothing; for a port that declares one, it emits a runConformance(adapter) runner that imports the port type, asserts the adapter is an object, and leaves placeholder comments where the method-by-method assertions should go.

// AUTO-GENERATED by ddd-port-codegen:conformance — do not edit.
import type { CustomerRepositoryPort } from '../../ports/customer-repository.port.js';

/**
 * Conformance suite CustomerRepositoryConformance: every adapter for
 * CustomerRepositoryPort must pass this runner. Fill in actual assertions
 * where each method is exercised — the scaffold deliberately leaves them
 * empty.
 */
export async function runConformance(adapter: CustomerRepositoryPort): Promise<void> {
  if (typeof adapter !== 'object' || adapter === null) {
    throw new Error('adapter must be an object');
  }
  // findById — exercise with adapter and inspect result shape
  // adapter.findById(...) : Promise<Customer | null>
  // save — exercise with adapter and inspect result shape
  // adapter.save(...) : Promise<void>
  // delete — exercise with adapter and inspect result shape
  // adapter.delete(...) : Promise<void>
}

The conformance scaffold is the part that completes the loop. The port declares a contract; the adapter implements it; the conformance runner is the test every adapter must pass before it ships. With the runner scaffolded, the only remaining work is to fill in the assertions — the structure, the imports, the failure modes for non-object adapters are already in place.


@Port is one half of the hexagonal pair; the other half is its @Adapter.

  • Every @Adapter names the @Port it implements; the analyzer will cross-validate that the adapter satisfies the port's interface and runs the conformance suite if one is declared.
  • The port often crosses the boundary defined by an @ACL: when an outbound port reaches a foreign system, the ACL is the translator that sits between them.
  • A port lives inside a @Module inside a @BoundedContext. The MODULE_REGISTRY's exports will, on a future pass, be cross-validated against the workspace's declared ports.
  • The conformance suite is itself a test class — the spec the @FeatureTest machinery turns into compliance signal.

Back to the series index.

⬇ Download