@shirudo/ddd-kit 0.16.0 → 1.0.0-rc.2

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,11 @@
2
2
 
3
3
  Composable TypeScript toolkit for tactical Domain-Driven Design.
4
4
 
5
- > **⚠️ BETA WARNING**
5
+ 📚 **[Full documentation site](https://shi-rudo.github.io/ddd-kit-ts/)** — guides, API reference, design decisions.
6
+
7
+ > **Release Candidate**
6
8
  >
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.
9
+ > 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
10
 
9
11
  ## Badges
10
12
 
@@ -20,7 +22,7 @@ Composable TypeScript toolkit for tactical Domain-Driven Design.
20
22
  - **Repositories** - Persistence abstraction layer for aggregates with specification pattern support
21
23
  - **Specifications** - Reusable query specifications for complex domain queries
22
24
  - **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
25
+ - **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
26
 
25
27
  ## Installation
26
28
 
@@ -62,7 +64,7 @@ const email = createEmail("user@example.com");
62
64
 
63
65
  ### Value Objects
64
66
 
65
- Value Objects are immutable objects that are defined by their attributes rather than identity. They ensure data integrity by preventing modification after creation. Use the `vo()` helper function to create deeply frozen value objects that cannot be mutated, even nested objects and arrays. The library provides `voEquals()` for value-based equality comparison, `voEqualsExcept()` for comparing while ignoring specified keys (useful for metadata), `voWithValidation()` for creating validated value objects (returns Result), and `voWithValidationUnsafe()` for the exception-throwing variant.
67
+ Value Objects are immutable objects that are defined by their attributes rather than identity. They ensure data integrity by preventing modification after creation. Use the `vo()` helper function to create deeply frozen value objects that cannot be mutated, even nested objects and arrays. The library provides `voEquals()` for value-based equality comparison, `voEqualsExcept()` for comparing while ignoring specified keys (useful for metadata), and `voWithValidation()` for creating validated value objects at the App-Service boundary (returns Result). For Domain construction, prefer the `ValueObject` base class — its constructor throws on invariant violation via the `validate()` hook.
66
68
 
67
69
  ### Entities
68
70
 
@@ -72,7 +74,7 @@ In Domain-Driven Design, Entities are objects with identity and state. Unlike Va
72
74
  - Has identity (id), state, and version for optimistic concurrency control
73
75
  - Represents the aggregate externally
74
76
  - Loaded/saved through repositories
75
- - Created by extending `AggregateRoot` or `AggregateEventSourced`
77
+ - Created by extending `AggregateRoot` (state-based) or `EventSourcedAggregate` (event-sourced)
76
78
  - Implements `IAggregateRoot<TId>`
77
79
 
78
80
  2. **Child Entities**: Entities within an aggregate.
@@ -107,7 +109,7 @@ The library provides:
107
109
 
108
110
  - **`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
111
 
110
- - **`AggregateEventSourced<TState, TEvent, TId>`** - Base class for Event-Sourced Aggregate Root Entities. Extends `AggregateRoot` (and thus implements `IAggregateRoot<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.
112
+ - **`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
113
 
112
114
  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
115
 
@@ -117,7 +119,7 @@ CQRS separates read operations (Queries) from write operations (Commands), provi
117
119
 
118
120
  ### Domain Events
119
121
 
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.
122
+ 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
123
 
122
124
  ### Repositories
123
125
 
@@ -170,19 +172,16 @@ const result = voWithValidation(
170
172
  "Amount must be non-negative and currency must be 3 characters"
171
173
  );
172
174
 
173
- if (result.ok) {
175
+ if (result.isOk()) {
174
176
  const validMoney = result.value;
175
177
  // Use validMoney...
176
178
  } else {
177
179
  console.error(result.error);
178
180
  }
179
181
 
180
- // Or use unsafe variant (throws exception)
181
- const validMoneyUnsafe = voWithValidationUnsafe(
182
- { amount: 100, currency: "USD" },
183
- (m) => m.amount >= 0 && m.currency.length === 3,
184
- "Amount must be non-negative and currency must be 3 characters"
185
- );
182
+ // For Domain construction, use the `ValueObject` base class — its constructor
183
+ // throws via the `validate()` hook, so Domain code keeps a throw-based contract.
184
+ // Reserve `voWithValidation` for parsing untrusted input at the App boundary.
186
185
 
187
186
  // Value object with nested structures (deep freeze)
188
187
  const address = vo({
@@ -247,32 +246,29 @@ class Order extends AggregateRoot<OrderState, OrderId> {
247
246
  }
248
247
 
249
248
  addItem(productId: string, quantity: number, price: number): void {
250
- if (this._state.status !== "pending") {
249
+ if (this.state.status !== "pending") {
251
250
  throw new Error("Cannot add items to a non-pending order");
252
251
  }
253
252
 
254
- this._state = {
255
- ...this._state,
256
- items: [...this._state.items, { productId, quantity, price }],
257
- total: this._state.total + quantity * price,
258
- };
259
- this.bumpVersion(); // Manual version bump for optimistic concurrency control
253
+ this.setState({
254
+ ...this.state,
255
+ items: [...this.state.items, { productId, quantity, price }],
256
+ total: this.state.total + quantity * price,
257
+ }, true); // true = bump version for optimistic concurrency control
260
258
  }
261
259
 
262
260
  confirm(): void {
263
- if (this._state.status !== "pending") {
261
+ if (this.state.status !== "pending") {
264
262
  throw new Error("Only pending orders can be confirmed");
265
263
  }
266
- this._state = { ...this._state, status: "confirmed" };
267
- this.bumpVersion();
264
+ this.setState({ ...this.state, status: "confirmed" }, true);
268
265
  }
269
266
 
270
267
  ship(): void {
271
- if (this._state.status !== "confirmed") {
268
+ if (this.state.status !== "confirmed") {
272
269
  throw new Error("Only confirmed orders can be shipped");
273
270
  }
274
- this._state = { ...this._state, status: "shipped" };
275
- this.bumpVersion();
271
+ this.setState({ ...this.state, status: "shipped" }, true);
276
272
  }
277
273
  }
278
274
 
@@ -297,15 +293,13 @@ type OrderDomainEvent =
297
293
 
298
294
  class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
299
295
  confirm(): void {
300
- this._state = { ...this._state, status: "confirmed" };
296
+ this.setState({ ...this.state, status: "confirmed" }, true);
301
297
  this.addDomainEvent({ type: "OrderConfirmed" }); // type-safe
302
- this.bumpVersion();
303
298
  }
304
299
 
305
300
  ship(trackingNumber: string): void {
306
- this._state = { ...this._state, status: "shipped" };
301
+ this.setState({ ...this.state, status: "shipped" }, true);
307
302
  this.addDomainEvent({ type: "OrderShipped", trackingNumber }); // type-safe
308
- this.bumpVersion();
309
303
  }
310
304
  }
311
305
 
@@ -313,11 +307,53 @@ class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
313
307
  // order.addDomainEvent({ type: "WrongEvent" }) → compile error
314
308
  ```
315
309
 
310
+ > **Domain-event ordering: record AFTER mutation.** A domain event represents something that has *just happened* to the aggregate. Always mutate state first (`setState`, invariant checks), then `addDomainEvent`. Recording before mutation is a footgun: if a subsequent invariant throws, the event has been queued for a fact that never actually happened.
311
+ >
312
+ > Two paths give you that ordering for free, so you don't have to remember the rule:
313
+ >
314
+ > - **`EventSourcedAggregate.apply(event)`** — `validateEvent` runs, then the handler computes the next state, then state + event + version commit atomically. State is never mutated without the event, and the event is never recorded without the state.
315
+ > - **`AggregateRoot.commit(newState, event)`** — opt-in helper that runs `setState(newState)` first (which throws on `validateState` failure) and only then appends the event(s). Use this instead of calling `setState` + `addDomainEvent` separately:
316
+ >
317
+ > ```ts
318
+ > confirm(): void {
319
+ > if (this.state.status === "confirmed") throw new OrderAlreadyConfirmedError(this.id);
320
+ > this.commit(
321
+ > { ...this.state, status: "confirmed" },
322
+ > { type: "OrderConfirmed", orderId: this.id },
323
+ > );
324
+ > }
325
+ > ```
326
+ >
327
+ > `commit()` accepts a single event, an array of events, or none. Direct `setState`/`addDomainEvent` calls remain available for cases that don't fit the helper (state-only mutations, audit-only events, multi-step transactions).
328
+
329
+ > **Aggregate methods own the behaviour, not the consumer.** Subclasses of `AggregateRoot` and `EventSourcedAggregate` expose state via the `state` getter (DDD requires invariant checks to read it), but the canonical Pattern is *Tell, Don't Ask*: write business methods on the aggregate that mutate via `commit()` / `setState()` / `apply()` and emit events; do not write `if (order.state.status === "draft") order.state.status = "confirmed"` from outside the aggregate. The state getter is for the aggregate's own methods and for read-only projections — not as a public mutation handle.
330
+
331
+ ### Event-Sourcing Schema Evolution (Upcasting)
332
+
333
+ `DomainEvent.version` is intentionally a plain integer rather than a library-managed migration chain. Schema evolution is **the consumer's responsibility** — every event store handles it differently (sync upcasters in the load path, async upcasters in a projection rebuild, schema-registry coupling, etc.). The recommended pattern is to wrap your event-store read path:
334
+
335
+ ```ts
336
+ // At the infrastructure boundary, before passing events to loadFromHistory:
337
+ function upcast(event: PersistedEvent): DomainEvent {
338
+ if (event.type === "OrderCreated" && event.version === 1) {
339
+ // v1 → v2 migration; produce a new DomainEvent
340
+ return { ...event, version: 2, payload: { ...event.payload, currency: "EUR" } };
341
+ }
342
+ return event;
343
+ }
344
+
345
+ const history = await eventStore.read(aggregateId);
346
+ const upcasted = history.map(upcast);
347
+ aggregate.loadFromHistory(upcasted);
348
+ ```
349
+
350
+ The library deliberately ships no `EventUpcaster` port. Real upcasting strategies vary too much (chained vs schema-registry vs lazy) to commit to one shape pre-1.0 without concrete usage data.
351
+
316
352
  ### Creating an Aggregate WITH Event Sourcing
317
353
 
318
354
  ```typescript
319
355
  import {
320
- AggregateEventSourced,
356
+ EventSourcedAggregate,
321
357
  createDomainEvent,
322
358
  type AggregateRoot,
323
359
  type Id,
@@ -339,7 +375,7 @@ type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
339
375
 
340
376
  type OrderEvent = OrderCreated | OrderConfirmed | OrderShipped;
341
377
 
342
- class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
378
+ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
343
379
  static create(id: OrderId, customerId: string): Order {
344
380
  const initialState: OrderState = {
345
381
  id,
@@ -355,28 +391,22 @@ class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> imple
355
391
  }
356
392
 
357
393
  confirm(): void {
358
- const result = this.apply(
359
- createDomainEvent("OrderConfirmed") as OrderConfirmed
360
- );
361
- if (!result.ok) {
362
- throw new Error(result.error);
363
- }
394
+ this.apply(createDomainEvent("OrderConfirmed") as OrderConfirmed);
364
395
  }
365
396
 
366
397
  ship(trackingNumber: string): void {
367
- const result = this.apply(
398
+ this.apply(
368
399
  createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
369
400
  );
370
- if (!result.ok) {
371
- throw new Error(result.error);
372
- }
373
401
  }
374
402
 
375
- // Or use unsafe variant (throws exception directly)
376
- confirmUnsafe(): void {
377
- this.applyUnsafe(
378
- createDomainEvent("OrderConfirmed") as OrderConfirmed
379
- );
403
+ // Override `validateEvent` to throw a DomainError subclass when an invariant
404
+ // is violated (e.g. confirming an already-confirmed order). `apply()` itself
405
+ // throws `MissingHandlerError` when no handler is registered for the event.
406
+ protected validateEvent(event: OrderEvent): void {
407
+ if (event.type === "OrderConfirmed" && this.state.status === "confirmed") {
408
+ throw new OrderAlreadyConfirmedError(this.id);
409
+ }
380
410
  }
381
411
 
382
412
  protected readonly handlers = {
@@ -417,8 +447,8 @@ console.log(order.version); // 3 (automatically bumped)
417
447
  ```typescript
418
448
  import {
419
449
  AggregateRoot,
420
- AggregateEventSourced,
421
- sameAggregate,
450
+ EventSourcedAggregate,
451
+ sameVersion,
422
452
  type Id,
423
453
  } from "@shirudo/ddd-kit";
424
454
 
@@ -441,11 +471,11 @@ const eventSourcedOrder = EventSourcedOrder.create("order-123" as OrderId, "cust
441
471
  const eventsAfterSnapshot = [/* events that occurred after snapshot */];
442
472
  eventSourcedOrder.restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot);
443
473
 
444
- // Aggregate equality check
474
+ // Optimistic concurrency check
445
475
  const order1 = await repository.getById(id);
446
476
  // ... some operations ...
447
477
  const order2 = await repository.getById(id);
448
- if (!sameAggregate(order1, order2)) {
478
+ if (!sameVersion(order1, order2)) {
449
479
  throw new Error("Aggregate was modified by another process");
450
480
  }
451
481
  ```
@@ -454,7 +484,7 @@ if (!sameAggregate(order1, order2)) {
454
484
 
455
485
  ```typescript
456
486
  import {
457
- AggregateEventSourced,
487
+ EventSourcedAggregate,
458
488
  createDomainEvent,
459
489
  err,
460
490
  ok,
@@ -469,7 +499,7 @@ type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
469
499
  type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
470
500
  type OrderEvent = OrderShipped;
471
501
 
472
- class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
502
+ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
473
503
  // Event validation
474
504
  protected validateEvent(event: OrderEvent): Result<true, string> {
475
505
  if (event.type === "OrderShipped" && this.state.status !== "confirmed") {
@@ -495,19 +525,24 @@ class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> imple
495
525
 
496
526
  ### Using CQRS: Commands and Queries
497
527
 
528
+ The library ships an in-memory `CommandBus` and `QueryBus`. These are zero-config in-process dispatchers — they fit:
529
+
530
+ - **Edge runtimes** (Cloudflare Workers, Vercel Edge, Deno Deploy, Bun): each worker invocation handles one command in-process; external brokers would defeat edge latency.
531
+ - **Modular monoliths**: a single Node process with several bounded contexts; the bus routes commands between modules. Domain events still leave the process via outbox/external bus when other services need them.
532
+ - **Tests and local development**: stand-in for production buses without infrastructure.
533
+ - **Small CLIs and scripts**: CQRS structure without infrastructure.
534
+
535
+ For **cross-process messaging** (RabbitMQ, NATS, Kafka, AWS SQS, etc.), don't use the in-memory bus — keep the `CommandHandler<C, R>` / `QueryHandler<Q, R>` types as the contract and wire them to your transport of choice. The handlers stay portable; only the dispatcher changes.
536
+
537
+ The included buses intentionally have no middleware/pipeline machinery — wrap handlers with decorator functions when you need logging, auth, metrics. Anything more elaborate is "in-house framework" territory and lives outside the kit.
538
+
498
539
  #### Commands (Write Operations)
499
540
 
500
541
  Commands represent write operations that change system state. They return `Result` for explicit error handling.
501
542
 
502
543
  ```typescript
503
- import {
504
- Command,
505
- CommandHandler,
506
- CommandBus,
507
- ok,
508
- err,
509
- type Result,
510
- } from "@shirudo/ddd-kit";
544
+ import { Command, CommandHandler, CommandBus } from "@shirudo/ddd-kit";
545
+ import { ok, err, type Result } from "@shirudo/result";
511
546
 
512
547
  // Define a command
513
548
  type CreateOrderCommand = Command & {
@@ -546,7 +581,7 @@ const result = await createOrderHandler({
546
581
  items: [{ productId: "product-1", quantity: 2, price: 10.0 }],
547
582
  });
548
583
 
549
- if (result.ok) {
584
+ if (result.isOk()) {
550
585
  console.log("Order created:", result.value);
551
586
  } else {
552
587
  console.error("Error:", result.error);
@@ -605,7 +640,7 @@ const result = await queryBus.execute({
605
640
  orderId: "order-123",
606
641
  });
607
642
 
608
- if (result.ok) {
643
+ if (result.isOk()) {
609
644
  const orderFromBus = result.value;
610
645
  // Use orderFromBus...
611
646
  } else {
@@ -719,7 +754,7 @@ channel.consume("order.commands", async (message) => {
719
754
  const command = JSON.parse(message.content.toString()) as CreateOrderCommand;
720
755
  const result = await createOrderHandler(command);
721
756
 
722
- if (result.ok) {
757
+ if (result.isOk()) {
723
758
  channel.ack(message);
724
759
  } else {
725
760
  channel.nack(message, false, true); // Requeue on error
@@ -892,49 +927,42 @@ class Order extends AggregateRoot<OrderState, OrderId>
892
927
  price,
893
928
  };
894
929
 
895
- this._state = {
896
- ...this._state,
897
- items: [...this._state.items, item],
898
- total: this._state.total + price * quantity,
899
- };
900
- this.bumpVersion(); // Versions the entire aggregate (including child entities)
930
+ this.setState({
931
+ ...this.state,
932
+ items: [...this.state.items, item],
933
+ total: this.state.total + price * quantity,
934
+ }, true); // true = bump version (versions the entire aggregate including child entities)
901
935
  return itemId;
902
936
  }
903
937
 
904
938
  updateItemQuantity(itemId: ItemId, newQuantity: number): void {
905
- const item = findEntityById(this._state.items, itemId);
939
+ const item = findEntityById(this.state.items, itemId);
906
940
  if (!item) {
907
941
  throw new Error("Item not found");
908
942
  }
909
943
 
910
- this._state = {
911
- ...this._state,
912
- items: updateEntityById(
913
- this._state.items,
914
- itemId,
915
- (i) => ({ ...i, quantity: newQuantity })
916
- ),
917
- total: this._state.total - item.price * item.quantity + item.price * newQuantity,
918
- };
919
- this.bumpVersion(); // Versions the entire aggregate
944
+ this.setState({
945
+ ...this.state,
946
+ items: updateEntityById(this.state.items, itemId, (i) => ({ ...i, quantity: newQuantity })),
947
+ total: this.state.total - item.price * item.quantity + item.price * newQuantity,
948
+ }, true);
920
949
  }
921
950
 
922
951
  removeItem(itemId: ItemId): void {
923
- const item = findEntityById(this._state.items, itemId);
952
+ const item = findEntityById(this.state.items, itemId);
924
953
  if (!item) {
925
954
  throw new Error("Item not found");
926
955
  }
927
956
 
928
- this._state = {
929
- ...this._state,
930
- items: removeEntityById(this._state.items, itemId),
931
- total: this._state.total - item.price * item.quantity,
932
- };
933
- this.bumpVersion(); // Versions the entire aggregate
957
+ this.setState({
958
+ ...this.state,
959
+ items: removeEntityById(this.state.items, itemId),
960
+ total: this.state.total - item.price * item.quantity,
961
+ }, true);
934
962
  }
935
963
 
936
964
  getItem(itemId: ItemId): OrderItem | undefined {
937
- return findEntityById(this._state.items, itemId);
965
+ return findEntityById(this.state.items, itemId);
938
966
  }
939
967
  }
940
968
 
@@ -983,15 +1011,15 @@ class OrderItem extends Entity<OrderItemState, ItemId> {
983
1011
  if (newQuantity <= 0) {
984
1012
  throw new Error("Quantity must be greater than 0");
985
1013
  }
986
- this._state = { ...this._state, quantity: newQuantity };
1014
+ this.setState({ ...this.state, quantity: newQuantity });
987
1015
  }
988
1016
 
989
1017
  calculateSubtotal(): number {
990
- return this._state.price * this._state.quantity;
1018
+ return this.state.price * this.state.quantity;
991
1019
  }
992
1020
 
993
1021
  isForProduct(productId: string): boolean {
994
- return this._state.productId === productId;
1022
+ return this.state.productId === productId;
995
1023
  }
996
1024
 
997
1025
  protected validateState(state: OrderItemState): void {
@@ -1028,17 +1056,16 @@ class Order extends AggregateRoot<OrderState, OrderId>
1028
1056
  const itemId = `item-${++this.itemCounter}` as ItemId;
1029
1057
  const item = new OrderItem(itemId, productId, quantity, price);
1030
1058
 
1031
- this._state = {
1032
- ...this._state,
1033
- items: [...this._state.items, item],
1034
- };
1035
- this.bumpVersion();
1059
+ this.setState({
1060
+ ...this.state,
1061
+ items: [...this.state.items, item],
1062
+ }, true);
1036
1063
  return itemId;
1037
1064
  }
1038
1065
 
1039
1066
  // Delegate to entity's business logic
1040
1067
  updateItemQuantity(itemId: ItemId, newQuantity: number): void {
1041
- const item = findEntityById(this._state.items, itemId);
1068
+ const item = findEntityById(this.state.items, itemId);
1042
1069
  if (!item) throw new Error("Item not found");
1043
1070
 
1044
1071
  item.updateQuantity(newQuantity); // Uses entity's logic
@@ -1047,18 +1074,17 @@ class Order extends AggregateRoot<OrderState, OrderId>
1047
1074
 
1048
1075
  // Use entity's business logic
1049
1076
  calculateTotal(): number {
1050
- return this._state.items.reduce(
1077
+ return this.state.items.reduce(
1051
1078
  (total, item) => total + item.calculateSubtotal(),
1052
1079
  0
1053
1080
  );
1054
1081
  }
1055
1082
 
1056
1083
  confirm(): void {
1057
- if (this._state.items.length === 0) {
1084
+ if (this.state.items.length === 0) {
1058
1085
  throw new Error("Cannot confirm an order without items");
1059
1086
  }
1060
- this._state = { ...this._state, status: "confirmed" };
1061
- this.bumpVersion();
1087
+ this.setState({ ...this.state, status: "confirmed" }, true);
1062
1088
  }
1063
1089
  }
1064
1090
 
@@ -1077,26 +1103,7 @@ The `Result<T, E>` type provides composition utilities to avoid repetitive `if (
1077
1103
  **Import Result utilities from the dedicated export path:**
1078
1104
 
1079
1105
  ```typescript
1080
- import {
1081
- ok,
1082
- err,
1083
- isOk,
1084
- isErr,
1085
- andThen,
1086
- map,
1087
- mapErr,
1088
- unwrapOr,
1089
- unwrapOrElse,
1090
- match,
1091
- matchAsync,
1092
- pipe,
1093
- tryCatch,
1094
- tryCatchAsync,
1095
- type Result,
1096
- Outcome,
1097
- Success,
1098
- Erroneous
1099
- } from "@shirudo/ddd-kit/result";
1106
+ import { ok, err, type Result } from "@shirudo/result";
1100
1107
 
1101
1108
  type UserId = string;
1102
1109
 
@@ -1104,107 +1111,41 @@ function validateUserId(id: string): Result<UserId, string> {
1104
1111
  return id.length > 0 ? ok(id as UserId) : err("User ID cannot be empty");
1105
1112
  }
1106
1113
 
1107
- function validateEmail(email: string): Result<string, string> {
1108
- return email.includes("@") ? ok(email) : err("Invalid email");
1109
- }
1110
-
1111
- // Chaining operations with andThen (avoids if-checks)
1112
- function createUser(id: string, email: string): Result<{ id: UserId; email: string }, string> {
1113
- return andThen(validateUserId(id), (userId) =>
1114
- map(validateEmail(email), (email) => ({
1115
- id: userId,
1116
- email,
1117
- }))
1118
- );
1119
- }
1120
-
1121
- // Using map for transformations
1122
- const result = ok(5);
1123
- const doubled = map(result, x => x * 2); // Ok<10>
1124
-
1125
- // Using mapErr to transform errors
1126
- const errorResult = err("not found");
1127
- const mappedError = mapErr(errorResult, e => `Error: ${e}`); // Err<"Error: not found">
1128
-
1129
- // Using unwrapOr for defaults
1130
- const userId = unwrapOr(validateUserId(""), "default-id");
1131
-
1132
- // Using unwrapOrElse for computed defaults
1133
- const userId2 = unwrapOrElse(validateUserId(""), err => `fallback-${Date.now()}`);
1134
-
1135
- // Using match for pattern matching
1136
- const message = match(createUser("user-123", "test@example.com"),
1137
- user => `User created: ${user.id}`,
1138
- error => `Error: ${error}`
1139
- );
1140
-
1141
- // Usage with type guards (still works)
1142
- const result2 = createUser("user-123", "test@example.com");
1143
- if (isOk(result2)) {
1144
- console.log("User created:", result2.value);
1114
+ const result = validateUserId("user-123");
1115
+ if (result.isOk()) {
1116
+ console.log("Valid:", result.value);
1145
1117
  } else {
1146
- console.error("Error:", result2.error);
1118
+ console.error("Invalid:", result.error);
1147
1119
  }
1120
+ ```
1148
1121
 
1149
- // Using tryCatch to wrap functions that throw exceptions
1150
- function riskyOperation(): string {
1151
- if (Math.random() > 0.5) {
1152
- throw new Error("Something went wrong");
1153
- }
1154
- return "success";
1155
- }
1122
+ `ddd-kit` declares `@shirudo/result` as a `peerDependency`, so install it once in your app:
1156
1123
 
1157
- const result3 = tryCatch(() => riskyOperation());
1158
- if (result3.ok) {
1159
- console.log(result3.value); // "success"
1160
- } else {
1161
- console.error(result3.error.message); // "Something went wrong"
1162
- }
1124
+ ```bash
1125
+ pnpm add @shirudo/result
1126
+ ```
1163
1127
 
1164
- // Using tryCatchAsync for async operations
1165
- async function riskyAsyncOperation(): Promise<string> {
1166
- if (Math.random() > 0.5) {
1167
- throw new Error("Async error");
1168
- }
1169
- return "async success";
1170
- }
1128
+ For composition utilities (`map`, `flatMap`, `mapErr`, `match`, `unwrapOr`, `pipe`, `tryCatch`, async variants, etc.), refer to the [`@shirudo/result` documentation](https://www.npmjs.com/package/@shirudo/result).
1171
1129
 
1172
- const result4 = await tryCatchAsync(() => riskyAsyncOperation());
1173
- match(result4,
1174
- (value) => console.log("Success:", value),
1175
- (error) => console.error("Error:", error.message)
1176
- );
1177
- ```
1130
+ **Where `ddd-kit` uses `Result` vs `throw`:**
1178
1131
 
1179
- **Available Composition Utilities:**
1180
- - `andThen<T, E, U>(result, fn)` - Chains Result operations (flatMap/bind). If Ok, applies function; if Err, returns error unchanged.
1181
- - `map<T, E, U>(result, fn)` - Transforms Ok value. If Err, returns error unchanged.
1182
- - `mapErr<T, E, F>(result, fn)` - Transforms Err value. If Ok, returns value unchanged.
1183
- - `unwrapOr<T, E>(result, defaultValue)` - Returns value if Ok, otherwise returns default.
1184
- - `unwrapOrElse<T, E>(result, fn)` - Returns value if Ok, otherwise computes default from error.
1185
- - `match<T, E, R>(result, onOk, onErr)` - Pattern matching. Applies one function if Ok, another if Err. Supports both function and object syntax.
1186
- - `matchAsync<T, E, R>(result, onOk, onErr)` - Asynchronous pattern matching. Applies async functions for Ok/Err cases. Supports both function and object syntax.
1187
- - `pipe<T, E>(initial, ...fns)` - Pipes a Result through multiple operations. Stops on first error. Cleaner alternative to nested `andThen` calls.
1188
- - `tryCatch<T, E>(fn, errorMapper?)` - Wraps a function that may throw exceptions into a Result type. Catches exceptions and converts them to Err results.
1189
- - `tryCatchAsync<T, E>(fn, errorMapper?)` - Wraps an async function that may throw exceptions into a Promise<Result>. Catches exceptions and Promise rejections.
1190
-
1191
- **Class-based API (for method chaining):**
1192
- - `Outcome<T, E>` - Wrapper class for Result with method chaining support
1193
- - `Success<T>` - Class representing successful results (created via `Ok()` factory)
1194
- - `Erroneous<E>` - Class representing error results (created via `Err()` factory)
1132
+ - **Domain layer throws** `DomainError`-derived exceptions. `EventSourcedAggregate.apply()`, the `validateEvent()` hook, and the `ValueObject` constructor all throw. Subclass `DomainError` for your aggregate-specific errors (e.g. `OrderAlreadyShippedError`) and catch via `instanceof`.
1133
+ - **Infrastructure boundary returns `Result`** where corruption is an expected recoverable failure: `EventSourcedAggregate.loadFromHistory()`, `restoreFromSnapshotWithEvents()`.
1134
+ - **App-Service boundary returns `Result`**: `CommandBus.execute()`, `QueryBus.execute()`, `CommandHandler<C,R>`, `QueryHandler<Q,R>`, `withCommit()`. This is where you map errors to HTTP statuses, logs, etc.
1135
+ - **`voWithValidation`** is the explicit Result variant for parsing untrusted input at the App boundary. For Domain construction, use the `ValueObject` base class (constructor throws via `validate()`).
1195
1136
 
1196
1137
  ## API Documentation
1197
1138
 
1198
1139
  This package is written in TypeScript and provides full type definitions. All types and functions are exported from the main entry point. You can explore the available APIs through your IDE's autocomplete or by examining the type definitions in `node_modules/@shirudo/ddd-kit/dist/index.d.ts`.
1199
1140
 
1200
1141
  Key exports include:
1201
- - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()`, `voWithValidationUnsafe()` - Value Object utilities
1142
+ - `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()`)
1202
1143
  - `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
1203
1144
  - `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
1204
- - `AggregateEventSourced<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Root Entities (extends `AggregateRoot`, implements `IAggregateEventSourced<TId, TEvent>`)
1205
- - `AggregateConfig`, `AggregateEventSourcedConfig` - Configuration interfaces
1145
+ - `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
1146
+ - `AggregateConfig`, `EventSourcedAggregateConfig` - Configuration interfaces
1206
1147
  - `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
1207
- - `sameAggregate()` - Aggregate equality helper
1148
+ - `sameVersion()` - Optimistic concurrency check (same ID and version)
1208
1149
  - `Entity<TState, TId>` - Base class for entities with state and business logic
1209
1150
  - `IEntity<TId, TState>` - Entity interface
1210
1151
  - `Identifiable<TId>` - Minimal interface for objects with id
@@ -1224,14 +1165,12 @@ Key exports include:
1224
1165
  - `EventBus.subscribe()` - Subscribe handlers to event types
1225
1166
  - `EventBus.publish()` - Publish events to all subscribers (uses `Promise.allSettled` — all handlers run even if one fails)
1226
1167
  - `EventBus.once()` - Wait for the next event of a given type (returns Promise, auto-unsubscribes)
1227
- - `Result<T, E>`, `ok()`, `err()`, `isOk()`, `isErr()` - Result type and type guards
1228
- - `andThen()`, `map()`, `mapErr()` - Result composition utilities
1229
- - `unwrapOr()`, `unwrapOrElse()`, `match()` - Result unwrapping and pattern matching
1230
- - `Id<Tag>` - Branded ID type
1231
- - `IRepository<TState, TEvent, TAgg, TId>` - Repository interface
1232
- - `ISpecification<T>` - Specification interface
1233
- - `UnitOfWork` - Unit of Work interface
1234
- - `guard()` - Guard/validation helper
1168
+ - `Id<Tag>` - Branded ID type (Result type and operators come from the `@shirudo/result` peer dep)
1169
+ - `IRepository<TAgg, TId>` - Repository interface
1170
+ - `IQueryableRepository<TAgg, TId, TFilter>` - Repository extension that adds filter-based querying. `TFilter` is the persistence layer's native filter shape (Drizzle SQL, Prisma WhereInput, Mongo filter doc, in-memory predicate, …). Repositories that are only accessed by id should implement `IRepository` directly and skip this extension.
1171
+ - `TransactionScope` - Transaction-scope abstraction (wraps a block of work in the persistence layer's native transaction). Intentionally minimal — not Fowler's full UoW with change tracking.
1172
+ - `DomainError` - Abstract base for domain exceptions (Consumer subclasses for their aggregate-specific errors)
1173
+ - `MissingHandlerError`, `AggregateNotFoundError` - Concrete library-internal `DomainError` subclasses
1235
1174
 
1236
1175
  ## Concurrency & Thread Safety
1237
1176
 
@@ -1349,7 +1288,7 @@ class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId>
1349
1288
 
1350
1289
  // 3. Save
1351
1290
  await this.repository.save(order);
1352
- await this.eventBus.publish(order.pendingEvents);
1291
+ await this.eventBus.publish(order.domainEvents);
1353
1292
 
1354
1293
  return ok(order.id);
1355
1294
  // 4. Aggregate is garbage collected when method returns
@@ -1556,7 +1495,7 @@ class OrderService {
1556
1495
  }
1557
1496
 
1558
1497
  await this.repository.save(order);
1559
- await this.eventBus.publish(order.pendingEvents);
1498
+ await this.eventBus.publish(order.domainEvents);
1560
1499
 
1561
1500
  return ok(order.id);
1562
1501
  // order is garbage collected here