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

Power Dynamics, Made Compilable

Two bounded contexts that touch are never neutral. One imposes its types, the other adapts; both negotiate; or both ignore each other and learn the difference on integration day. @ContextRelationship from @frenchexdev/ddd-context-relationship reifies that power dynamic so the architecture decision survives the meeting in which it was made.


What @ContextRelationship Reifies

Evans's context map (chapter 14 of Domain-Driven Design) is a small diagram with enormous consequences. It names, between every pair of contexts that touch, the kind of relationship — conformist, customer-supplier, partnership, published-language, open-host-service, separate-ways, big-ball-of-mud — and the direction of power: who decides the contract, who is forced to follow, who collaborates as an equal. The diagram lives, classically, on a wall or in a slide; six months later nobody remembers whether the Notification context was supposed to be a customer of Subscription or a conformist downstream of it, and the difference quietly shapes every refactor the team commits.

The vocabulary itself is non-negotiable. Vernon's Implementing Domain-Driven Design (chapter 3) explains why: each kind implies a different cost structure, a different test discipline, a different rate at which the contract may change. A conformist downstream accepts the upstream model verbatim and pays no translation cost but also has no leverage to influence it. A customer-supplier relationship has explicit negotiation. A partnership is a co-evolved contract that fails open. Separate-ways declares the cost of integration too high to bear and refuses to integrate at all — a strategic decision, not an oversight. Big-ball-of-mud is the honest label for the context whose mess you intend to firewall, not to refactor.

@ContextRelationship turns this living diagram into compilable metadata. Each relationship is a class file with four required arguments — kind, upstream, downstream, power — that name the strategic decision in terms the type system accepts. From that point on, every other tool in the corpus can ask what relationships does the Subscription context participate in?, which downstreams conform to it?, which upstreams does it follow? — and answer with sorted, typed data instead of with a wiki page.


The Runtime: ddd-context-relationship

decorator.ts declares the vocabulary explicitly. ContextRelationshipKind is a closed union of seven string literals — 'conformist' | 'open-host-service' | 'published-language' | 'customer-supplier' | 'partnership' | 'big-ball-of-mud' | 'separate-ways'. RelationshipPower is a closed union of three — 'upstream-wins' | 'downstream-wins' | 'collaborative'. There is no string escape hatch; a typo on either field is a compile error before any analyzer runs.

ContextRelationshipOptions requires all four fields. There is no optional argument. The decorator wraps the options in a ContextRelationshipMetadata (discriminator decoratorKind: 'ContextRelationship', schema marker version: 1) and stamps the typed payload onto the anchor class as __contextRelationship + __contextRelationshipMetadata. Same pattern as @Subdomain and @BoundedContext — no global registry, no runtime reflection, the class is its own typed token.

In an invented architecture where the Notification context conforms to whatever Subscription publishes, the call site reads:

import { ContextRelationship } from '@frenchexdev/ddd-context-relationship';

@ContextRelationship({
  kind: 'conformist',
  upstream: 'Subscription',
  downstream: 'Notification',
  power: 'upstream-wins',
})
export class SubscriptionNotificationConformist {}

The class itself is again a metadata anchor — empty body, single purpose, one decorator per file as the analyzer will enforce. The four arguments together describe the entire strategic relationship: the kind names the integration style, upstream/downstream name the two contexts by their @BoundedContext names (the analyzer will not validate that cross-reference yet, but the codegen consumes the names verbatim), and power names who breaks the tie when the contract evolves. A customer-supplier relationship with power: 'downstream-wins' is the explicit shape of the downstream is the customer and gets veto power; flipping the power to upstream-wins describes the opposite arrangement and is a different strategic choice that the type system now refuses to confuse.

isContextRelationshipClass is the type guard the codegen uses to discover relationship classes by walking exports. Same discipline as the other decorators: the class is the registry entry.


The Analyzer: ddd-context-relationship-analyzer

The spec at spec.ts declares pattern CONTEXTREL under parent requirement ContextRelationshipPowerRequirement and marks the feature priority: 'Critical'. Like @BoundedContext, the relationship is critical infrastructure — a mis-declared power dynamic is the kind of mistake that survives multiple refactors and quietly distorts every downstream architectural decision.

Five acceptance criteria and five matching rules DDD-CTXREL-001 through DDD-CTXREL-005. Four of them are require-decorator-arg invariants enforcing that each of kind, upstream, downstream, power is present. The fifth is the now-familiar single-per-file rule — one relationship per source file, so the context map stays navigable and so two relationships do not silently share a fixture.

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

