@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 +157 -218
- package/dist/index.d.ts +1141 -513
- package/dist/index.js +1144 -1
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +92 -1
- package/dist/utils.js +282 -0
- package/dist/utils.js.map +1 -1
- package/package.json +69 -65
- package/dist/deep-equal-except-C8yoSk4L.d.ts +0 -57
- package/dist/result-jCwPSjFa.d.ts +0 -352
- package/dist/result.d.ts +0 -204
- package/dist/result.js +0 -2
- package/dist/result.js.map +0 -1
- package/dist/utils-array.d.ts +0 -47
- package/dist/utils-array.js +0 -2
- package/dist/utils-array.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Composable TypeScript toolkit for tactical Domain-Driven Design.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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),
|
|
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 `
|
|
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
|
-
- **`
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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.
|
|
255
|
-
...this.
|
|
256
|
-
items: [...this.
|
|
257
|
-
total: this.
|
|
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.
|
|
261
|
+
if (this.state.status !== "pending") {
|
|
264
262
|
throw new Error("Only pending orders can be confirmed");
|
|
265
263
|
}
|
|
266
|
-
this.
|
|
267
|
-
this.bumpVersion();
|
|
264
|
+
this.setState({ ...this.state, status: "confirmed" }, true);
|
|
268
265
|
}
|
|
269
266
|
|
|
270
267
|
ship(): void {
|
|
271
|
-
if (this.
|
|
268
|
+
if (this.state.status !== "confirmed") {
|
|
272
269
|
throw new Error("Only confirmed orders can be shipped");
|
|
273
270
|
}
|
|
274
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
//
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
896
|
-
...this.
|
|
897
|
-
items: [...this.
|
|
898
|
-
total: this.
|
|
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.
|
|
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.
|
|
911
|
-
...this.
|
|
912
|
-
items: updateEntityById(
|
|
913
|
-
|
|
914
|
-
|
|
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.
|
|
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.
|
|
929
|
-
...this.
|
|
930
|
-
items: removeEntityById(this.
|
|
931
|
-
total: this.
|
|
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.
|
|
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.
|
|
1014
|
+
this.setState({ ...this.state, quantity: newQuantity });
|
|
987
1015
|
}
|
|
988
1016
|
|
|
989
1017
|
calculateSubtotal(): number {
|
|
990
|
-
return this.
|
|
1018
|
+
return this.state.price * this.state.quantity;
|
|
991
1019
|
}
|
|
992
1020
|
|
|
993
1021
|
isForProduct(productId: string): boolean {
|
|
994
|
-
return this.
|
|
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.
|
|
1032
|
-
...this.
|
|
1033
|
-
items: [...this.
|
|
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.
|
|
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.
|
|
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.
|
|
1084
|
+
if (this.state.items.length === 0) {
|
|
1058
1085
|
throw new Error("Cannot confirm an order without items");
|
|
1059
1086
|
}
|
|
1060
|
-
this.
|
|
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
|
-
|
|
1108
|
-
|
|
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("
|
|
1118
|
+
console.error("Invalid:", result.error);
|
|
1147
1119
|
}
|
|
1120
|
+
```
|
|
1148
1121
|
|
|
1149
|
-
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
} else {
|
|
1161
|
-
console.error(result3.error.message); // "Something went wrong"
|
|
1162
|
-
}
|
|
1124
|
+
```bash
|
|
1125
|
+
pnpm add @shirudo/result
|
|
1126
|
+
```
|
|
1163
1127
|
|
|
1164
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
1180
|
-
-
|
|
1181
|
-
- `
|
|
1182
|
-
-
|
|
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()
|
|
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
|
-
- `
|
|
1205
|
-
- `AggregateConfig`, `
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
1228
|
-
- `
|
|
1229
|
-
- `
|
|
1230
|
-
- `
|
|
1231
|
-
- `
|
|
1232
|
-
- `
|
|
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.
|
|
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.
|
|
1498
|
+
await this.eventBus.publish(order.domainEvents);
|
|
1560
1499
|
|
|
1561
1500
|
return ok(order.id);
|
|
1562
1501
|
// order is garbage collected here
|