@shirudo/ddd-kit 0.15.0 → 1.0.0-rc.1

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  Composable TypeScript toolkit for tactical Domain-Driven Design.
4
4
 
5
- > **⚠️ BETA WARNING**
5
+ > **Release Candidate**
6
6
  >
7
- > This library is currently in beta. The API is subject to change until a stable 1.0.0 release. Breaking changes may occur in minor versions during the beta phase. Please pin your dependencies to specific versions.
7
+ > This library is in Release Candidate phase. The API is considered stable and ready for production evaluation. Please report any issues before the final 1.0.0 release.
8
8
 
9
9
  ## Badges
10
10
 
@@ -20,7 +20,7 @@ Composable TypeScript toolkit for tactical Domain-Driven Design.
20
20
  - **Repositories** - Persistence abstraction layer for aggregates with specification pattern support
21
21
  - **Specifications** - Reusable query specifications for complex domain queries
22
22
  - **Unit of Work** - Transaction management for maintaining consistency across operations
23
- - **Result Type** - Functional error handling with `Result<T, E>` type for explicit success/failure states
23
+ - **Result Type** - Functional error handling with `Result<T, E>` type for explicit success/failure states. For advanced error handling with typed error hierarchies, see [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error)
24
24
 
25
25
  ## Installation
26
26
 
@@ -72,7 +72,7 @@ In Domain-Driven Design, Entities are objects with identity and state. Unlike Va
72
72
  - Has identity (id), state, and version for optimistic concurrency control
73
73
  - Represents the aggregate externally
74
74
  - Loaded/saved through repositories
75
- - Created by extending `AggregateRoot` or `AggregateEventSourced`
75
+ - Created by extending `AggregateRoot` (state-based) or `EventSourcedAggregate` (event-sourced)
76
76
  - Implements `IAggregateRoot<TId>`
77
77
 
78
78
  2. **Child Entities**: Entities within an aggregate.
@@ -103,11 +103,11 @@ The Aggregate Root is an Entity (the parent Entity of the aggregate) that repres
103
103
 
104
104
  The library provides:
105
105
 
106
- - **`AggregateRoot<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>`** - 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.
107
107
 
108
- - **`AggregateBase<TState, TId>`** - Base class for creating Aggregate Root Entities without Event Sourcing. Implements `AggregateRoot<TId>`. The aggregate state (`TState`) contains child entities and value objects. Provides ID and version management, state management, 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>`. 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.
109
109
 
110
- - **`AggregateEventSourced<TState, TEvent, TId>`** - Base class for Event-Sourced Aggregate Root Entities. Extends `AggregateBase` (and thus implements `AggregateRoot<TId>`). Adds event tracking, event handlers, event validation, and history replay capabilities. Use this when you want full Event Sourcing with event tracking and replay.
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
 
112
112
  Both classes support automatic versioning (configurable), snapshot creation/restoration, and optimistic concurrency control. The version applies to the entire aggregate, including all child entities.
113
113
 
@@ -117,7 +117,7 @@ CQRS separates read operations (Queries) from write operations (Commands), provi
117
117
 
118
118
  ### Domain Events
119
119
 
120
- Domain Events represent something meaningful that happened in your domain. They are immutable records with a type, payload, timestamp, optional version for schema evolution, and metadata for traceability. Events support versioning for handling schema changes over time and include metadata fields like `correlationId`, `causationId`, `userId`, and `source` for tracking event flow in distributed systems. Events are automatically tracked by aggregates and can be published to event buses or stored in outboxes for eventual consistency.
120
+ Domain Events represent something meaningful that happened in your domain. They are immutable records with a type, payload, timestamp, version for schema evolution, and optional metadata for traceability. Events support versioning for handling schema changes over time and include metadata fields like `correlationId`, `causationId`, `userId`, and `source` for tracking event flow in distributed systems. Events are automatically tracked by aggregates and can be published to event buses or stored in outboxes for eventual consistency.
121
121
 
122
122
  ### Repositories
123
123
 
@@ -218,8 +218,8 @@ voEqualsExcept(address1, address2, {
218
218
 
219
219
  ```typescript
220
220
  import {
221
- AggregateBase,
222
- type AggregateRoot,
221
+ AggregateRoot,
222
+ type IAggregateRoot,
223
223
  type Id,
224
224
  } from "@shirudo/ddd-kit";
