@shirudo/ddd-kit 1.0.0-rc.5 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,9 +103,9 @@ The Aggregate Root is an Entity (the parent Entity of the aggregate) that repres
103
103
 
104
104
  The library provides:
105
105
 
106
- - **`IAggregateRoot<TId>`** - Marker interface for Aggregate Root Entities. The Aggregate Root is an Entity with identity (id) and version for optimistic concurrency control. It represents the aggregate externally and is the only object that can be loaded/saved through repositories.
106
+ - **`IAggregateRoot<TId, TEvent?>`** - Interface for Aggregate Root Entities. The Aggregate Root is an Entity with identity (id), version for optimistic concurrency control, and a `pendingEvents` list of domain events recorded but not yet flushed. Both aggregate flavours (state-stored and event-sourced) expose `pendingEvents` under the same name, so a generic Repository.save() can harvest them uniformly.
107
107
 
108
- - **`AggregateRoot<TState, TId, TEvent?>`** - Base class for creating Aggregate Root Entities without Event Sourcing. Implements `IAggregateRoot<TId>`. The optional `TEvent` parameter (defaults to `unknown`) enables type-safe domain events — only aggregates that specify it get compile-time event validation. Provides ID and version management, state management, domain event tracking, and snapshot support. Use this when you don't need Event Sourcing but still want aggregate patterns with versioning and state management.
108
+ - **`AggregateRoot<TState, TId, TEvent?>`** - Base class for creating Aggregate Root Entities without Event Sourcing. Implements `IAggregateRoot<TId, TEvent>`. The optional `TEvent` parameter (defaults to `never`) enables type-safe domain events — only aggregates that specify it can record events at all. Provides ID and version management, state management, pending-event tracking, and snapshot support.
109
109
 
110
110
  - **`EventSourcedAggregate<TState, TEvent, TId>`** - Base class for Event-Sourced Aggregate Roots. Extends `Entity` directly (not `AggregateRoot`) so that state changes can only happen through event handlers via `apply()`. Provides event tracking, event validation, history replay, and snapshot support.
111
111
 
@@ -301,7 +301,7 @@ class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
301
301
  }
302
302
  }
303
303
 
304
- // order.domainEvents is ReadonlyArray<OrderDomainEvent> — no cast needed
304
+ // order.pendingEvents is ReadonlyArray<OrderDomainEvent> — no cast needed
305
305
  // order.addDomainEvent({ type: "WrongEvent" }) → compile error
306
306
  ```
307
307
 
@@ -431,13 +431,10 @@ order.confirm();
431
431
  order.ship("TRACK-789");
432
432
 
433
433
  // Access pending events
434
- console.log(order.pendingEvents); // Array of events not yet persisted
435
-
436
- // Helper methods
437
- console.log(order.hasPendingEvents()); // true
438
- console.log(order.getEventCount()); // 3
439
- console.log(order.getLatestEvent()?.type); // "OrderShipped"
440
- console.log(order.version); // 3 (automatically bumped)
434
+ console.log(order.pendingEvents); // Array of events not yet persisted
435
+ console.log(order.pendingEvents.length); // 3
436
+ console.log(order.pendingEvents.at(-1)?.type); // "OrderShipped"
437
+ console.log(order.version); // 3 (automatically bumped)
441
438
  ```
442
439
 
443
440
  ### Aggregate Features: Snapshots and Configuration
@@ -675,7 +672,7 @@ const createOrderHandler: CommandHandler<CreateOrderCommand, string> = async (
675
672
 
676
673
  return {
677
674
  result: order.id,
678
- events: order.pendingEvents,
675
+ aggregates: [order],
679
676
  };
680
677
  }
681
678
  );
@@ -1139,9 +1136,9 @@ This package is written in TypeScript and provides full type definitions. All ty
1139
1136
  Key exports include:
1140
1137
  - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()` - Value Object utilities (`voWithValidation` is for the App-Service boundary; Domain construction goes through the `ValueObject` base class which throws via `validate()`)
1141
1138
  - `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
1142
- - `AggregateRoot<TState, TId, TEvent?>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId>`). Optional `TEvent` parameter enables type-safe domain events
1139
+ - `AggregateRoot<TState, TId, TEvent?>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId, TEvent>`). Optional `TEvent` parameter enables type-safe domain events
1143
1140
  - `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
1144
- - `AggregateConfig`, `EventSourcedAggregateConfig` - Configuration interfaces
1141
+ - `AggregateConfig` - Configuration interface for `AggregateRoot` (controls per-call `setState` version-bump behavior)
1145
1142
  - `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
