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

The Translator at the Frontier

The frontier between two contexts is the most expensive line of code in any DDD system. Cross it without translation and the upstream's types leak into the downstream's model; the downstream becomes a quiet conformist whether or not anyone agreed to that strategy. @ACL from @frenchexdev/ddd-acl reifies the translator that keeps the frontier honest — the foreign model stays foreign, and the local model stays local.


What @ACL Reifies

Evans's Anti-Corruption Layer (Domain-Driven Design, chapter 14) is one of the few patterns whose name names exactly what it does: a layer of code whose entire purpose is to refuse the corruption of the local model by the upstream model. If the upstream is a payment gateway returning Stripe.Charge, the ACL is the class that turns a Stripe.Charge into a local Payment value object — and refuses to let any other code in the downstream context import Stripe.Charge at all. Vernon (Implementing Domain-Driven Design, chapter 3) frames it as the practical answer to a conformist relationship the team has chosen to refuse: keep the integration, refuse the type leakage, pay the translation cost on every call.

Without an ACL, the upstream's vocabulary creeps in. A Charge.metadata['customer_id'] reads through, untyped, into a domain service. An Stripe.SubscriptionStatus enum becomes the de-facto status of the local subscription. Six months later, half the local domain is shaped by Stripe's data model and refactoring becomes impossible — the downstream conforms whether or not anyone wanted to. The point of declaring the ACL is to make the translation visible, lintable, and replaceable: the day Stripe is replaced by Adyen, only the ACL changes.

@ACL reifies the boundary as class metadata. Two required arguments — from (the upstream context name) and to (the downstream context name) — pin the translator's frontier. From that point on the analyzer can refuse ACL classes that do not name both ends, the codegen can scaffold a translator stub that throws on first call (a missing implementation cannot quietly default to null), and the registry can list every translator in the workspace so the team can see at a glance where its frontiers actually live.


The Runtime: ddd-acl

decorator.ts keeps the surface minimal. AclOptions requires only from: string and to: string — the two context names the translator sits between. The decorator wraps them in an AclMetadata with the usual discriminator (decoratorKind: 'ACL') and schema marker (version: 1), stamps __acl and __aclMetadata onto the class, and gets out of the way. There is no translate(...) interface in the runtime package itself — the shape of the translation is the responsibility of the concrete class. The codegen will scaffold a starting point, but the contract between the ACL and the downstream consumers is the consumers' to design.

In a downstream Subscription context that integrates with the upstream Payment context (in our invented architecture, Payment wraps Stripe), the ACL looks like this:

import { ACL } from '@frenchexdev/ddd-acl';
import type { Payment } from '../domain/payment.js';
import type { StripeChargePayload } from '@stripe/stripe-js'; // foreign type — confined to this file

@ACL({ from: 'Payment', to: 'Subscription' })
export class PaymentToSubscriptionACL {
  translate(charge: StripeChargePayload): Payment {
    return {
      id:        charge.id as Payment['id'],
      amount:    { value: charge.amount, currency: charge.currency.toUpperCase() as Payment['amount']['currency'] },
      capturedAt: new Date(charge.created * 1000),
    };
  }
}

The signature of translate is deliberately not part of the decorator. Some ACLs translate inbound (foreign → local), some translate outbound (local → foreign), some translate both. Some translate synchronously, some over a network call, some via a message bus. The decorator captures the metadata that says this class is the only place where foreign types are allowed in this direction; the body is left to the team.

isAclClass is the type guard the codegen and the workspace-wide tools use to find every ACL by walking exports. Same discipline as the other strategic decorators — no global registry, no side effects, the class is its own typed token.


The Analyzer: ddd-acl-analyzer

The spec at spec.ts declares pattern ACL under parent requirement AntiCorruptionLayerTranslationRequirement. Priority is Critical — an ACL that quietly omits a required argument is the kind of mistake that lets the upstream model creep into the downstream domain unnoticed.

Five acceptance criteria, five rules DDD-ACL-001 through DDD-ACL-005. The two require-decorator-arg invariants enforce from and to; a require-concrete-class invariant refuses abstract ACLs (a translator has to be runnable — an abstract translator translates nothing); the require-name-suffix invariant nudges classes toward the ACL or AntiCorruptionLayer suffix at info severity; and the now-familiar single-per-file rule keeps the frontier tidy.

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

