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> {
/* ... */
}
}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.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.
}
}// 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.
Cross-Links
@AggregateRoot is the centre of every tactical pattern.
- Built on
@Entity— the root is itself an entity, with a typedId<T>. - Composes
@ValueObjectfields and internal entities; the analyzer enforces the encapsulation. - Emits
@DomainEventinstances (the cross-patternDDD0132from domain-event-analyzer enforces that only roots emit events). - Loaded and saved by
@Repository; persisted to@EventStorewhen@EventSourcingis the discipline. - Its id is produced by
@IdentityStrategy.
Back to the series index.