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

Minimal Co-Ownership, Explicit Participants

A shared kernel is the architectural exception that proves the rule. Bounded contexts normally do not share types; an @ACL translates at the frontier. But sometimes two contexts have a slice of model that is genuinely the same — a Money value object, a CountryCode enum, the contract for a RetryPolicy — and the cost of duplication outweighs the cost of co-ownership. @SharedKernel from @frenchexdev/ddd-shared-kernel makes that exception explicit, narrow, and lintable.


What @SharedKernel Reifies

Evans (Domain-Driven Design, chapter 14) introduces the shared kernel cautiously, almost reluctantly. The pattern says: some bounded contexts may share a small fragment of model, but the fragment is jointly owned, no party may change it unilaterally, and every change requires re-blessing by all participants. Vernon (Implementing Domain-Driven Design, chapter 3) is even more explicit about why this is the dangerous pattern — a shared kernel that grows is the seed of a future big-ball-of-mud, because every additional shared type expands the surface across which any change requires synchronised approval.

The pattern's discipline lives entirely in the word minimal. A shared kernel is justified only when the cost of duplicating a fragment of model exceeds the cost of co-owning it — and that is rarer than teams imagine. A Money value object with currency-conversion semantics may legitimately be shared; the concept of a Customer almost never is, because a Customer in billing means something different from a Customer in support and you want both contexts to model their own. The shared kernel pattern, applied well, is small; applied badly, it metastasises and re-creates the coupling that bounded contexts were invented to dissolve.

@SharedKernel reifies the minimum. The decorator carries two required arguments: a name and the list of participants (the names of the bounded contexts that co-own this kernel). That second argument is the discipline — every shared kernel must name the contexts on the hook for it, and the codegen will surface that list as a typed Participant alias every consumer can import. A kernel without explicit participants is a kernel that nobody owns, which is to say a kernel that everyone will silently extend.


The Runtime: ddd-shared-kernel

decorator.ts declares the surface in nine working lines. SharedKernelOptions requires a name: string and a participants: readonly string[]. The decorator wraps both into a SharedKernelMetadata with decoratorKind: 'SharedKernel' and version: 1, then stamps __sharedKernel + __sharedKernelMetadata onto the class. There is nothing else. The shape of what is shared — types, value objects, enums, interfaces — lives inside the decorated class's module, not in the decorator.

In an invented architecture where Subscription and Invoice share a Money value object plus a small Currency enum, the kernel module looks like this:

import { SharedKernel } from '@frenchexdev/ddd-shared-kernel';

@SharedKernel({
  name: 'BillingPrimitives',
  participants: ['Subscription', 'Invoice'],
})
export class BillingPrimitivesKernel {}

// The kernel's shared types live next to the anchor — in the same module —
// so the boundary of the shared surface is the boundary of the file.
export type Currency = 'EUR' | 'USD' | 'GBP';
export interface Money { readonly value: number; readonly currency: Currency; }

The anchor class BillingPrimitivesKernel is again a metadata-only token. The shared surface — Currency, Money — sits in the same module on purpose: anything that lives outside this file is not part of the kernel, and any change inside this file requires approval from both Subscription and Invoice teams. The file is the contract.

isSharedKernelClass is the type guard for walking exports and finding kernels without a hand-maintained registry.


The Analyzer: ddd-shared-kernel-analyzer

The spec at spec.ts declares pattern SHAREDKERNEL under parent requirement SharedKernelMinimalismRequirement. Priority is High — not Critical, because a missing @SharedKernel is rarely fatal in the way a missing @BoundedContext or a leaking @ACL is — but the minimalism in the requirement name is the strategic intent the analyzer protects.

Three rules DDD-SK-001 through DDD-SK-003: name required, participants required, single kernel per file. The single-per-file rule matters more here than elsewhere — every shared kernel has its own participant set, and the analyzer's "one per file" insistence forces the team to think which contexts are co-owning this exact set of types? every time, rather than piling several kernels onto one anchor and quietly merging their participant lists.

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

