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

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/dist/index.d.ts CHANGED
@@ -25,6 +25,16 @@ type Id<Tag extends string> = string & {
25
25
  * generator type — `IdGenerator<"UserId">.next()` returns `Id<"UserId">`
26
26
  * with no caller-side generic to abuse.
27
27
  *
28
+ * **Your factory must produce unique ids under concurrent calls.**
29
+ * The kit makes no attempt to dedupe or detect collisions — a collision
30
+ * silently overwrites earlier rows (under unique-key constraints) or
31
+ * silently aliases two different entities (without them). Safe choices:
32
+ * `crypto.randomUUID()` (UUIDv4, the default for events), ULID, UUIDv7,
33
+ * KSUID — all collision-resistant by design. Unsafe choices: `Date.now()`
34
+ * alone (duplicates within the same millisecond), a process-local
35
+ * counter without persistence (resets to 1 on restart, collides with
36
+ * prior runs), a sequential id derived from non-atomic state.
37
+ *
28
38
  * @example
29
39
  * ```ts
30
40
  * import { ulid } from "ulid";
@@ -71,12 +81,56 @@ type EventIdFactory = () => string;
71
81
  *
72
82
  * **Module-scoped — last setter wins.** The factory lives as a single
73
83
  * module variable; importing two libraries that both call this races on
74
- * load order. For multi-tenant request isolation (e.g. one factory per
75
- * tenant in a single Worker invocation) **prefer the per-call
76
- * `options.eventId`** instead of mutating the global. Same caveat applies
77
- * to `setClockFactory`.
84
+ * load order, and parallel test workers will see each other's factory.
85
+ * For test isolation and short-lived contexts prefer
86
+ * {@link withEventIdFactory}; for multi-tenant request isolation
87
+ * (e.g. one factory per tenant in a single Worker invocation) **prefer
88
+ * the per-call `options.eventId`** instead of mutating the global. Same
89
+ * caveat applies to `setClockFactory`.
78
90
  */
79
91
  declare function setEventIdFactory(factory: EventIdFactory): void;
92
+ /**
93
+ * Scoped variant of {@link setEventIdFactory}: installs `factory`,
94
+ * runs `fn`, then restores the previous factory in a `finally` block —
95
+ * so the restoration happens even if `fn` throws. Safe for parallel
96
+ * tests and for synchronous request handlers that need a tenant-
97
+ * specific factory without polluting the global.
98
+ *
99
+ * **Synchronous-only — enforced at runtime.** If `fn` returns a
100
+ * thenable (a `Promise` or any object with a `then` method), the
101
+ * helper throws *before* returning the value to the caller. This
102
+ * catches the async-misuse footgun where the factory would be
103
+ * restored before the awaited body of `fn` runs, leaving the awaited
104
+ * code reading the previous factory. For async scoping across `await`
105
+ * boundaries, use `AsyncLocalStorage` — out of scope for this helper;
106
+ * build it on top if you need it.
107
+ *
108
+ * Composes by nesting: an inner `withEventIdFactory` restores back to
109
+ * the outer's factory; the outer restores to the original.
110
+ *
111
+ * **When to prefer the per-call `options.eventId` instead.** If you're
112
+ * constructing a single event and want full control over its id,
113
+ * passing `{ eventId: "..." }` to `createDomainEvent` is the strongest
114
+ * isolation — it bypasses the factory mechanism entirely, no global
115
+ * mutation, no scope to manage. Reach for `withEventIdFactory` when
116
+ * the events are constructed deep inside domain methods you can't
117
+ * thread an explicit id through (typical test scenario), or when many
118
+ * events in a scope should share the same factory.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // In a vitest test:
123
+ * it("emits deterministic ids", () => {
124
+ * withEventIdFactory(() => "evt-fixed", () => {
125
+ * const e = createDomainEvent("X", { v: 1 });
126
+ * expect(e.eventId).toBe("evt-fixed");
127
+ * });
128
+ * // Outside the callback the default crypto.randomUUID is restored,
129
+ * // even if the body had thrown.
130
+ * });
131
+ * ```
132
+ */
133
+ declare function withEventIdFactory<T>(factory: EventIdFactory, fn: () => T): T;
80
134
  /**
81
135
  * Restores the default event-id factory (`crypto.randomUUID()`).
82
136
  * Intended for use in test `afterEach` hooks.
@@ -102,8 +156,37 @@ type ClockFactory = () => Date;
102
156
  *
103
157
  * The per-call `options.occurredAt` override always wins over this
104
158
  * factory. Symmetric to `setEventIdFactory`.
159
+ *
160
+ * Module-scoped — see {@link setEventIdFactory} for the global-state
161
+ * caveats. For test isolation prefer {@link withClockFactory}; for
162
+ * multi-tenant request isolation prefer the per-call
163
+ * `options.occurredAt`.
105
164
  */
106
165
  declare function setClockFactory(factory: ClockFactory): void;
166
+ /**
167
+ * Scoped variant of {@link setClockFactory}: installs `factory`, runs
168
+ * `fn`, then restores the previous factory in a `finally` block.
169
+ * Synchronous-only — same constraints (and same runtime thenable
170
+ * guard) as {@link withEventIdFactory}.
171
+ *
172
+ * **When to prefer the per-call `options.occurredAt` instead.** Same
173
+ * trade-off as {@link withEventIdFactory}: passing `{ occurredAt }`
174
+ * to `createDomainEvent` is the strongest isolation for single-event
175
+ * cases. The scoped helper is for events constructed deep inside
176
+ * domain methods where threading an explicit timestamp is awkward.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * it("stamps events with a fixed clock", () => {
181
+ * const fixed = new Date("2026-01-01T00:00:00Z");
182
+ * withClockFactory(() => fixed, () => {
183
+ * const e = createDomainEvent("X", { v: 1 });
184
+ * expect(e.occurredAt).toEqual(fixed);
185
+ * });
186
+ * });
187
+ * ```
188
+ */
189
+ declare function withClockFactory<T>(factory: ClockFactory, fn: () => T): T;
107
190
  /**
108
191
  * Restores the default clock factory (`() => new Date()`).
109
192
  * Intended for use in test `afterEach` hooks.
@@ -231,9 +314,25 @@ interface CreateDomainEventOptions {
231
314
  * Creates a domain event with default values.
232
315
  * Sets occurredAt to current date and version to 1 if not provided.
233
316
  *
317
+ * **For aggregate-internal events, prefer `this.recordEvent(...)` on
318
+ * `AggregateRoot` / `EventSourcedAggregate`.** That helper auto-injects
319
+ * `aggregateId` (from `this.id`) and `aggregateType` (from the
320
+ * aggregate's declared `aggregateType` property), which downstream
321
+ * consumers — outbox dispatchers, projection handlers, audit logs —
322
+ * route by. The `withCommit` harvest boundary now validates both fields
323
+ * are present and throws if they're missing, so a direct
324
+ * `createDomainEvent(...)` call inside an aggregate that forgets the
325
+ * options is caught at runtime.
326
+ *
327
+ * Use `createDomainEvent(...)` directly for events that don't belong to
328
+ * an aggregate: system events, integration events, configuration events,
329
+ * test fixtures. For those, set `aggregateId` / `aggregateType` in
330
+ * `options` if downstream consumers expect routing metadata.
331
+ *
234
332
  * @param type - The event type
235
333
  * @param payload - The event payload
236
- * @param options - Optional event configuration
334
+ * @param options - Optional event configuration (including `aggregateId`
335
+ * and `aggregateType` for routing)
237
336
  * @returns A domain event
238
337
  *
239
338
  * @example
@@ -735,7 +834,28 @@ interface AggregateConfig {
735
834
  * }
736
835
  * ```
737
836
  */
738
- declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
837
+ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent extends AnyDomainEvent = never> extends Entity<TState, TId> implements IAggregateRoot<TId, TEvent> {
838
+ /**
839
+ * The aggregate's domain type as a string, used to populate
840
+ * `aggregateType` on events recorded via {@link recordEvent}.
841
+ *
842
+ * Subclasses MUST declare this as a string literal:
843
+ *
844
+ * ```ts
845
+ * class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
846
+ * protected readonly aggregateType = "Order";
847
+ * }
848
+ * ```
849
+ *
850
+ * The string is *the* identifier downstream consumers (outbox
851
+ * dispatchers, projection handlers, audit logs) use to route by
852
+ * aggregate kind. Use the same canonical name across your system —
853
+ * matching the class name is the obvious choice, but the value
854
+ * comes from this explicit declaration, not `constructor.name`
855
+ * (which is fragile under minification, bundler transforms, and
856
+ * subclass renaming).
857
+ */
858
+ protected abstract readonly aggregateType: string;
739
859
  private _version;
740
860
  get version(): Version;
741
861
  protected setVersion(version: Version): void;
@@ -753,16 +873,57 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = ne
753
873
  */
754
874
  clearPendingEvents(): void;
755
875
  /**
756
- * Post-save hook called by a `Repository.save()` implementation to push
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).
876
+ * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
877
+ * (or by your own orchestration code, after harvesting `pendingEvents`)
878
+ * to push the persisted version back into the in-memory aggregate and
879
+ * clear `pendingEvents`. TypeScript has no `final` keyword, but
880
+ * subclasses **should not** override this method directly.
881
+ *
882
+ * Overriding without calling `super.markPersisted(version)` silently
883
+ * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
884
+ * through the outbox, double-emitting events. This bug has been hit
885
+ * in production by consumers; the {@link onPersisted} hook below is
886
+ * the safer extension point.
887
+ *
888
+ * If you must override (legitimate cases are very rare), call
889
+ * `super.markPersisted(version)` FIRST so the framework's cleanup
890
+ * runs, then add your logic afterwards.
760
891
  *
761
- * Use this so `save()` can keep its `Promise<void>` return type: the
762
- * caller holds the aggregate reference, which is up to date after this
763
- * call.
892
+ * @param version - The version assigned by the persistence layer
893
+ * @see onPersisted the safe extension point for subclasses
764
894
  */
765
895
  markPersisted(version: Version): void;
896
+ /**
897
+ * Subclass extension point — fires AFTER {@link markPersisted} has
898
+ * updated the version and cleared `pendingEvents`. Override this for
899
+ * post-persist logging, metrics, or cache-eviction without risk of
900
+ * breaking the framework's pendingEvents cleanup.
901
+ *
902
+ * The default implementation is a no-op. Subclasses do NOT need to
903
+ * call `super.onPersisted(version)` — there is nothing in the parent
904
+ * implementation to preserve.
905
+ *
906
+ * **`onPersisted` deliberately receives only the version, not the
907
+ * drained events.** Event-driven post-persist logic (aggregate-level
908
+ * audit logging, per-event-type side effects) belongs in `EventBus`
909
+ * subscribers or the outbox dispatcher — that is the proper
910
+ * Aggregate-Boundary separation. Building event-aware logic into
911
+ * `onPersisted` couples aggregate lifecycle to event processing and
912
+ * recreates the boundary problems Vernon's aggregate discipline is
913
+ * meant to prevent.
914
+ *
915
+ * **The hook must return synchronously.** `markPersisted` is `void`-
916
+ * typed and calls `onPersisted` without `await`. TypeScript's
917
+ * permissive `void` will accept an `async`-override returning
918
+ * `Promise<void>`, but the returned promise is fire-and-forget —
919
+ * any rejection becomes an unhandled rejection and `withCommit`
920
+ * proceeds without waiting. For asynchronous work, subscribe to the
921
+ * relevant domain event on the `EventBus` instead; that is the
922
+ * properly awaited extension point.
923
+ *
924
+ * @param version - The version that was just persisted
925
+ */
926
+ protected onPersisted(_version: Version): void;
766
927
  /**
767
928
  * Mutates state and records the resulting domain events in the
768
929
  * **canonical record-after-mutation order**. Use this instead of calling
@@ -840,6 +1001,41 @@ declare abstract class AggregateRoot<TState, TId extends Id<string>, TEvent = ne
840
1001
  * @param event - The domain event to record
841
1002
  */
842
1003
  protected addDomainEvent(event: TEvent): void;
1004
+ /**
1005
+ * Sugar for `createDomainEvent` that auto-injects `aggregateId`
1006
+ * (from `this.id`) and `aggregateType` (from {@link aggregateType})
1007
+ * into the event's metadata fields. This is the canonical path for
1008
+ * recording events from inside aggregate domain methods.
1009
+ *
1010
+ * Downstream consumers — outbox dispatchers, projection handlers,
1011
+ * audit logs — route by these two fields. Calling
1012
+ * `createDomainEvent(...)` directly inside an aggregate method
1013
+ * leaves them unset and is caught at the `withCommit` harvest
1014
+ * boundary, but `this.recordEvent(...)` makes the right thing
1015
+ * impossible to forget.
1016
+ *
1017
+ * @example
1018
+ * ```ts
1019
+ * class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
1020
+ * protected readonly aggregateType = "Order";
1021
+ *
1022
+ * confirm(): void {
1023
+ * this.commit(
1024
+ * { ...this.state, status: "confirmed" },
1025
+ * this.recordEvent("OrderConfirmed", { orderId: this.id }),
1026
+ * );
1027
+ * }
1028
+ * }
1029
+ * ```
1030
+ *
1031
+ * @param type - event type discriminator (must be one of `TEvent`'s tags)
1032
+ * @param payload - payload for that event subtype
1033
+ * @param options - any remaining `createDomainEvent` options
1034
+ * (`eventId`, `occurredAt`, `metadata`, `version`); `aggregateId`
1035
+ * and `aggregateType` are deliberately omitted — the helper sets
1036
+ * them.
1037
+ */
1038
+ protected recordEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateDomainEventOptions, "aggregateId" | "aggregateType">): E;
843
1039
  /**
844
1040
  * Manually bumps the aggregate version.
845
1041
  * Call this after state changes for Optimistic Concurrency Control.
@@ -1051,6 +1247,25 @@ type Handler<TState, TEvent extends AnyDomainEvent> = (state: TState, event: TEv
1051
1247
  * ```
1052
1248
  */
1053
1249
  declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEvent, TId extends Id<string>> extends Entity<TState, TId> implements IEventSourcedAggregate<TId, TEvent> {
1250
+ /**
1251
+ * The aggregate's domain type as a string, used to populate
1252
+ * `aggregateType` on events recorded via {@link recordEvent}.
1253
+ *
1254
+ * Subclasses MUST declare this as a string literal:
1255
+ *
1256
+ * ```ts
1257
+ * class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
1258
+ * protected readonly aggregateType = "Order";
1259
+ * }
1260
+ * ```
1261
+ *
1262
+ * Downstream consumers (outbox dispatchers, projection handlers,
1263
+ * audit logs) route by this. Use the canonical aggregate name
1264
+ * consistently across your bounded context. The value comes from
1265
+ * this explicit declaration, not `constructor.name` (fragile under
1266
+ * minification + bundler transforms).
1267
+ */
1268
+ protected abstract readonly aggregateType: string;
1054
1269
  private _version;
1055
1270
  get version(): Version;
1056
1271
  private setVersion;
@@ -1058,13 +1273,83 @@ declare abstract class EventSourcedAggregate<TState, TEvent extends AnyDomainEve
1058
1273
  get pendingEvents(): ReadonlyArray<TEvent>;
1059
1274
  clearPendingEvents(): void;
1060
1275
  /**
1061
- * Post-save hook called by a `Repository.save()` implementation to push
1062
- * the persisted version back into the in-memory aggregate and clear the
1063
- * pending events (they are now in the event store / outbox). Lets
1064
- * `save()` keep its `Promise<void>` return type.
1276
+ * **Framework lifecycle method — `@sealed`.** Called by `withCommit`
1277
+ * (or by your own orchestration code, after harvesting `pendingEvents`)
1278
+ * to push the persisted version back into the in-memory aggregate and
1279
+ * clear `pendingEvents`. TypeScript has no `final` keyword, but
1280
+ * subclasses **should not** override this method directly.
1281
+ *
1282
+ * Overriding without calling `super.markPersisted(version)` silently
1283
+ * leaks `pendingEvents` — the next `withCommit` will re-dispatch them
1284
+ * through the outbox, double-emitting events. This bug has been hit
1285
+ * in production by consumers; the {@link onPersisted} hook below is
1286
+ * the safer extension point.
1287
+ *
1288
+ * If you must override (legitimate cases are very rare), call
1289
+ * `super.markPersisted(version)` FIRST so the framework's cleanup
1290
+ * runs, then add your logic afterwards.
1291
+ *
1292
+ * @param version - The version assigned by the persistence layer
1293
+ * @see onPersisted — the safe extension point for subclasses
1065
1294
  */
1066
1295
  markPersisted(version: Version): void;
1296
+ /**
1297
+ * Subclass extension point — fires AFTER {@link markPersisted} has
1298
+ * updated the version and cleared `pendingEvents`. Override this for
1299
+ * post-persist logging, metrics, or cache-eviction without risk of
1300
+ * breaking the framework's pendingEvents cleanup.
1301
+ *
1302
+ * The default implementation is a no-op. Subclasses do NOT need to
1303
+ * call `super.onPersisted(version)` — there is nothing in the parent
1304
+ * implementation to preserve.
1305
+ *
1306
+ * **`onPersisted` deliberately receives only the version, not the
1307
+ * drained events.** Event-driven post-persist logic (aggregate-level
1308
+ * audit logging, per-event-type side effects) belongs in `EventBus`
1309
+ * subscribers or the outbox dispatcher — that is the proper
1310
+ * Aggregate-Boundary separation. Building event-aware logic into
1311
+ * `onPersisted` couples aggregate lifecycle to event processing and
1312
+ * recreates the boundary problems Vernon's aggregate discipline is
1313
+ * meant to prevent.
1314
+ *
1315
+ * **The hook must return synchronously.** `markPersisted` is `void`-
1316
+ * typed and calls `onPersisted` without `await`. TypeScript's
1317
+ * permissive `void` will accept an `async`-override returning
1318
+ * `Promise<void>`, but the returned promise is fire-and-forget —
1319
+ * any rejection becomes an unhandled rejection and `withCommit`
1320
+ * proceeds without waiting. For asynchronous work, subscribe to the
1321
+ * relevant domain event on the `EventBus` instead; that is the
1322
+ * properly awaited extension point.
1323
+ *
1324
+ * @param version - The version that was just persisted
1325
+ */
1326
+ protected onPersisted(_version: Version): void;
1067
1327
  protected constructor(id: TId, initialState: TState);
1328
+ /**
1329
+ * Sugar for `createDomainEvent` that auto-injects `aggregateId`
1330
+ * (from `this.id`) and `aggregateType` (from {@link aggregateType})
1331
+ * into the event's metadata fields. The canonical path for
1332
+ * constructing events to feed into `apply()` from inside aggregate
1333
+ * domain methods.
1334
+ *
1335
+ * @example
1336
+ * ```ts
1337
+ * class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
1338
+ * protected readonly aggregateType = "Order";
1339
+ *
1340
+ * confirm(): void {
1341
+ * this.apply(this.recordEvent("OrderConfirmed", { orderId: this.id }));
1342
+ * }
1343
+ * }
1344
+ * ```
1345
+ *
1346
+ * Calling `createDomainEvent(...)` directly inside an aggregate
1347
+ * method leaves `aggregateId` and `aggregateType` unset; the
1348
+ * `withCommit` harvest boundary catches it at runtime, but
1349
+ * `this.recordEvent(...)` makes the right thing impossible to
1350
+ * forget.
1351
+ */
1352
+ protected recordEvent<E extends TEvent>(type: E["type"], payload: E["payload"], options?: Omit<CreateDomainEventOptions, "aggregateId" | "aggregateType">): E;
1068
1353
  /**
1069
1354
  * Validates an event before it is applied. Default is no-op.
1070
1355
  * Subclasses override to throw a concrete `DomainError` subclass when
@@ -1638,6 +1923,29 @@ interface TransactionScope<TCtx> {
1638
1923
  * aggregate's `pendingEvents` and writes them via `outbox.add` (so
1639
1924
  * events persist atomically with the state change). Skipped when no
1640
1925
  * events were recorded.
1926
+ *
1927
+ * **Harvest order.** Events are concatenated in the order
1928
+ * aggregates appear in the returned `aggregates` array, then in
1929
+ * each aggregate's `pendingEvents` order (insertion order via
1930
+ * `apply` / `commit` / `addDomainEvent`). So `aggregates: [a, b]`
1931
+ * with `a` emitting `[e1, e2]` and `b` emitting `[e3]` produces
1932
+ * `outbox.add([e1, e2, e3])` and `bus.publish([e1, e2, e3])` in
1933
+ * that exact order.
1934
+ *
1935
+ * **Two ordering guarantees, not one.** Within a single aggregate
1936
+ * the order is *causal* — events are recorded in the order the
1937
+ * domain methods ran, and subscribers (handlers, projections,
1938
+ * replay) MUST process them in that order. Across aggregates the
1939
+ * order in this batch is deterministic but *not* a domain
1940
+ * guarantee. Greg Young / Vernon IDDD §10: aggregates are
1941
+ * independent consistency boundaries; events across them are
1942
+ * eventually consistent. Subscribers should NOT engineer
1943
+ * dependencies on cross-aggregate ordering — use
1944
+ * `EventMetadata.causationId` to express true causation, or a
1945
+ * process manager to coordinate. The in-process EventBus delivers
1946
+ * this batch in order, sequential outbox-dispatchers preserve it
1947
+ * too, but parallel dispatchers or message brokers may reorder
1948
+ * across aggregates at delivery time.
1641
1949
  * 3. The transaction commits.
1642
1950
  * 4. **After** the commit, `aggregate.markPersisted(aggregate.version)`
1643
1951
  * fires on each returned aggregate — only now are pending events
@@ -1654,6 +1962,19 @@ interface TransactionScope<TCtx> {
1654
1962
  * If the transaction rolls back, `markPersisted` is **not** called — the
1655
1963
  * aggregate keeps its pending events, so the caller can retry or discard.
1656
1964
  *
1965
+ * **Duplicate aggregates are deduped by reference.** If the returned
1966
+ * `aggregates` array contains the same instance twice — e.g. a use
1967
+ * case touches an order via two repository references that happen to
1968
+ * resolve to the same identity-map entry — `withCommit` dedupes by
1969
+ * JavaScript object identity before harvesting. Each event lands in
1970
+ * the outbox exactly once and `markPersisted` fires exactly once. Two
1971
+ * *different* instances with the same logical id cannot be detected
1972
+ * at this layer; that is a Repository contract violation (failure to
1973
+ * maintain Fowler's Identity Map per Unit of Work). See
1974
+ * `docs/guide/repository.md` → "Identity Map: one instance per
1975
+ * aggregate per Unit of Work" for the requirement on `IRepository`
1976
+ * implementations that makes this dedupe sound.
1977
+ *
1657
1978
  * @example Tx-bound repos (Drizzle, Prisma, Mongo, …)
1658
1979
  * ```typescript
1659
1980
  * const result = await withCommit({ outbox, bus, scope }, async (tx) => {
@@ -2008,6 +2329,27 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2008
2329
  * stored (optimistic concurrency).
2009
2330
  * 2. Write the aggregate to durable storage.
2010
2331
  *
2332
+ * **Insert vs update — library convention.** A fresh aggregate begins
2333
+ * at `version === 0` (the `Version` brand defaults to `0` in both
2334
+ * `AggregateRoot` and `EventSourcedAggregate`). After the first
2335
+ * versioned mutation (`setState(_, true)`, `apply()`, `commit()`) the
2336
+ * version is `> 0`. Implementations distinguish the two paths by the
2337
+ * incoming `aggregate.version`:
2338
+ *
2339
+ * - `aggregate.version === 0` → **INSERT** (no existing row to lock
2340
+ * against; the write succeeds unconditionally or fails the unique
2341
+ * constraint on `id`).
2342
+ * - `aggregate.version > 0` → **UPDATE** with the OCC predicate
2343
+ * `WHERE id = ? AND version = expected`. If the row count is `0`,
2344
+ * another writer raced you — throw `ConcurrencyConflictError`.
2345
+ *
2346
+ * The library does not formalise this in the type system because
2347
+ * version-bump semantics differ across the two aggregate flavours
2348
+ * (state-stored aggregates bump on the user's call to `setState(_,
2349
+ * true)`; event-sourced aggregates bump on every `apply()` by
2350
+ * definition). The `version === 0` invariant for "never persisted" is
2351
+ * the common contract.
2352
+ *
2011
2353
  * Do **not** call `aggregate.markPersisted(...)` here. The library's
2012
2354
  * `withCommit` orchestrator handles the post-save lifecycle (harvest
2013
2355
  * pending events into the outbox, then mark persisted after commit).
@@ -2021,7 +2363,45 @@ interface IRepository<TAgg extends IAggregateRoot<TId>, TId extends Id<string>>
2021
2363
  */
2022
2364
  save(aggregate: TAgg): Promise<void>;
2023
2365
  /**
2024
- * Removes the aggregate by id.
2366
+ * Removes the aggregate's row by id. Pure persistence — does NOT
2367
+ * harvest pending events from the aggregate (the contract takes
2368
+ * only the id, so there is no aggregate to harvest from).
2369
+ *
2370
+ * Before reaching for `delete`, ask whether the user-facing "delete"
2371
+ * is the right domain verb. Most are actually state transitions
2372
+ * (*cancel*, *archive*, *close*, *deactivate*, *terminate*) with
2373
+ * proper domain names that should be modelled as state changes plus
2374
+ * a recorded event — not as row removal.
2375
+ *
2376
+ * `delete(id)` belongs in the toolkit for three distinct cases, in
2377
+ * decreasing order of common occurrence (see
2378
+ * `docs/guide/repository.md` → "Deletion and Domain Events" for
2379
+ * worked examples):
2380
+ *
2381
+ * 1. **State transition that records an event.** The user-facing
2382
+ * "delete" maps to a real domain operation (e.g. `order.cancel()`,
2383
+ * `order.archive()`). Call `save(aggregate)`; the row stays with
2384
+ * a status column. `delete(id)` is never called by the use case.
2385
+ *
2386
+ * 2. **Hard-delete with event harvest.** The row genuinely must
2387
+ * vanish (regulatory purge, retention-window expiry, true
2388
+ * termination) *and* the disappearance is a domain fact
2389
+ * subscribers care about. Inside `withCommit`'s transactional
2390
+ * callback, record the deletion event on the aggregate, then
2391
+ * call `delete(id)`. Return the aggregate in the `aggregates`
2392
+ * array so `withCommit` harvests its pending events into the
2393
+ * outbox before the row is gone.
2394
+ *
2395
+ * 3. **Hard-delete without event.** Deletion is invisible to the
2396
+ * domain (abandoned-cart cleanup, expired session rows). No
2397
+ * subscriber cares. If the entity has identity in the ubiquitous
2398
+ * language, you probably want path 1 or 2 instead.
2399
+ *
2400
+ * In pure event-sourced systems `delete` is rarely meaningful —
2401
+ * end-of-lifecycle there is a `Closed` / `Terminated` event in the
2402
+ * stream, and identity persists in the event log. `delete` applies
2403
+ * primarily to state-stored aggregates and snapshot / projection
2404
+ * tables.
2025
2405
  */
2026
2406
  delete(id: TId): Promise<void>;
2027
2407
  }
@@ -2295,4 +2675,4 @@ declare abstract class ValueObject<T extends object> implements IValueObject<T>
2295
2675
  toJSON(): Readonly<T>;
2296
2676
  }
2297
2677
 
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 };
2678
+ 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, withClockFactory, withCommit, withEventIdFactory };