1146
1143
  - `sameVersion()` - Optimistic concurrency check (same ID and version)
1147
1144
  - `Entity<TState, TId>` - Base class for entities with state and business logic
@@ -1286,7 +1283,7 @@ class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId>
1286
1283
 
1287
1284
  // 3. Save
1288
1285
  await this.repository.save(order);
1289
- await this.eventBus.publish(order.domainEvents);
1286
+ await this.eventBus.publish(order.pendingEvents);
1290
1287
 
1291
1288
  return ok(order.id);
1292
1289
  // 4. Aggregate is garbage collected when method returns
@@ -1432,7 +1429,7 @@ async function createOrderCommand(cmd: CreateOrderCommand) {
1432
1429
 
1433
1430
  return {
1434
1431
  result: order.id,
1435
- events: order.pendingEvents // Published atomically
1432
+ aggregates: [order], // withCommit harvests pendingEvents and dispatches
1436
1433
  };
1437
1434
  }); // Commits or rollbacks everything
1438
1435
  }
@@ -1493,7 +1490,7 @@ class OrderService {
1493
1490
  }
1494
1491
 
1495
1492
  await this.repository.save(order);
1496
- await this.eventBus.publish(order.domainEvents);
1493
+ await this.eventBus.publish(order.pendingEvents);
1497
1494
 
1498
1495
  return ok(order.id);
1499
1496
  // order is garbage collected here
package/dist/index.d.ts CHANGED
@@ -189,6 +189,13 @@ interface DomainEvent<T extends string, P = void> {
189
189
  */
190
190
  metadata?: EventMetadata;
191
191
  }
192
+ /**
193
+ * Upper-bound alias for "any `DomainEvent` shape". Use as a generic
194
+ * constraint when a type parameter should accept any concrete event
195
+ * union. The `unknown` payload is the upper bound — concrete unions
196
+ * still narrow via `Extract<Evt, { type: K }>` at the use-site.
197
+ */
198
+ type AnyDomainEvent = DomainEvent<string, unknown>;
192
199
  /**
193
200
  * Shared option bag for the `createDomainEvent*` factories.
194
201
  */
@@ -263,7 +270,7 @@ declare function createDomainEventWithMetadata<T extends string, P>(type: T, pay
263
270
  * );
264
271
  * ```
265
272
  */
266
- declare function copyMetadata(sourceEvent: DomainEvent<string, unknown>, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
273
+ declare function copyMetadata(sourceEvent: AnyDomainEvent, additionalMetadata?: Partial<EventMetadata>): EventMetadata;
267
274
  /**
268
275
  * Merges multiple metadata objects into one.
269
276
  * Later metadata objects override earlier ones for the same keys.
@@ -502,7 +509,7 @@ declare function sameEntity<TId extends Id<string>>(a: Identifiable<TId>, b: Ide
502
509
  * // item is { id: itemId1, productId: "prod-1", quantity: 2 }
503
510
  * ```
504
511
  */
505
- declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): T | undefined;
512
+ declare function findEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T | undefined;
506
513
  /**
507
514
  * Checks if an entity with the given ID exists in the collection.
508
515
  *
@@ -520,7 +527,7 @@ declare function findEntityById<TId extends Id<string>, T extends Identifiable<T
520
527
  * hasEntityId(items, itemId2); // false
521
528
  * ```
522
529
  */
523
- declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): boolean;
530
+ declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): boolean;
524
531
  /**
525
532
  * Removes an entity with the given ID from the collection.
526
533
  * Returns a new array without the entity.
@@ -540,7 +547,7 @@ declare function hasEntityId<TId extends Id<string>, T extends Identifiable<TId>
540
547
  * // updated is [{ id: itemId2, productId: "prod-2", quantity: 1 }]
541
548
  * ```
542
549
  */
543
- declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId): T[];
550
+ declare function removeEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId): T[];
544
551
  /**
545
552
  * Updates an entity with the given ID in the collection.
546
553
  * Returns a new array with the updated entity.
@@ -564,7 +571,7 @@ declare function removeEntityById<TId extends Id<string>, T extends Identifiable
564
571
  * // updated is [{ id: itemId1, productId: "prod-1", quantity: 3 }]
565
572
  * ```
566
573
  */
567
- declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId, updater: (entity: T) => T): T[];
574
+ declare function updateEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, updater: (entity: T) => T): T[];
568
575
  /**
569
576
  * Replaces an entity with the given ID in the collection.
570
577
  * Returns a new array with the replaced entity.
@@ -588,7 +595,7 @@ declare function updateEntityById<TId extends Id<string>, T extends Identifiable
588
595
  * });
589
596
  * ```
590
597
  */
