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 Consistency Boundary

A bounded context contains many entities and value objects; only some are entry points to the outside world. @AggregateRoot from @frenchexdev/ddd-aggregate-root reifies Evans's chapter-6 primitive: the cluster of related entities that shares a consistency boundary plus the single object that mediates access. The root is the only thing the outside world may load, hold, mutate, or save; the internal entities live behind it.


What @AggregateRoot Reifies

Evans's aggregate is two things at once. A cluster of related entities and value objects that share an invariant — a Subscription aggregate may contain SubscriptionPlan, BillingCycle, RenewalPolicy, all required to remain mutually consistent. A transactional boundary: a single database transaction either commits the whole cluster's changes or none of them. There is no partial commit, no half-updated invariant.

The root is the unique entry point. The outside world — an application service, a command handler, another aggregate — never reaches into the cluster directly; it loads the root, asks the root to do something, and the root mediates any access to its internals. The discipline is harsh but pays back twice: invariants stay enforceable (because every mutation goes through the root), and the cluster's transactional shape is unambiguous (because the root is the only thing the repository persists).

@AggregateRoot ships two optional decorator fields. eventStream: string names the event store stream this aggregate's events are appended to — when set, the codegen and the analyzer wire the aggregate into the event-sourcing infrastructure. optimisticLocking: boolean toggles the requirement that the aggregate carry a version: number field — the optimistic-concurrency check that lets the repository refuse a stale save without taking row-level locks.


The Runtime: ddd-aggregate-root

decorator.ts declares the metadata. AggregateRootOptions accepts both fields as optional (the decorator can be @AggregateRoot() with no arguments). The metadata adds root: true as a marker — distinguishing the root from internal entities in the same cluster — plus the usual decoratorKind and version.

In our invented Subscription context:

import { AggregateRoot } from '@frenchexdev/ddd-aggregate-root';
import type { Entity } from '@frenchexdev/ddd-entity';
import { Guard, type Result, ok, err } from '@frenchexdev/ddd-core';
import type { SubscriptionId, CustomerId, BillingPlanId } from './types.js';
import type { Money } from '../value-objects/money.js';

@AggregateRoot({
  eventStream: 'Subscription',
  optimisticLocking: true,
})
export class Subscription extends SubscriptionBase {
  constructor(
    public readonly id:         SubscriptionId,
    public readonly customerId: CustomerId,
    public readonly version:    number,    // required by optimisticLocking: true
    private          plan:       BillingPlanId,
    private          status:     'pending' | 'active' | 'cancelled',
  ) {
    super();
    Guard.defined(plan, 'Subscription.plan');
  }

  cancel(reason: string): Result<SubscriptionCancelled, AlreadyCancelled> {
    if (this.status === 'cancelled') return err({ kind: 'AlreadyCancelled' });
    this.status = 'cancelled';
    const event = new SubscriptionCancelled(this.id, reason, new Date().toISOString());
    this.emitEvent(event);
    return ok(event);
  }

  startBilling(plan: BillingPlanId, initialCharge: Money): Result<BillingStarted, void> {
    /* ... */
  }
}

Three discipline-points show up at once. The aggregate's id, customerId, and version are readonly — identity continuity and the optimistic-concurrency anchor are immutable. The plan and status are private — the outside world cannot mutate them; only the aggregate's own methods can. Every method returns Result<Event, Failure> — failures are part of the type, not a thrown exception.

The aggregate inherits from SubscriptionBase — the codegen-emitted Base class that carries version and emitEvent. The leaf class supplies the domain methods; the Base supplies the structural plumbing.


The Analyzer: ddd-aggregate-root-analyzer

Hand-written. codes.ts exports three diagnostics in the DDD0NNN namespace.

DDD0120_AGGREGATE_NOT_ENCAPSULATED is the boundary invariant at error severity. An aggregate that exposes an internal @Entity as a public field has broken the cluster — the outside world can now reach into it without going through the root. The fix is to expose a derived value (a copy, a projection, or a method that mediates access).

DDD0125_AGGREGATE_REFERS_TO_NON_DOMAIN_TYPE is the type-shape warning. An aggregate field whose type is neither an @Entity nor a @ValueObject is a leak — a raw Date, a Promise, a third-party library type that should have been wrapped. Warning severity because there are edge cases (Date for non-serialised in-memory state, primitives in tightly-scoped private fields) but the recommendation is to wrap.

DDD0128_AGGREGATE_MISSING_VERSION_FIELD is the optimistic-locking enforcement at error severity. A decorator that says optimisticLocking: true without a matching version: number field on the class is a pinky-promise that the type system cannot deliver — the repository's optimistic check will reach for .version and find undefined.

aggregateNotEncapsulated('Subscription', 'aggregates/subscription.ts', 12);
// DDD0120 [error] @AggregateRoot "Subscription" exposes an internal Entity directly.
// The root must be the only entry point.

aggregateRefersToNonDomainType('Subscription', 'rawTimestamp', 'aggregates/subscription.ts', 18);
// DDD0125 [warning] @AggregateRoot "Subscription" references "rawTimestamp" which is
// neither an @Entity nor a @ValueObject. Wrap it or remove the field.

aggregateMissingVersionField('Subscription', 'aggregates/subscription.ts', 8);
// DDD0128 [error] @AggregateRoot "Subscription" declares optimisticLocking: true but
// no `version: number` field is present. Add a readonly version field or disable
// optimistic locking.

The three diagnostics will survive the spec-first migration; their codes are part of the contract.


The Codegen: ddd-aggregate-root-codegen

generator.ts emits a Base class per aggregate. The Base supplies two things: the readonly version: number = 0 field (when optimisticLocking: true) and an emitEvent hook the subclass overrides to dispatch through the event bus or outbox.

// AUTO-GENERATED by ddd-aggregate-root-codegen@0.0.1 — do not edit.
// Base class for aggregate root "Subscription". Boundary + optional optimistic locking.

export abstract class SubscriptionBase {
  /** Optimistic concurrency version. Bumped on every commit. */
  public readonly version: number = 0;

  protected emitEvent(_event: { readonly name: string }): void {
    // Subclasses dispatch through EventBus or Outbox.
  }
}

The Base is deliberately minimal — version field if locking is on, emitEvent slot otherwise empty. The discipline of what to do when an event is emitted (dispatch through @EventBus, write to @Outbox, append to @EventStore) lives in the aggregate's specific wiring, not in the Base; the Base just guarantees the slot exists with a typed signature so subclasses cannot rename it accidentally.

The codegen is also notable for what it does not emit. It does not generate a constructor — the constructor is the place where invariants are enforced by hand, with Guard calls and domain-specific validation, and the codegen cannot reasonably guess the shape. It does not generate the domain methods — those are the aggregate's reason to exist. It generates only the boundary, leaving the behaviour to the developer.


@AggregateRoot is the centre of every tactical pattern.

Back to the series index.

⬇ Download