export const aclAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'ACL',
  featureId: 'ACL-ANALYZER',
  priority: 'Critical',
  // ...
  rules: [
    { kind: 'require-decorator-arg',  code: 'DDD-ACL-001', severity: 'error', targetAC: 'declares-from-context',
      decoratorName: 'ACL', argName: 'from',
      message: '@ACL must declare from (upstream context)' },
    { kind: 'require-decorator-arg',  code: 'DDD-ACL-002', severity: 'error', targetAC: 'declares-to-context',
      decoratorName: 'ACL', argName: 'to',
      message: '@ACL must declare to (downstream context)' },
    { kind: 'require-concrete-class', code: 'DDD-ACL-003', severity: 'error', targetAC: 'is-concrete-class',
      decoratorName: 'ACL',
      message: 'ACL must be a concrete class (translator with executable logic)' },
    { kind: 'require-name-suffix',    code: 'DDD-ACL-004', severity: 'info',  targetAC: 'name-suffix',
      suffix: 'ACL',
      message: 'ACL type should end with "ACL" suffix by convention' },
    { kind: 'single-per-file',        code: 'DDD-ACL-005', severity: 'error', targetAC: 'single-acl-per-file',
      decoratorName: 'ACL',
      message: 'File declares {count} @ACL classes — split one ACL per file' },
  ],
});

A failing example trips DDD-ACL-003 — declare the decorator on an abstract class and the analyzer refuses, because an abstract translator means the import path stays open with no runnable shield behind it:

@ACL({ from: 'Payment', to: 'Subscription' })
export abstract class PaymentToSubscriptionACL {
  abstract translate(charge: unknown): unknown;
}

// DDD-ACL-003 [error] ACL must be a concrete class (translator with executable logic)
//   AC: ACL-ANALYZER/is-concrete-class

The Codegen: ddd-acl-codegen

The codegen spec at spec.ts declares two templates. The first is a translator stub — not a complete translator, but a concrete class with a translate(input) method that throws NotImplemented. The point is loud failure: an ACL that has been declared but not implemented is a strategic-DDD bug, and the stub makes that bug surface on the first call rather than producing a quiet wrong answer. The second is the workspace-wide ACL registry keyed by from/to.

templates/translator-stub.ts emits a tiny class named <AclClassName>Stub with an async translate(input: unknown): Promise<unknown> that throws NotImplemented. The intent is that the team copies the stub into the concrete ACL class on first use; the spec's translator-stub-throws-not-implemented AC pins the behaviour so a future template rewrite cannot accidentally swap the throw for a silent default.

// AUTO-GENERATED by ddd-acl-codegen:translator-stub — do not edit.
/**
 * Anti-Corruption Layer translator: Payment → Subscription.
 * Implement `translate` to convert foreign types from the upstream context
 * (Payment) into types of the downstream context (Subscription).
 */
export class PaymentToSubscriptionACLStub {
  async translate(input: unknown): Promise<unknown> {
    throw new Error('NotImplemented: PaymentToSubscriptionACLStub.translate (Payment → Subscription)');
  }
}

templates/acl-registry.ts emits the workspace-wide registry — every ACL keyed by its from/to pair, sorted alphabetically by from → to → className for deterministic output. A separate ACL_NAMES literal tuple exposes the closed set of class names as a type-level value, useful as the exhaustive type whenever a workspace operation needs to enumerate ACLs.

// AUTO-GENERATED by ddd-acl-codegen:acl-registry — do not edit.
import { PaymentToSubscriptionACL } from '../../acls/payment-to-subscription.js';
import { SubscriptionToNotificationACL } from '../../acls/subscription-to-notification.js';

export const ACL_REGISTRY = [
  { from: 'Payment',      to: 'Subscription',  acl: PaymentToSubscriptionACL },
  { from: 'Subscription', to: 'Notification',  acl: SubscriptionToNotificationACL },
] as const;

export const ACL_NAMES = ['PaymentToSubscriptionACL', 'SubscriptionToNotificationACL'] as const;

A workspace-wide CI check can then ask: for every conformist or customer-supplier @ContextRelationship, is there a matching @ACL? — pure data over generated registries, no AST walking required at audit time.


@ACL is the practical end-state of several strategic decisions and points everywhere.

  • It is usually implied by a @ContextRelationship of kind conformist, customer-supplier, or open-host-service where the downstream wants to refuse type leakage.
  • The from and to fields name @BoundedContext instances — a future analyzer pass will cross-validate that the named contexts exist.
  • It is the architectural opposite of a @SharedKernel: an ACL keeps two models separate by translation; a shared kernel merges them by co-ownership. The choice is strategic, not stylistic.
  • A @Subdomain classified as core should almost never depend on a generic upstream without an ACL — the investment tier and the ACL go together.

Back to the series index.

⬇ Download