export const contextRelationshipAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'CONTEXTREL',
  featureId: 'CONTEXT-RELATIONSHIP-ANALYZER',
  priority: 'Critical',
  // ...
  rules: [
    { kind: 'require-decorator-arg', code: 'DDD-CTXREL-001', severity: 'error', targetAC: 'declares-kind',
      decoratorName: 'ContextRelationship', argName: 'kind',
      message: '@ContextRelationship must declare kind' },
    { kind: 'require-decorator-arg', code: 'DDD-CTXREL-002', severity: 'error', targetAC: 'declares-upstream',
      decoratorName: 'ContextRelationship', argName: 'upstream',
      message: '@ContextRelationship must declare upstream' },
    { kind: 'require-decorator-arg', code: 'DDD-CTXREL-003', severity: 'error', targetAC: 'declares-downstream',
      decoratorName: 'ContextRelationship', argName: 'downstream',
      message: '@ContextRelationship must declare downstream' },
    { kind: 'require-decorator-arg', code: 'DDD-CTXREL-004', severity: 'error', targetAC: 'declares-power',
      decoratorName: 'ContextRelationship', argName: 'power',
      message: '@ContextRelationship must declare power dynamic' },
    { kind: 'single-per-file', code: 'DDD-CTXREL-005', severity: 'error', targetAC: 'single-rel-per-file',
      decoratorName: 'ContextRelationship',
      message: 'File declares {count} @ContextRelationship classes — split one per file' },
  ],
});

A failing example trips DDD-CTXREL-004: omit power and the analyzer refuses the build, because a relationship without a declared power dynamic is the worst kind of ambiguity — both readers will assume the convenient interpretation and disagree on every change.

@ContextRelationship({
  kind: 'partnership',
  upstream: 'Subscription',
  downstream: 'Notification',
  // power omitted
})
export class SubscriptionNotificationPartnership {}

// DDD-CTXREL-004 [error] @ContextRelationship must declare power dynamic
//   AC: CONTEXT-RELATIONSHIP-ANALYZER/declares-power

The Codegen: ddd-context-relationship-codegen

The codegen spec at spec.ts declares two templates. The first is a workspace-wide registry; the second is a per-relationship Mermaid fragment. Five acceptance criteria pin the contract, including the distinctive power-dicts-arrow-direction — the arrow direction in the Mermaid diagram is derived from the power field, so the rendered context map cannot disagree with the metadata.

templates/relationship-registry.ts consumes the descriptor list, sorts deterministically by upstream → downstream → className, and emits RELATIONSHIP_REGISTRY as an as const array of { kind, upstream, downstream, power, ctor } tuples. The deterministic sort matters: it lets downstream consumers (the BC CLI plugin, the compliance report, the docs site) hash or snapshot the file without churning over reordered inputs.

// AUTO-GENERATED by ddd-context-relationship-codegen:relationship-registry — do not edit.
import { SubscriptionNotificationConformist } from '../../relationships/sub-notif.js';
import { SubscriptionPaymentCustomerSupplier } from '../../relationships/sub-payment.js';

export const RELATIONSHIP_REGISTRY = [
  { kind: 'customer-supplier', upstream: 'Subscription', downstream: 'Payment',      power: 'downstream-wins', ctor: SubscriptionPaymentCustomerSupplier },
  { kind: 'conformist',        upstream: 'Subscription', downstream: 'Notification', power: 'upstream-wins',   ctor: SubscriptionNotificationConformist },
] as const;

templates/context-map.ts is the more visually interesting of the two. For each relationship, it emits a Mermaid flowchart fragment and a RELATIONSHIP_META literal. The arrow direction is dictated by power: upstream-wins becomes -->, downstream-wins becomes <--, collaborative becomes <-->. The arrow label combines the relationship kind and the power so the diagram reads on its own. A documentation pipeline then concatenates all the fragments into a single Mermaid flowchart that is the context map — generated, never hand-drawn, never out of sync with the decorators.

// AUTO-GENERATED by ddd-context-relationship-codegen:context-map — do not edit.
/**
 * Mermaid flowchart fragment for Subscription --> Notification.
 */
export const CONTEXT_MAP_FRAGMENT = `Subscription -->|conformist / upstream-wins| Notification`;

export const RELATIONSHIP_META = {
  kind: 'conformist',
  upstream: 'Subscription',
  downstream: 'Notification',
  power: 'upstream-wins',
} as const;

The power-dicts-arrow-direction acceptance criterion locks the mapping in the spec, so a hand edit that tries to use <-- for an upstream-wins relationship will fail the spec's property test before the change reaches review. The Mermaid output is the rendered shape of the truth that lives in the decorator, not a parallel artefact that needs to be kept in sync.


@ContextRelationship is the connective tissue of the strategic patterns and points everywhere.

  • The upstream and downstream fields are the names declared by @BoundedContext — a future analyzer pass will cross-validate that the named contexts exist.
  • A conformist or customer-supplier relationship usually requires an @ACL on the downstream side to keep foreign types out of the local model.
  • A partnership or published-language relationship may be served by a @SharedKernel where the cost of duplication is higher than the cost of co-ownership.
  • The strategic kind influences the @Subdomain investment tier — a separate-ways relationship between a core and a generic subdomain is a strategic statement, not an oversight.

Back to the series index.

⬇ Download