export const sharedKernelAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'SHAREDKERNEL',
  featureId: 'SHARED-KERNEL-ANALYZER',
  priority: 'High',
  // ...
  rules: [
    { kind: 'require-decorator-arg', code: 'DDD-SK-001', severity: 'error', targetAC: 'declares-name',
      decoratorName: 'SharedKernel', argName: 'name',
      message: '@SharedKernel must declare a name' },
    { kind: 'require-decorator-arg', code: 'DDD-SK-002', severity: 'error', targetAC: 'declares-participants',
      decoratorName: 'SharedKernel', argName: 'participants',
      message: '@SharedKernel must declare participants (BoundedContext names)' },
    { kind: 'single-per-file', code: 'DDD-SK-003', severity: 'error', targetAC: 'single-sk-per-file',
      decoratorName: 'SharedKernel',
      message: 'File declares {count} @SharedKernel classes — split one per file' },
  ],
});

A failing example trips DDD-SK-002 — declare a kernel without naming its participants and the analyzer refuses, because an anonymous kernel is the prelude to every team treating it as a free dumping ground:

@SharedKernel({ name: 'BillingPrimitives' })
//             participants omitted
export class BillingPrimitivesKernel {}

// DDD-SK-002 [error] @SharedKernel must declare participants (BoundedContext names)
//   AC: SHARED-KERNEL-ANALYZER/declares-participants

The Codegen: ddd-shared-kernel-codegen

The codegen spec at spec.ts declares two templates. The first is the workspace-wide registry; the second is a per-kernel participants doc that the dashboard side of the corpus consumes to draw the "who owns what" matrix.

templates/sk-registry.ts sorts kernels alphabetically by name and sorts participants within each entry, so reordering decorator arguments produces byte-identical output. The emitted SHARED_KERNEL_REGISTRY exposes each kernel's name, the constructor reference, and the participants tuple typed as const — every consumer that cares about the participants gets a closed-list union, not a string array.

// AUTO-GENERATED by ddd-shared-kernel-codegen:sk-registry — do not edit.
import { BillingPrimitivesKernel } from '../../kernels/billing-primitives.js';
import { RetryPolicyKernel } from '../../kernels/retry-policy.js';

export const SHARED_KERNEL_REGISTRY = [
  { name: 'BillingPrimitives', ctor: BillingPrimitivesKernel, participants: ['Invoice', 'Subscription'] as const },
  { name: 'RetryPolicy',       ctor: RetryPolicyKernel,       participants: ['Notification', 'Payment'] as const },
] as const;

templates/participants-doc.ts is the distinctive template of this triplet. Per kernel, it emits KERNEL_NAME, the sorted PARTICIPANTS tuple, and — crucially — a typed alias Participant = typeof PARTICIPANTS[number]. That alias is the practical end-state of explicit participants: any code that accepts a participant gets a closed union type rather than a string, and a typo on a participant name is a compile error.

// AUTO-GENERATED by ddd-shared-kernel-codegen:participants-doc — do not edit.
/**
 * Shared kernel BillingPrimitives — coordinated by 2 BoundedContext(s).
 */
export const KERNEL_NAME = 'BillingPrimitives' as const;
export const PARTICIPANTS = ['Invoice', 'Subscription'] as const;
export type Participant = typeof PARTICIPANTS[number];

A consumer can then write a function signature like function notifyParticipants(p: Participant, change: KernelChange): void and the compiler will refuse any participant name that is not in the literal tuple. If Invoice is removed from participants, the regenerated tuple shrinks, the union narrows, and every call site that still passes 'Invoice' fails to compile. The minimalism the pattern demands becomes a constraint the type system maintains.


@SharedKernel is the architectural exception, so its cross-links describe both what it replaces and what it implies.

  • It is the architectural opposite of an @ACL: an ACL keeps two models separate by translation, a shared kernel merges them by co-ownership. Choose one per context pair, not both.
  • It is most commonly justified by a partnership or published-language @ContextRelationship. A conformist relationship rarely warrants one — by definition only one side has the power to define the contract.
  • The participants field names @BoundedContext instances; a future cross-pattern analyzer pass will validate that every named participant exists.
  • A @Subdomain classification across the participants influences the discipline expected of the kernel — kernels across different subdomain types deserve heightened review.

Back to the series index.

⬇ Download