225
225
 
@@ -233,7 +233,8 @@ type OrderState = {
233
233
  status: "pending" | "confirmed" | "shipped";
234
234
  };
235
235
 
236
- class Order extends AggregateBase<OrderState, OrderId> implements AggregateRoot<OrderId> {
236
+ // Without typed events (TEvent defaults to unknown)
237
+ class Order extends AggregateRoot<OrderState, OrderId> {
237
238
  static create(id: OrderId, customerId: string): Order {
238
239
  const initialState: OrderState = {
239
240
  id,
@@ -246,32 +247,29 @@ class Order extends AggregateBase<OrderState, OrderId> implements AggregateRoot<
246
247
  }
247
248
 
248
249
  addItem(productId: string, quantity: number, price: number): void {
249
- if (this._state.status !== "pending") {
250
+ if (this.state.status !== "pending") {
250
251
  throw new Error("Cannot add items to a non-pending order");
251
252
  }
252
253
 
253
- this._state = {
254
- ...this._state,
255
- items: [...this._state.items, { productId, quantity, price }],
256
- total: this._state.total + quantity * price,
257
- };
258
- this.bumpVersion(); // Manual version bump for optimistic concurrency control
254
+ this.setState({
255
+ ...this.state,
256
+ items: [...this.state.items, { productId, quantity, price }],
257
+ total: this.state.total + quantity * price,
258
+ }, true); // true = bump version for optimistic concurrency control
259
259
  }
260
260
 
261
261
  confirm(): void {
262
- if (this._state.status !== "pending") {
262
+ if (this.state.status !== "pending") {
263
263
  throw new Error("Only pending orders can be confirmed");
264
264
  }
265
- this._state = { ...this._state, status: "confirmed" };
266
- this.bumpVersion();
265
+ this.setState({ ...this.state, status: "confirmed" }, true);
267
266
  }
268
267
 
269
268
  ship(): void {
270
- if (this._state.status !== "confirmed") {
269
+ if (this.state.status !== "confirmed") {
271
270
  throw new Error("Only confirmed orders can be shipped");
272
271
  }
273
- this._state = { ...this._state, status: "shipped" };
274
- this.bumpVersion();
272
+ this.setState({ ...this.state, status: "shipped" }, true);
275
273
  }
276
274
  }
277
275
 
@@ -285,11 +283,36 @@ console.log(order.version); // 3 (manually bumped)
285
283
  console.log(order.state.status); // "shipped"
286
284
  ```
287
285
 
286
+ #### With Typed Domain Events
287
+
288
+ Use the optional third type parameter to get compile-time event validation:
289
+
290
+ ```typescript
291
+ type OrderDomainEvent =
292
+ | { type: "OrderConfirmed" }
293
+ | { type: "OrderShipped"; trackingNumber: string };
294
+
295
+ class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
296
+ confirm(): void {
297
+ this.setState({ ...this.state, status: "confirmed" }, true);
298
+ this.addDomainEvent({ type: "OrderConfirmed" }); // type-safe
299
+ }
300
+
301
+ ship(trackingNumber: string): void {
302
+ this.setState({ ...this.state, status: "shipped" }, true);
303
+ this.addDomainEvent({ type: "OrderShipped", trackingNumber }); // type-safe
304
+ }
305
+ }
306
+
307
+ // order.domainEvents is ReadonlyArray<OrderDomainEvent> — no cast needed
308
+ // order.addDomainEvent({ type: "WrongEvent" }) → compile error
309
+ ```
310
+
288
311
  ### Creating an Aggregate WITH Event Sourcing
289
312
 
290
313
  ```typescript
291
314
  import {
292
- AggregateEventSourced,
315
+ EventSourcedAggregate,
293
316
  createDomainEvent,
294
317
  type AggregateRoot,
295
318
  type Id,
@@ -306,12 +329,12 @@ type OrderState = {
306
329
  };
307
330
 
308
331
  type OrderCreated = DomainEvent<"OrderCreated", { customerId: string }>;
309
- type OrderConfirmed = DomainEvent<"OrderConfirmed", {}>;
332
+ type OrderConfirmed = DomainEvent<"OrderConfirmed">;
310
333
  type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
311
334
 
312
335
  type OrderEvent = OrderCreated | OrderConfirmed | OrderShipped;
313
336
 
314
- class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
337
+ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
315
338
  static create(id: OrderId, customerId: string): Order {
316
339
  const initialState: OrderState = {
317
340
  id,
@@ -328,7 +351,7 @@ class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> imple
328
351
 
329
352
  confirm(): void {
330
353
  const result = this.apply(
331
- createDomainEvent("OrderConfirmed", {}) as OrderConfirmed
354
+ createDomainEvent("OrderConfirmed") as OrderConfirmed
332
355
  );
333
356
  if (!result.ok) {
334
357
  throw new Error(result.error);
@@ -347,7 +370,7 @@ class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> imple
347
370
  // Or use unsafe variant (throws exception directly)
348
371
  confirmUnsafe(): void {
349
372
  this.applyUnsafe(
350
- createDomainEvent("OrderConfirmed", {}) as OrderConfirmed
373
+ createDomainEvent("OrderConfirmed") as OrderConfirmed
351
374
  );
352
375
  }
353
376
 
@@ -388,9 +411,9 @@ console.log(order.version); // 3 (automatically bumped)
388
411
 
389
412
  ```typescript
390
413
  import {
391
- AggregateBase,
392
- AggregateEventSourced,
393
- sameAggregate,
414
+ AggregateRoot,
415
+ EventSourcedAggregate,
416
+ sameVersion,
394
417
  type Id,
395
418
  } from "@shirudo/ddd-kit";
396
419
 
@@ -413,11 +436,11 @@ const eventSourcedOrder = EventSourcedOrder.create("order-123" as OrderId, "cust
413
436
  const eventsAfterSnapshot = [/* events that occurred after snapshot */];
414
437
  eventSourcedOrder.restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot);
415
438
 
416
- // Aggregate equality check
439
+ // Optimistic concurrency check
417
440
  const order1 = await repository.getById(id);
418
441
  // ... some operations ...
419
442
  const order2 = await repository.getById(id);
420
- if (!sameAggregate(order1, order2)) {
443
+ if (!sameVersion(order1, order2)) {
421
444
  throw new Error("Aggregate was modified by another process");
422
445
  }
423
446
  ```
@@ -426,7 +449,7 @@ if (!sameAggregate(order1, order2)) {
426
449
 
427
450
  ```typescript
428
451
  import {
429
- AggregateEventSourced,
452
+ EventSourcedAggregate,
430
453
  createDomainEvent,
431
454
  err,
432
455
  ok,
@@ -441,7 +464,7 @@ type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
441
464
  type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
442
465
  type OrderEvent = OrderShipped;
443
466
 
444
- class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
467
+ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
445
468
  // Event validation
446
469
  protected validateEvent(event: OrderEvent): Result<true, string> {
447
470
  if (event.type === "OrderShipped" && this.state.status !== "confirmed") {
@@ -756,6 +779,11 @@ const orderCreated = createDomainEvent("OrderCreated", {
756
779
 
757
780
  await eventBus.publish([orderCreated]);
758
781
  // Both email and logging handlers will be called
782
+
783
+ // Wait for the next event of a given type (useful for tests and workflows)
784
+ const event = await eventBus.once<OrderCreated>("OrderCreated");
785
+ console.log("Order created:", event.payload.orderId);
786
+ // Automatically unsubscribes after the first event
759
787
  ```
760
788
 
761
789
  ### Creating Events with Metadata for Traceability
@@ -859,49 +887,42 @@ class Order extends AggregateRoot<OrderState, OrderId>
859
887
  price,
860
888
  };
861
889
 
862
- this._state = {
863
- ...this._state,
864
- items: [...this._state.items, item],
865
- total: this._state.total + price * quantity,
866
- };
867
- this.bumpVersion(); // Versions the entire aggregate (including child entities)
890
+ this.setState({
891
+ ...this.state,
892
+ items: [...this.state.items, item],
893
+ total: this.state.total + price * quantity,
894
+ }, true); // true = bump version (versions the entire aggregate including child entities)
868
895
  return itemId;
869
896
  }
870
897
 
871
898
  updateItemQuantity(itemId: ItemId, newQuantity: number): void {
872
- const item = findEntityById(this._state.items, itemId);
899
+ const item = findEntityById(this.state.items, itemId);
873
900
  if (!item) {
874
901
  throw new Error("Item not found");
875
902
  }
876
903
 
877
- this._state = {
878
- ...this._state,
879
- items: updateEntityById(
880
- this._state.items,
881
- itemId,
882
- (i) => ({ ...i, quantity: newQuantity })
883
- ),
884
- total: this._state.total - item.price * item.quantity + item.price * newQuantity,
885
- };
886
- this.bumpVersion(); // Versions the entire aggregate
904
+ this.setState({
905
+ ...this.state,
906
+ items: updateEntityById(this.state.items, itemId, (i) => ({ ...i, quantity: newQuantity })),
907
+ total: this.state.total - item.price * item.quantity + item.price * newQuantity,
908
+ }, true);
887
909
  }
888
910
 
889
911
  removeItem(itemId: ItemId): void {
890
- const item = findEntityById(this._state.items, itemId);
912
+ const item = findEntityById(this.state.items, itemId);
891
913
  if (!item) {
892
914
  throw new Error("Item not found");
893
915
  }
894
916
 
895
- this._state = {
896
- ...this._state,
897
- items: removeEntityById(this._state.items, itemId),
898
- total: this._state.total - item.price * item.quantity,
899
- };
900
- this.bumpVersion(); // Versions the entire aggregate
917
+ this.setState({
918
+ ...this.state,
919
+ items: removeEntityById(this.state.items, itemId),
920
+ total: this.state.total - item.price * item.quantity,
921
+ }, true);
901
922
  }
902
923
 
903
924
  getItem(itemId: ItemId): OrderItem | undefined {
904
- return findEntityById(this._state.items, itemId);
925
+ return findEntityById(this.state.items, itemId);
905
926
  }
906
927
  }
907
928
 
@@ -950,15 +971,15 @@ class OrderItem extends Entity<OrderItemState, ItemId> {
950
971
  if (newQuantity <= 0) {
951
972
  throw new Error("Quantity must be greater than 0");
952
973
  }
953
- this._state = { ...this._state, quantity: newQuantity };
974
+ this.setState({ ...this.state, quantity: newQuantity });
954
975
  }
955
976
 
956
977
  calculateSubtotal(): number {
957
- return this._state.price * this._state.quantity;
978
+ return this.state.price * this.state.quantity;
958
979
  }
959
980
 
960
981
  isForProduct(productId: string): boolean {
961
- return this._state.productId === productId;
982
+ return this.state.productId === productId;
962
983
  }
963
984
 
964
985
  protected validateState(state: OrderItemState): void {
@@ -995,17 +1016,16 @@ class Order extends AggregateRoot<OrderState, OrderId>
995
1016
  const itemId = `item-${++this.itemCounter}` as ItemId;
996
1017
  const item = new OrderItem(itemId, productId, quantity, price);
997
1018
 
998
- this._state = {
999
- ...this._state,
1000
- items: [...this._state.items, item],
1001
- };
1002
- this.bumpVersion();
1019
+ this.setState({
1020
+ ...this.state,
1021
+ items: [...this.state.items, item],
1022
+ }, true);
1003
1023
  return itemId;
1004
1024
  }
1005
1025
 
1006
1026
  // Delegate to entity's business logic
1007
1027
  updateItemQuantity(itemId: ItemId, newQuantity: number): void {
1008
- const item = findEntityById(this._state.items, itemId);
1028
+ const item = findEntityById(this.state.items, itemId);
1009
1029
  if (!item) throw new Error("Item not found");
1010
1030
 
1011
1031
  item.updateQuantity(newQuantity); // Uses entity's logic
@@ -1014,18 +1034,17 @@ class Order extends AggregateRoot<OrderState, OrderId>
1014
1034
 
1015
1035
  // Use entity's business logic
1016
1036
  calculateTotal(): number {
1017
- return this._state.items.reduce(
1037
+ return this.state.items.reduce(
1018
1038
  (total, item) => total + item.calculateSubtotal(),
1019
1039
  0
1020
1040
  );
1021
1041
  }
1022
1042
 
1023
1043
  confirm(): void {
1024
- if (this._state.items.length === 0) {
1044
+ if (this.state.items.length === 0) {
1025
1045
  throw new Error("Cannot confirm an order without items");
1026
1046
  }
1027
- this._state = { ...this._state, status: "confirmed" };
1028
- this.bumpVersion();
1047
+ this.setState({ ...this.state, status: "confirmed" }, true);
1029
1048
  }
1030
1049
  }
1031
1050
 
@@ -1167,11 +1186,11 @@ This package is written in TypeScript and provides full type definitions. All ty
1167
1186
  Key exports include:
1168
1187
  - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()`, `voWithValidationUnsafe()` - Value Object utilities
1169
1188
  - `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
1170
- - `AggregateRoot<TState, TId>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId>`)
1171
- - `AggregateEventSourced<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Root Entities (extends `AggregateRoot`, implements `IAggregateEventSourced<TId, TEvent>`)
1172
- - `AggregateConfig`, `AggregateEventSourcedConfig` - Configuration interfaces
1189
+ - `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
1190
+ - `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
1191
+ - `AggregateConfig`, `EventSourcedAggregateConfig` - Configuration interfaces
1173
1192
  - `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
1174
- - `sameAggregate()` - Aggregate equality helper
1193
+ - `sameVersion()` - Optimistic concurrency check (same ID and version)
1175
1194
  - `Entity<TState, TId>` - Base class for entities with state and business logic
1176
1195
  - `IEntity<TId, TState>` - Entity interface
1177
1196
  - `Identifiable<TId>` - Minimal interface for objects with id
@@ -1181,18 +1200,21 @@ Key exports include:
1181
1200
  - `CommandBus`, `ICommandBus` - Command bus for centralized command execution
1182
1201
  - `QueryBus`, `IQueryBus` - Query bus for centralized query execution (with `execute()` returning Result and `executeUnsafe()` throwing exceptions)
1183
1202
  - `withCommit()` - Helper for transactional command execution with events
1184
- - `DomainEvent<T, P>`, `EventMetadata` - Domain event interfaces
1185
- - `createDomainEvent()`, `createDomainEventWithMetadata()` - Event creation helpers
1203
+ - `DomainEvent<T, P?>` - Domain event interface (`P` defaults to `void` for payload-less events)
1204
+ - `EventMetadata` - Event metadata interface for traceability
1205
+ - `createDomainEvent()` - Event creation helper (payload is optional for payload-less events)
1206
+ - `createDomainEventWithMetadata()` - Event creation with metadata
1186
1207
  - `copyMetadata()`, `mergeMetadata()` - Metadata utilities
1187
1208
  - `EventBus<Evt>`, `EventBusImpl<Evt>` - Event bus interface and implementation for pub/sub pattern
1188
1209
  - `EventHandler<Evt>` - Event handler function type
1189
1210
  - `EventBus.subscribe()` - Subscribe handlers to event types
1190
- - `EventBus.publish()` - Publish events to all subscribers
1211
+ - `EventBus.publish()` - Publish events to all subscribers (uses `Promise.allSettled` — all handlers run even if one fails)
1212
+ - `EventBus.once()` - Wait for the next event of a given type (returns Promise, auto-unsubscribes)
1191
1213
  - `Result<T, E>`, `ok()`, `err()`, `isOk()`, `isErr()` - Result type and type guards
1192
1214
  - `andThen()`, `map()`, `mapErr()` - Result composition utilities
1193
1215
  - `unwrapOr()`, `unwrapOrElse()`, `match()` - Result unwrapping and pattern matching
1194
1216
  - `Id<Tag>` - Branded ID type
1195
- - `IRepository<TState, TEvent, TAgg, TId>` - Repository interface
1217
+ - `IRepository<TAgg, TId>` - Repository interface
1196
1218
  - `ISpecification<T>` - Specification interface
1197
1219
  - `UnitOfWork` - Unit of Work interface
1198
1220
  - `guard()` - Guard/validation helper
@@ -1313,7 +1335,7 @@ class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId>
1313
1335
 
1314
1336
  // 3. Save
1315
1337
  await this.repository.save(order);
1316
- await this.eventBus.publish(order.pendingEvents);
1338
+ await this.eventBus.publish(order.domainEvents);
1317
1339
 
1318
1340
  return ok(order.id);
1319
1341
  // 4. Aggregate is garbage collected when method returns
@@ -1520,7 +1542,7 @@ class OrderService {
1520
1542
  }
1521
1543
 
1522
1544
  await this.repository.save(order);
1523
- await this.eventBus.publish(order.pendingEvents);
1545
+ await this.eventBus.publish(order.domainEvents);
1524
1546
 
1525
1547
  return ok(order.id);
1526
1548
  // order is garbage collected here