domain-toolkit
    Preparing search index...

    Class AggregateRoot<EntityState>Abstract

    An Aggregate Root is an Entity with a second job: it is the consistency boundary for a cluster of objects that must obey a rule together.

    Every Aggregate Root is an Entity. Not every Entity is an Aggregate Root. The extra responsibilities are exactly three:

    1. It is the only way in. Nothing outside the aggregate may hold a reference to a child entity, and nothing outside may call a method on one. All behaviour is invoked on the root.
    2. It guarantees its invariants. After any method returns, the rule spanning the cluster holds. This is only possible because rule (1) prevents anyone changing a child behind the root's back.
    3. It is the unit of persistence and of publication. Repositories load and save whole aggregates; domain events leave the model through the root.

    As small as its invariants allow. Two objects belong in the same aggregate only if there is a rule that must be true of both of them at every instant. If the rule can be true "shortly afterwards" — eventually — then they are two aggregates and an event connects them.

    In this codebase: BookStock contains its Copy entities because availableCount must match the copies at every instant. Member and Loan are separate aggregates, because "a member has at most N loans" is allowed to be repaired a moment later, by an event handler.

    Type Parameters

    Hierarchy (View Summary)

    Index
    abstractBase: boolean = true

    Declared for itself, not merely inherited — see Entity.abstractBase. The guard is Object.hasOwn, so without its own copy this class would walk straight past it.

    id: string
    • get hasPendingEvents(): boolean

      Whether anything inside this boundary is waiting to be published.

      Overridden so that it agrees with pullDomainEvents about depth: the inherited version reads only the root's own buffer, and would report false for a root whose pending events were all recorded by a child.

      Unlike pullDomainEvents, this peeks. It is safe to ask any number of times and answers the same thing each time.

      Returns boolean

    • The child entities living inside this boundary.

      The root must declare them, because it is the root's job to drain their recorded events. Forget one and its events are silently dropped.

      Defaults to none: plenty of aggregate roots are a single entity with no children at all, and that is not a design failure.

      Returns readonly Entity<State>[]

    • Route an event to the @Handle method that declares it, then record it.

      This is the event-sourced way to change state: the handler performs the mutation, and the event that caused it is kept for publication. State and history cannot drift apart, because one call produces both.

      Use Entity.record instead for an event that reports something without changing this aggregate's own state.

      Parameters

      Returns void

    • Route an event to its handler without recording it.

      The distinction from apply is the whole of rehydration: replaying history must rebuild state without re-publishing facts the world already knows. An aggregate loaded from twenty events and then saved would otherwise emit all twenty again.

      Parameters

      Returns void

    • Rebuild an aggregate from its event stream.

      const stock = BookStock.fromEvents(
      "dune",
      { title: "Dune", barcodes: [], copies: [] },
      await store.read("dune"),
      );

      initialState is the seed the stream is replayed onto. An event stream says what changed, never what the empty shape was, so a handler like this.set("barcodes", [...this.get("barcodes"), ...]) needs an array to append to before the first event arrives.

      It is a RequiredState for the same reason Entity.create's is, and the fit is if anything tighter here: the seed is by definition the shape before anything happened, and an optional property is by convention one that only happens later.

      Invariants are asserted once, when the stream has finished — never between events, which would refuse valid histories.

      Type Parameters

      Parameters

      Returns This["prototype"]

    • Assert every rule this aggregate is responsible for.

      Abstract on purpose: declaring an aggregate root is a claim that you are protecting something, and this method is where you say what. An implementation that is genuinely empty is a signal that the cluster may not need to be an aggregate at all.

      Called for you by Entity.mutate when an operation returns, and by fromEvents once a stream has finished replaying. You should not need to call it by hand.

      Returns void

    • Build an entity of the class this was called on.

      const stock = BookStock.create("dune", {
      title: "Dune",
      barcodes: [],
      copies: [],
      }); // : BookStock

      Creation takes the entity's required properties only. Supplying an optional one does not compile:

      BookStock.create(id, { title: "Dune", barcodes: [], copies: [] });          // ok
      BookStock.create(id, { title: "Dune", barcodes: [], copies: [],
      author: "Herbert" }); // TS2353

      That refusal is the point: an entity begins life holding exactly what it cannot exist without, and everything else arrives through behaviour that means something in the domain. Read RequiredState for the ?-means-deferred convention this depends on.

      Type Parameters

      • This extends { prototype: Entity<any> }

      Parameters

      Returns This["prototype"]

    • Read state, either whole or one property at a time.

      Reading is not a write channel: the value handed back is detached from the state, so mutating it reaches nothing. The copy is one level deep — see @domain-toolkit/state for that boundary and its one deliberate limit.

      Returns EntityState

    • Read state, either whole or one property at a time.

      Reading is not a write channel: the value handed back is detached from the state, so mutating it reaches nothing. The copy is one level deep — see @domain-toolkit/state for that boundary and its one deliberate limit.

      Type Parameters

      • K extends string | number | symbol

      Parameters

      • key: K

      Returns EntityState[K]

    • Open a mutation context for the duration of operation.

      Separate from mutate because rehydration needs the context without the invariant check: AggregateRoot.replay reaches set through its handlers, and a per-event assertion would reject valid histories.

      Type Parameters

      • T

      Parameters

      • operation: () => T

      Returns T

    • Refuse a state change made outside a domain operation.

      This is what makes mutate mandatory rather than a convention: a change made outside an operation is one nothing will check and nothing can roll back.

      Parameters

      • method: string

      Returns void

    • Run one domain operation atomically: either it completes with this object's invariants intact, or nothing about it happened.

      addCopy(barcode: string): void {
      this.mutate(() => this.apply(new CopyAdded(barcode)));
      }

      On the way out it calls assertInvariants. If that throws — or if the operation itself throws — the state is restored and every event the operation recorded is discarded, so a caller who catches the error is telling the truth when they assume nothing happened.

      One limit, pinned by a test: rollback covers this object's own state and event buffer, and does not rewind events recorded by child entities during the operation. Their buffers are #private to each Entity.

      Parameters

      • operation: () => void

      Returns void

    • Identity equality. Note what is not compared: none of the attributes. Two Copy instances loaded separately from the repository, one of them stale, are still the same copy.

      Identity is only meaningful between entities of the same kind: a Copy and a Member that happen to carry the same string do not refer to the same thing. "Same kind" is the same concrete class, not instanceof, so that the answer is symmetric — a.equals(b) and b.equals(a) always agree, which a subclass-tolerant check cannot promise.

      Parameters

      Returns boolean