591
- declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[], id: TId, replacement: T): T[];
598
+ declare function replaceEntityById<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>, id: TId, replacement: T): T[];
592
599
  /**
593
600
  * Extracts all IDs from a collection of entities.
594
601
  *
@@ -606,7 +613,7 @@ declare function replaceEntityById<TId extends Id<string>, T extends Identifiabl
606
613
  * // ids is [itemId1, itemId2]
607
614
  * ```
608
615
  */
609
- declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: T[]): TId[];
616
+ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(entities: ReadonlyArray<T>): TId[];
610
617
 
611
618
  /**
612
619
  * Marker interface for Aggregate Roots.
@@ -635,7 +642,7 @@ declare function entityIds<TId extends Id<string>, T extends Identifiable<TId>>(
635
642
  * }
636
643
  * ```
637
644
  */
638
- interface IAggregateRoot<TId extends Id<string>> {
645
+ interface IAggregateRoot<TId extends Id<string>, TEvent = never> {
639
646
  /**
640
647
  * Unique identifier of the aggregate root entity.
641
648
  */
@@ -646,11 +653,25 @@ interface IAggregateRoot<TId extends Id<string>> {
646
653
  * This version applies to the entire aggregate, including all child entities.
647
654
  */
648
655
  readonly version: Version;
656
+ /**
657
+ * Read-only list of domain events recorded on this aggregate that have
658
+ * not yet been flushed to the outbox / persistence layer. Both state-
659
+ * stored (`AggregateRoot`) and event-sourced (`EventSourcedAggregate`)
660
+ * aggregates expose them under the same name, so Repository.save() can
661
+ * harvest them uniformly without branching on the aggregate flavour.
662
+ */
663
+ readonly pendingEvents: ReadonlyArray<TEvent>;
664
+ /**
665
+ * Clears the pending-event list. Called by `markPersisted` after a
666
+ * successful write — the events have been handed off to the outbox
667
+ * / event store and are no longer the aggregate's responsibility.
668
+ */
669
+ clearPendingEvents(): void;
649
670
  /**
650
671
  * Post-save hook: a `Repository.save()` implementation calls this with
651
672
  * the persisted version after a successful write to push the new
652
- * version back into the aggregate and clear any recorded domain events
653
- * (they are now safely on the write side / in the outbox).
673
+ * version back into the aggregate and clear pendingEvents (they are
674
+ * now safely on the write side / in the outbox).
654
675
  *
655
676
  * Required by the interface so a Repository implementation can call it
656
677
  * via the published `IAggregateRoot` contract without taking the
@@ -665,17 +686,14 @@ interface IAggregateRoot<TId extends Id<string>> {
665
686
  */
666
687
  interface AggregateConfig {
667
688
  /**
668
- * Whether `setState()` should bump the version automatically.
689
+ * Whether `setState()` should bump the version automatically when the
690
+ * caller omits the per-call `bumpVersion` argument.
669
691
  *
670
- * Defaults to **`false`** for `AggregateRoot` because `setState()`
671
- * already takes an explicit `bumpVersion` argument per call, so adding
672
- * an "always bump" config on top would be redundant. Keep it `false`
673
- * unless you have a subclass that never passes `bumpVersion` and you
674
- * want every state change to advance the version anyway.
675
- *
676
- * (Contrast with `EventSourcedAggregate`, which defaults this to
677
- * `true` because every event-sourced state change is per definition a
678
- * versioned commit.)
692
+ * Defaults to **`false`** — `setState()` already takes an explicit
693
+ * `bumpVersion` argument per call, so the config is just the default
694
+ * the per-call argument falls back to. Set to `true` only if you have
695
+ * a subclass that never passes `bumpVersion` and you want every state
696
+ * change to advance the version anyway.
679
697
  */
680
698
  autoVersionBump?: boolean;
681
699
  }
@@ -701,7 +719,7 @@ interface AggregateConfig {
701
719
  *
702
720
  * @template TState - The type of the aggregate state (contains child entities and value objects)
703
721
  * @template TId - The type of the aggregate root identifier
704
- * @template TEvent - The type of domain events recorded by this aggregate (defaults to unknown)
722
+ * @template TEvent - The type of domain events recorded by this aggregate. Defaults to `never` — aggregates without a declared event type cannot emit events (emitting any event becomes a compile error). Supply a concrete event union to opt in.
705
723
  *
706
724
  * @example
707
725
  * ```typescript
@@ -717,28 +735,28 @@ interface AggregateConfig {
717
735
  * }
718
736
  * ```
719
737
  */
720
- declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId> {
738
+ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
721
739
  private _version;
722
740
  get version(): Version;
723
741
  protected setVersion(version: Version): void;
724
742
  private readonly _config;
725
743
  private readonly _autoVersionBump;
726
- private _domainEvents;
744
+ private _pendingEvents;
727
745
  /**
728
- * Returns a read-only list of domain events recorded by this aggregate.
729
- * These events are side-effects of state changes.
746
+ * Read-only list of domain events recorded on this aggregate that have
747
+ * not yet been flushed to the outbox / persistence layer.
730
748
  */
731
- get domainEvents(): ReadonlyArray<TEvent>;
749
+ get pendingEvents(): ReadonlyArray<TEvent>;
732
750
  /**
733
- * Clears the list of recorded domain events.
734
- * Call this after dispatching the events.
751
+ * Clears the pending-event list. Call this after the events have been
752
+ * dispatched (typically `markPersisted` handles it for you).
735
753
  */
736
- clearDomainEvents(): void;
754
+ clearPendingEvents(): void;
737
755
  /**
738
756
  * Post-save hook called by a `Repository.save()` implementation to push
739
- * the persisted version back into the in-memory aggregate and clear the
740
- * recorded domain events (they are now safely on the write side / in
741
- * the outbox).
757
+ * the persisted version back into the in-memory aggregate and clear
758
+ * pendingEvents (they are now safely on the write side / in the
759
+ * outbox).
742
760
  *
743
761
  * Use this so `save()` can keep its `Promise<void>` return type: the
744
762
  * caller holds the aggregate reference, which is up to date after this
@@ -972,11 +990,7 @@ declare class ConcurrencyConflictError extends InfrastructureError<"ConcurrencyC
972
990
  * @template TId - The type of the aggregate root identifier
973
991
  * @template TEvent - The union type of all domain events
974
992
  */
975
- interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends DomainEvent<string, unknown>> extends IAggregateRoot<TId> {
976
- /**
977
- * Returns a read-only list of new, not-yet-persisted events.
978
- */
979
- readonly pendingEvents: ReadonlyArray<TEvent>;
993
+ interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends AnyDomainEvent> extends IAggregateRoot<TId, TEvent> {
980
994
  /**
981
995
  * Reconstitutes the aggregate from an event history. Returns `Result`
982
996
  * because event-stream corruption is an expected recoverable failure
@@ -984,45 +998,9 @@ interface IEventSourcedAggregate<TId extends Id<string>, TEvent extends DomainEv
984
998
  *
985
999
  * @param history - An ordered list of past events
986
1000
  */
987
- loadFromHistory(history: TEvent[]): Result<void, DomainError>;
988
- /**
989
- * Clears the list of pending events.
990
- */
991
- clearPendingEvents(): void;
992
- /**
993
- * Checks if the aggregate has any pending events.
994
- */
995
- hasPendingEvents(): boolean;
996
- /**
997
- * Returns the number of pending events.
998
- */
999
- getEventCount(): number;
1000
- /**
1001
- * Returns the latest pending event, if any.
1002
- */
1003
- getLatestEvent(): TEvent | undefined;
1004
- }
1005
- type Handler<TState, TEvent> = (state: TState, event: TEvent) => TState;
1006
- /**
1007
- * Configuration options for EventSourcedAggregate behavior.
1008
- */
1009
- interface EventSourcedAggregateConfig {
1010
- /**
1011
- * Whether `apply()` should bump the version per event.
1012
- *
1013
- * Defaults to **`true`** for `EventSourcedAggregate` — each applied
1014
- * event is by definition a versioned state change, so the canonical
1015
- * event-sourcing pattern is "one event = one version bump". Set to
1016
- * `false` only if your event store assigns version numbers itself
1017
- * and you want the aggregate to track them via `bumpVersion()` /
1018
- * `setVersion()` calls instead.
1019
- *
1020
- * (Contrast with `AggregateRoot`, which defaults this to `false`
1021
- * because its `setState()` already takes a per-call `bumpVersion`
1022
- * argument.)
1023
- */
1024
- autoVersionBump?: boolean;
1001
+ loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
1025
1002
  }
1003
+ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEvent) => TState;
1026
1004
  /**
1027
1005
  * Base class for Event-Sourced Aggregate Roots (Vernon, IDDD Chapter 8).
1028
1006
  *
@@ -1072,12 +1050,11 @@ interface EventSourcedAggregateConfig {
1072
1050
  * }
1073
1051
  * ```
1074
1052
  */
1075
- declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<string, unknown>, TId extends Id<string>> extends Entity<TState, TId> implements IEventSourcedAggregate<TId, TEvent> {
1053
+ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>> extends Entity<TState, TId> implements IEventSourcedAggregate<TId, TEvent> {
1076
1054
  private _version;
1077
1055
  get version(): Version;
1078
1056
  private setVersion;
1079
1057
  private _pendingEvents;
1080
- private readonly _autoVersionBump;
1081
1058
  get pendingEvents(): ReadonlyArray<TEvent>;
1082
1059
  clearPendingEvents(): void;
1083
1060
  /**
@@ -1087,7 +1064,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1087
1064
  * `save()` keep its `Promise<void>` return type.
1088
1065
  */
1089
1066
  markPersisted(version: Version): void;
1090
- protected constructor(id: TId, initialState: TState, config?: EventSourcedAggregateConfig);
1067
+ protected constructor(id: TId, initialState: TState);
1091
1068
  /**
1092
1069
  * Validates an event before it is applied. Default is no-op.
1093
1070
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1123,11 +1100,6 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1123
1100
  * resolved via the (statically-sound) `handlers` map.
1124
1101
  */
1125
1102
  private dispatchAndCommit;
1126
- /**
1127
- * Manually bumps the aggregate version.
1128
- * Only needed if `autoVersionBump` is disabled.
1129
- */
1130
- protected bumpVersion(): void;
1131
1103
  /**
1132
1104
  * Reconstitutes the aggregate from an event history. Catches `DomainError`
1133
1105
  * thrown during replay and returns it as an `Err` — this is the
@@ -1139,10 +1111,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1139
1111
  * an aggregate already at v=1 (e.g. after a creation event) loading
1140
1112
  * 2 events ends at v=3, not v=2.
1141
1113
  */
1142
- loadFromHistory(history: TEvent[]): Result<void, DomainError>;
1143
- hasPendingEvents(): boolean;
1144
- getEventCount(): number;
1145
- getLatestEvent(): TEvent | undefined;
1114
+ loadFromHistory(history: ReadonlyArray<TEvent>): Result<void, DomainError>;
1146
1115
  /**
1147
1116
  * Creates a snapshot of the current aggregate state.
1148
1117
  */
@@ -1157,7 +1126,7 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends DomainEvent<
1157
1126
  * aggregate is rolled back to its pre-call state + version. Partial
1158
1127
  * restoration is never observable to the caller.
1159
1128
  */
1160
- restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TState>, eventsAfterSnapshot: TEvent[]): Result<void, DomainError>;
1129
+ restoreFromSnapshotWithEvents(snapshot: AggregateSnapshot<TState>, eventsAfterSnapshot: ReadonlyArray<TEvent>): Result<void, DomainError>;
1161
1130
  /**
1162
1131
  * A map of event types to their corresponding handlers.
1163
1132
  * Subclasses MUST implement this property.
@@ -1456,9 +1425,7 @@ type EventHandler<Evt> = (event: Evt) => Promise<void> | void;
1456
1425
  * await bus.publish([orderCreatedEvent, orderShippedEvent]);
1457
1426
  * ```
1458
1427
  */
1459
- interface EventBus<Evt extends {
1460
- type: string;
1461
- }> {
1428
+ interface EventBus<Evt extends AnyDomainEvent> {
1462
1429
  /**
1463
1430
  * Publishes events to all subscribed handlers.
1464
1431
  *
@@ -1551,7 +1518,7 @@ interface OnceOptions {
1551
1518
  * own `eventId`, generate its own UUID, use the row's auto-increment
1552
1519
  * primary key, or whatever the storage layer prefers.
1553
1520
  */
1554
- interface OutboxRecord<Evt> {
1521
+ interface OutboxRecord<Evt extends AnyDomainEvent> {
1555
1522
  dispatchId: string;
1556
1523
  event: Evt;
1557
1524
  }
@@ -1571,7 +1538,7 @@ interface OutboxRecord<Evt> {
1571
1538
  * that's already marked is a no-op, not an error. This lets the
1572
1539
  * dispatcher safely retry on partial-failure.
1573
1540
  */
1574
- interface Outbox<Evt> {
1541
+ interface Outbox<Evt extends AnyDomainEvent> {
1575
1542
  /**
1576
1543
  * Persists events. Called from inside `withCommit`'s transactional
1577
1544
  * callback, atomically with the aggregate write.
@@ -1607,11 +1574,15 @@ interface Outbox<Evt> {
1607
1574
  * and rolls back if it throws.
1608
1575
  *
1609
1576
  * `TCtx` is the persistence layer's transaction handle — Drizzle's `tx`,
1610
- * Prisma's `tx`, Mongo's session, or `unknown` for the no-context path.
1611
- * The scope opens the transaction and passes the handle to `fn`; the
1612
- * use case binds its repositories to that handle (typically by
1613
- * constructing a tx-scoped repo from the ctx). Default `TCtx = unknown`
1614
- * keeps the no-context callers compiling.
1577
+ * Prisma's `tx`, Mongo's session, etc. The scope opens the transaction
1578
+ * and passes the handle to `fn`; the use case binds its repositories to
1579
+ * that handle (typically by constructing a tx-scoped repo from the ctx).
1580
+ *
1581
+ * No default for `TCtx`: every implementor names their context type
1582
+ * explicitly. For genuinely context-free scopes (in-memory tests, naive
1583
+ * no-tx scopes) use `TransactionScope<undefined>` — that's a conscious
1584
+ * "there is nothing meaningful here" statement, not an accidental
1585
+ * `unknown` fallback.
1615
1586
  *
1616
1587
  * Intentionally **not** Fowler's full Unit of Work (no change tracking,
1617
1588
  * no `registerDirty` / `registerNew` / `registerDeleted`, no commit-time
@@ -1631,11 +1602,11 @@ interface Outbox<Evt> {
1631
1602
  * ```typescript
1632
1603
  * await scope.transactional(async (tx) => {
1633
1604
  * // Construct tx-bound repos from ctx (your factory / DI of choice)
1634
- * const orders = makeOrderRepo(tx);
1605
+ * const orderRepository = makeOrderRepository(tx);
1635
1606
  *
1636
- * const order = await orders.getByIdOrFail(orderId);
1607
+ * const order = await orderRepository.getByIdOrFail(orderId);
1637
1608
  * order.confirm();
1638
- * await orders.save(order);
1609
+ * await orderRepository.save(order);
1639
1610
  * });
1640
1611
  * ```
1641
1612
  *
@@ -1645,60 +1616,62 @@ interface Outbox<Evt> {
1645
1616
  * (constructor injection, factory functions, `withTx` chains); pick one
1646
1617
  * and keep it consistent.
1647
1618
  */
1648
- interface TransactionScope<TCtx = unknown> {
1619
+ interface TransactionScope<TCtx> {
1649
1620
  transactional<T>(fn: (ctx: TCtx) => Promise<T>): Promise<T>;
1650
1621
  }
1651
1622
 
1652
1623
  /**
1653
1624
  * Helper for executing a write Use Case inside a transaction scope.
1654
1625
  *
1626
+ * The use-case callback returns the aggregates it touched; `withCommit`
1627
+ * owns the post-save lifecycle (harvest, outbox, mark-persisted, publish).
1628
+ * This matches the Vernon / Axon / EventFlow unit-of-work pattern:
1629
+ * `Repository.save` is pure persistence; "this aggregate has been
1630
+ * committed" is the orchestrator's call to make, not the repo's.
1631
+ *
1655
1632
  * Order of operations:
1656
1633
  * 1. `fn(ctx)` runs inside `scope.transactional(...)` — domain mutations
1657
1634
  * + repo writes happen here. `ctx` is whatever transaction handle the
1658
1635
  * `scope` exposes (Drizzle `tx`, Prisma `tx`, Mongo session, or
1659
- * `unknown` for the no-context path).
1660
- * 2. `outbox.add(events)` is also inside the transaction (skipped when
1661
- * the use case emits no events), so events persist atomically with
1662
- * the state change.
1636
+ * `undefined` for context-free scopes).
1637
+ * 2. **Still inside the transaction**, `withCommit` harvests every
1638
+ * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1639
+ * events persist atomically with the state change). Skipped when no
1640
+ * events were recorded.
1663
1641
  * 3. The transaction commits.
1664
- * 4. **After** the commit, `bus.publish(events)` fires for the
1665
- * in-process fast path (also skipped when the event list is empty).
1642
+ * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1643
+ * fires on each returned aggregate only now are pending events
1644
+ * considered flushed.
1645
+ * 5. `bus.publish(events)` fires for the in-process fast path (skipped
1646
+ * when no events or no `bus` is wired).
1666
1647
  *
1667
1648
  * Publishing AFTER commit prevents the classic "publish before commit"
1668
1649
  * footgun: in-process subscribers can never react to events from a
1669
- * transaction that later rolled back. If `bus.publish` itself fails, the
1650
+ * transaction that later rolled back. If `bus.publish` itself throws, the
1670
1651
  * outbox still holds the events and an outbox-dispatcher will deliver
1671
1652
  * them (eventual consistency).
1672
1653
  *
1673
- * @example No-context (tests / single-store flows)
1674
- * ```typescript
1675
- * const result = await withCommit({ outbox, bus, scope }, async () => {
1676
- * order.confirm();
1677
- * await orderRepo.save(order);
1678
- * return { result: order.id, events: order.domainEvents };
1679
- * });
1680
- * ```
1654
+ * If the transaction rolls back, `markPersisted` is **not** called — the
1655
+ * aggregate keeps its pending events, so the caller can retry or discard.
1681
1656
  *
1682
1657
  * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1683
1658
  * ```typescript
1684
1659
  * const result = await withCommit({ outbox, bus, scope }, async (tx) => {
1685
- * const orders = makeOrderRepo(tx); // your factory binds tx to the repo
1686
- * const order = await orders.getByIdOrFail(orderId);
1660
+ * const orderRepository = makeOrderRepository(tx); // your factory binds tx to the repo
1661
+ * const order = await orderRepository.getByIdOrFail(orderId);
1687
1662
  * order.confirm();
1688
- * await orders.save(order);
1689
- * return { result: order.id, events: order.domainEvents };
1663
+ * await orderRepository.save(order); // pure persistence — does NOT call markPersisted
1664
+ * return { result: order.id, aggregates: [order] };
1690
1665
  * });
1691
1666
  * ```
1692
1667
  */
1693
- declare function withCommit<Evt extends {
1694
- type: string;
1695
- }, R, TCtx = unknown>(deps: {
1668
+ declare function withCommit<Evt extends AnyDomainEvent, R, TCtx>(deps: {
1696
1669
  outbox: Outbox<Evt>;
1697
1670
  bus?: EventBus<Evt>;
1698
1671
  scope: TransactionScope<TCtx>;
1699
1672
  }, fn: (ctx: TCtx) => Promise<{
1700
1673
  result: R;
1701
- events: ReadonlyArray<Evt>;
1674
+ aggregates: ReadonlyArray<IAggregateRoot<Id<string>, Evt>>;
1702
1675
  }>): Promise<R>;
1703
1676
 
1704
1677
  /**
@@ -1933,7 +1906,7 @@ declare class QueryBus<TMap extends QueryTypeMap = QueryTypeMap> implements IQue
1933
1906
  * // Both handlers will be called
1934
1907
  * ```
1935
1908
  */
1936
- declare class EventBusImpl<Evt extends DomainEvent<string, unknown>> implements EventBus<Evt> {
1909
+ declare class EventBusImpl<Evt extends AnyDomainEvent> implements EventBus<Evt> {
1937
1910
  private readonly handlers;
1938
1911
  subscribe<K extends Evt["type"]>(eventType: K, handler: EventHandler<Extract<Evt, {
1939
1912
  type: K;
@@ -1952,6 +1925,44 @@ declare class EventBusImpl<Evt extends DomainEvent<string, unknown>> implements
1952
1925
  publish(events: ReadonlyArray<Evt>): Promise<void>;
1953
1926
  }
1954
1927
 
1928
+ /**
1929
+ * In-memory reference implementation of `Outbox<Evt>`.
1930
+ *
1931
+ * Intended for tests, single-process workers, and quick-start demos.
1932
+ * Uses the event's own `eventId` as the dispatch id — the common, clean
1933
+ * choice. Storage is a `Map<string, OutboxRecord<Evt>>` keyed by
1934
+ * `eventId`, so re-adding the same event is naturally idempotent (the
1935
+ * duplicate entry overwrites itself; `getPending` returns each event at
1936
+ * most once).
1937
+ *
1938
+ * For production, back the outbox with a transactional store so the
1939
+ * outbox row participates in the same transaction as the aggregate
1940
+ * write (see `TransactionScope` + `withCommit`). This class lives in
1941
+ * memory only — events are lost on process restart.
1942
+ *
1943
+ * @example
1944
+ * ```ts
1945
+ * import { InMemoryOutbox, EventBusImpl, withCommit } from "@shirudo/ddd-kit";
1946
+ *
1947
+ * const outbox = new InMemoryOutbox<OrderEvent>();
1948
+ * const bus = new EventBusImpl<OrderEvent>();
1949
+ *
1950
+ * await withCommit({ scope, outbox, bus }, async (tx) => {
1951
+ * const orderRepository = makeOrderRepository(tx);
1952
+ * const order = await orderRepository.getByIdOrFail(id);
1953
+ * order.confirm();
1954
+ * await orderRepository.save(order);
1955
+ * return { result: order.id, aggregates: [order] };
1956
+ * });
1957
+ * ```
1958
+ */
1959
+ declare class InMemoryOutbox<Evt extends AnyDomainEvent> implements Outbox<Evt> {
1960
+ private readonly pending;
1961
+ add(events: ReadonlyArray<Evt>): Promise<void>;
1962
+ getPending(limit?: number): Promise<ReadonlyArray<OutboxRecord<Evt>>>;
1963
+ markDispatched(dispatchIds: ReadonlyArray<string>): Promise<void>;
1964
+ }
1965
+
1955
1966
  /**
1956
1967
  * Core repository contract for Aggregate Roots.
1957
1968
  *
@@ -1988,17 +1999,25 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
1988
1999
  */
1989
2000
  exists(id: TId): Promise<boolean>;
1990
2001
  /**
1991
- * Persists the aggregate (insert or update). Implementations should:
2002
+ * Persists the aggregate (insert or update). Implementations are
2003
+ * responsible for **persistence only** — they must NOT touch the
2004
+ * aggregate's in-memory state:
1992
2005
  *
1993
2006
  * 1. Throw `ConcurrencyConflictError` from `@shirudo/ddd-kit` when the
1994
2007
  * aggregate's expected version does not match the version currently
1995
2008
  * stored (optimistic concurrency).
1996
- * 2. After a successful write, call `aggregate.markPersisted(newVersion)`
1997
- * so the in-memory aggregate reflects the new version and clears its
1998
- * pending/domain events.
2009
+ * 2. Write the aggregate to durable storage.
2010
+ *
2011
+ * Do **not** call `aggregate.markPersisted(...)` here. The library's
2012
+ * `withCommit` orchestrator handles the post-save lifecycle (harvest
2013
+ * pending events into the outbox, then mark persisted after commit).
2014
+ * Calling `markPersisted` inside `save` clears pending events too early
2015
+ * and breaks the harvest path — and is also why the Vernon/Axon/
2016
+ * EventFlow pattern separates persistence from commit-events.
1999
2017
  *
2000
- * Return type stays `void` the caller already holds the aggregate
2001
- * reference, which is now up to date.
2018
+ * If you are not using `withCommit` (custom orchestration), call
2019
+ * `aggregate.markPersisted(aggregate.version)` yourself **after** you
2020
+ * have harvested `aggregate.pendingEvents` for downstream dispatch.
2002
2021
  */
2003
2022
  save(aggregate: TAgg): Promise<void>;
2004
2023
  /**
@@ -2276,4 +2295,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2276
2295
  toJSON(): Readonly<T>;
2277
2296
  }
2278
2297
 
2279
- export { type AggregateConfig, AggregateNotFoundError, AggregateRoot, type AggregateSnapshot, type ClockFactory, type Command, CommandBus, type CommandHandler, ConcurrencyConflictError, type CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, type DomainEvent, Entity, type EventBus, EventBusImpl, type EventHandler, type EventIdFactory, type EventMetadata, EventSourcedAggregate, type EventSourcedAggregateConfig, type IAggregateRoot, type ICommandBus, type IEntity, type IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IValueObject, type Id, type IdGenerator, type Identifiable, InfrastructureError, MissingHandlerError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type TransactionScope, type VO, ValueObject, type Version, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withCommit };
2298
+ export { type AggregateConfig, AggregateNotFoundError, AggregateRoot, type AggregateSnapshot, type AnyDomainEvent, type ClockFactory, type Command, CommandBus, type CommandHandler, ConcurrencyConflictError, type CreateDomainEventOptions, DeepEqualExceptOptions, DomainError, type DomainEvent, Entity, type EventBus, EventBusImpl, type EventHandler, type EventIdFactory, type EventMetadata, EventSourcedAggregate, type IAggregateRoot, type ICommandBus, type IEntity, type IEventSourcedAggregate, type IQueryBus, type IQueryableRepository, type IRepository, type IValueObject, type Id, type IdGenerator, type Identifiable, InMemoryOutbox, InfrastructureError, MissingHandlerError, type OnceOptions, type Outbox, type OutboxRecord, type Query, QueryBus, type QueryHandler, type TransactionScope, type VO, ValueObject, type Version, copyMetadata, createDomainEvent, createDomainEventWithMetadata, deepFreeze, entityIds, findEntityById, freezeShallow, hasEntityId, mergeMetadata, removeEntityById, replaceEntityById, resetClockFactory, resetEventIdFactory, sameEntity, sameVersion, setClockFactory, setEventIdFactory, updateEntityById, vo, voEquals, voEqualsExcept, voWithValidation, withCommit };