@shirudo/ddd-kit 1.0.0 → 1.0.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.
Files changed (2) hide show
  1. package/README.md +43 -1533
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -1,1578 +1,88 @@
1
1
  # @shirudo/ddd-kit
2
2
 
3
- Composable TypeScript toolkit for tactical Domain-Driven Design.
3
+ Composable TypeScript toolkit for tactical Domain-Driven Design. Ships the canonical building blocks — Value Objects, Entities, Aggregate Roots, Domain Events, Repositories, and CQRS handlers — without a framework or runtime lock-in. ESM-only; runs on Node 18+, Cloudflare Workers, Vercel Edge, Deno, and Bun.
4
4
 
5
5
  > **Stable — 1.0**
6
6
  >
7
- > The public API is stable and follows [Semantic Versioning](https://semver.org/). Breaking changes bump the major and ship with a migration path in the [CHANGELOG](CHANGELOG.md).
8
-
9
- ## Badges
7
+ > The public API is stable and follows [Semantic Versioning](https://semver.org/). Breaking changes bump the major and ship with a migration path in the [CHANGELOG](https://github.com/shi-rudo/ddd-kit-ts/blob/main/CHANGELOG.md).
10
8
 
11
9
  ![npm version](https://img.shields.io/npm/v/@shirudo/ddd-kit)
12
10
  ![license](https://img.shields.io/npm/l/@shirudo/ddd-kit)
13
11
 
14
12
  ## Features
15
13
 
16
- - **Value Objects** - Immutable objects defined by their attributes, ensuring data integrity
17
- - **Entities** - Optional interface and helpers for entities with identity, useful for nested entities within aggregates
18
- - **Aggregates** - Event-sourced aggregates with versioning for optimistic concurrency control
19
- - **Domain Events** - Type-safe domain events with versioning and metadata for schema evolution and traceability
20
- - **Repositories** - Persistence abstraction layer for aggregates with specification pattern support
21
- - **Specifications** - Reusable query specifications for complex domain queries
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. For advanced error handling with typed error hierarchies, see [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error)
14
+ - **Value Objects** — deep-frozen, by-attribute equality (`vo`, `ValueObject`, `voEquals`).
15
+ - **Entities** identity + lifecycle, with collection helpers branded by `Id<Tag>`.
16
+ - **Aggregate Roots** — state-stored (`AggregateRoot`) and event-sourced (`EventSourcedAggregate`), with optimistic-concurrency versioning.
17
+ - **Domain Events** typed, deeply frozen, carry metadata for traceability and schema evolution.
18
+ - **Repositories** — technology-agnostic persistence ports with an Identity-Map contract and OCC.
19
+ - **CQRS** — zero-config in-memory `CommandBus` / `QueryBus`, plus `CommandHandler` / `QueryHandler` types for external brokers.
20
+ - **Outbox & unit of work** `withCommit` harvests pending events inside the transaction and publishes them atomically.
21
+ - **Result-first boundary** a typed error hierarchy on [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error) and `Result` from [`@shirudo/result`](https://www.npmjs.com/package/@shirudo/result); `voValidated` collects field violations and renders RFC 9457 via the opt-in `@shirudo/ddd-kit/http` entry.
24
22
 
25
23
  ## Installation
26
24
 
27
- Install the package using npm:
28
-
29
- ```bash
30
- npm install @shirudo/ddd-kit
31
- ```
32
-
33
- Or using pnpm:
34
-
35
25
  ```bash
36
- pnpm add @shirudo/ddd-kit
26
+ pnpm add @shirudo/ddd-kit @shirudo/result @shirudo/base-error
37
27
  ```
38
28
 
39
- ## Quick Start
29
+ `@shirudo/result` and `@shirudo/base-error` are peer dependencies — install them once in the consuming app.
40
30
 
41
- Here's a minimal example showing how to create and use a Value Object:
31
+ ## Quick start
42
32
 
43
33
  ```typescript
44
34
  import { vo, type VO } from "@shirudo/ddd-kit";
45
35
 
46
- type EmailAddress = VO<{
47
- value: string;
48
- }>;
36
+ type EmailAddress = VO<{ value: string }>;
49
37
 
50
38
  function createEmail(value: string): EmailAddress {
51
- if (!value.includes("@")) {
52
- throw new Error("Invalid email address");
53
- }
54
- return vo({ value });
39
+ if (!value.includes("@")) throw new Error("Invalid email address");
40
+ return vo({ value }); // deeply frozen, immutable
55
41
  }
56
42
 
57
43
  const email = createEmail("user@example.com");
58
- // email.value is readonly and immutable
59
- ```
60
-
61
- ## Core Concepts
62
-
63
- ### Value Objects
64
-
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 at the App-Service boundary (returns Result), and `voValidated()` when you need to collect *every* invalid field into one `ValidationError` instead of failing on the first. For Domain construction, prefer the `ValueObject` base class — its constructor throws on invariant violation via the `validate()` hook.
66
-
67
- ### Entities
68
-
69
- In Domain-Driven Design, Entities are objects with identity and state. Unlike Value Objects (compared by value), Entities are compared by identity (id). There are two types of entities:
70
-
71
- 1. **Aggregate Root Entity**: The parent Entity of an aggregate.
72
- - Has identity (id), state, and version for optimistic concurrency control
73
- - Represents the aggregate externally
74
- - Loaded/saved through repositories
75
- - Created by extending `AggregateRoot` (state-based) or `EventSourcedAggregate` (event-sourced)
76
- - Implements `IAggregateRoot<TId>`
77
-
78
- 2. **Child Entities**: Entities within an aggregate.
79
- - Have identity (id) and state, but no own version
80
- - Can have business logic (methods) specific to the entity
81
- - Exist only within the aggregate boundary
82
- - Versioned through the Aggregate Root
83
- - Cannot be referenced directly from outside the aggregate
84
- - **Two approaches**:
85
- - **Class-based** (recommended for entities with logic): Extend `Entity<TState, TId>`
86
- - **Functional-style** (for simple data): Use `Identifiable<TId> & TProps`
87
-
88
- The library provides:
89
- - **`Entity<TState, TId>`** - Base class for entities with state and business logic
90
- - **`Entity<TId>`** - Simple class for entities without state management
91
- - **`Identifiable<TId>`** - Minimal interface for objects with id
92
- - Helper functions like `sameEntity()`, `findEntityById()`, `hasEntityId()`, `updateEntityById()`, and `removeEntityById()` for working with entity collections
93
-
94
- ### Aggregates
95
-
96
- Aggregates are clusters of entities and value objects that form a consistency boundary. An aggregate consists of:
97
-
98
- - **One Aggregate Root** (Entity with id + version)
99
- - **Optional child entities** (Entities with id, but no own version)
100
- - **Optional value objects** (immutable objects)
101
-
102
- The Aggregate Root is an Entity (the parent Entity of the aggregate) that represents the aggregate externally. All changes to child entities are versioned through the Aggregate Root. The version applies to the entire aggregate, including all child entities.
103
-
104
- The library provides:
105
-
106
- - **`IAggregateRoot<TId, TEvent?>`** - Interface for Aggregate Root Entities. The Aggregate Root is an Entity with identity (id), version for optimistic concurrency control, and a `pendingEvents` list of domain events recorded but not yet flushed. Both aggregate flavours (state-stored and event-sourced) expose `pendingEvents` under the same name, so a generic Repository.save() can harvest them uniformly.
107
-
108
- - **`AggregateRoot<TState, TId, TEvent?>`** - Base class for creating Aggregate Root Entities without Event Sourcing. Implements `IAggregateRoot<TId, TEvent>`. The optional `TEvent` parameter (defaults to `never`) enables type-safe domain events — only aggregates that specify it can record events at all. Provides ID and version management, state management, pending-event tracking, and snapshot support.
109
-
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
-
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
-
114
- ### CQRS (Command Query Responsibility Segregation)
115
-
116
- CQRS separates read operations (Queries) from write operations (Commands), providing clear patterns for handling different types of operations. Commands change system state and return `Result` for error handling, while Queries read data and return results directly. The library provides optional Command and Query Buses for centralized handler registration and execution.
117
-
118
- ### Domain Events
119
-
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
-
122
- ### Repositories
123
-
124
- Repositories abstract the persistence layer, allowing you to work with aggregates without dealing with database specifics. They support finding aggregates by ID, using specifications for complex queries, and saving/deleting aggregates while maintaining transactional boundaries.
125
-
126
- ### Specifications
127
-
128
- Specifications encapsulate business rules for queries in a reusable, composable way. They provide a domain-centric approach to querying that separates business logic from data access implementation details.
129
-
130
- ### Result Type
131
-
132
- The `Result<T, E>` type provides functional error handling without exceptions. It explicitly represents success (`Ok<T>`) or failure (`Err<E>`) states, making error handling predictable and type-safe throughout your domain logic.
133
-
134
- ## Usage Examples
135
-
136
- ### Creating a Value Object
137
-
138
- ```typescript
139
- import { vo, voEquals, voEqualsExcept, voWithValidation, voValidated, type VO } from "@shirudo/ddd-kit";
140
-
141
- // Simple value object (Functional Style)
142
- type Money = VO<{
143
- amount: number;
144
- currency: string;
145
- }>;
146
-
147
- const price = vo({ amount: 99.99, currency: "USD" });
148
- // price is deeply immutable - nested objects and arrays are also frozen
149
-
150
- // Class-based Value Object (OOP Style)
151
- import { ValueObject } from "@shirudo/ddd-kit";
152
-
153
- class Address extends ValueObject<{ street: string; city: string }> {
154
- constructor(props: { street: string; city: string }) {
155
- super(props);
156
- }
157
-
158
- get street(): string {
159
- return this.props.street;
160
- }
161
- }
162
-
163
- const address = new Address({ street: "Main St", city: "New York" });
164
- // address.props is immutable
165
-
166
- // Value object with validation (returns Result)
167
- const result = voWithValidation(
168
- { amount: 100, currency: "USD" },
169
- (m) => m.amount >= 0 && m.currency.length === 3,
170
- "Amount must be non-negative and currency must be 3 characters"
171
- );
172
-
173
- if (result.isOk()) {
174
- const validMoney = result.value;
175
- // Use validMoney...
176
- } else {
177
- console.error(result.error);
178
- }
179
-
180
- // For Domain construction, use the `ValueObject` base class — its constructor
181
- // throws via the `validate()` hook, so Domain code keeps a throw-based contract.
182
- // Reserve `voWithValidation` for parsing untrusted input at the App boundary.
183
-
184
- // To report every invalid field at once, use `voValidated` — it collects all
185
- // violations into one `ValidationError` (a Result-axis value, not a throw):
186
- const parsed = voValidated(
187
- { amount: -1, currency: "US" },
188
- (issues, m) => {
189
- if (m.amount < 0)
190
- issues.addIssue({ message: "must be non-negative", path: ["amount"] });
191
- if (m.currency.length !== 3)
192
- issues.addIssue({ message: "must be a 3-letter code", path: ["currency"] });
193
- }
194
- );
195
- // On failure: parsed.error.publicIssues() lists every violation.
196
- // Render RFC 9457 with `toProblemDetails` from `@shirudo/ddd-kit/http`.
197
-
198
- // Value object with nested structures (deep freeze)
199
- const address = vo({
200
- street: "Main St",
201
- city: "Berlin",
202
- coordinates: { lat: 52.5, lng: 13.4 }
203
- });
204
- // address.coordinates.lat = 99; // ❌ Error: Cannot assign to read-only property
205
-
206
- // Equality comparison
207
- const money1 = vo({ amount: 100, currency: "USD" });
208
- const money2 = vo({ amount: 100, currency: "USD" });
209
- voEquals(money1, money2); // true (value equality, not reference)
210
-
211
- // Equality comparison ignoring metadata
212
- const address1 = vo({
213
- street: "Main St",
214
- city: "Berlin",
215
- metadata: { updatedAt: "2024-01-02" }
216
- });
217
- const address2 = vo({
218
- street: "Main St",
219
- city: "Berlin",
220
- metadata: { updatedAt: "2024-01-03" }
221
- });
222
- voEquals(address1, address2); // false (different metadata)
223
- voEqualsExcept(address1, address2, {
224
- ignoreKeyPredicate: (key, path) => path.includes("metadata")
225
- }); // true (metadata ignored)
226
- ```
227
-
228
- ### Creating an Aggregate WITHOUT Event Sourcing
229
-
230
- ```typescript
231
- import {
232
- AggregateRoot,
233
- type IAggregateRoot,
234
- type Id,
235
- } from "@shirudo/ddd-kit";
236
-
237
- type OrderId = Id<"OrderId">;
238
-
239
- type OrderState = {
240
- id: OrderId;
241
- customerId: string;
242
- items: Array<{ productId: string; quantity: number; price: number }>;
243
- total: number;
244
- status: "pending" | "confirmed" | "shipped";
245
- };
246
-
247
- // Without typed events (TEvent defaults to unknown)
248
- class Order extends AggregateRoot<OrderState, OrderId> {
249
- static create(id: OrderId, customerId: string): Order {
250
- const initialState: OrderState = {
251
- id,
252
- customerId,
253
- items: [],
254
- total: 0,
255
- status: "pending",
256
- };
257
- return new Order(id, initialState);
258
- }
259
-
260
- addItem(productId: string, quantity: number, price: number): void {
261
- if (this.state.status !== "pending") {
262
- throw new Error("Cannot add items to a non-pending order");
263
- }
264
-
265
- this.setState({
266
- ...this.state,
267
- items: [...this.state.items, { productId, quantity, price }],
268
- total: this.state.total + quantity * price,
269
- }, true); // true = bump version for optimistic concurrency control
270
- }
271
-
272
- confirm(): void {
273
- if (this.state.status !== "pending") {
274
- throw new Error("Only pending orders can be confirmed");
275
- }
276
- this.setState({ ...this.state, status: "confirmed" }, true);
277
- }
278
-
279
- ship(): void {
280
- if (this.state.status !== "confirmed") {
281
- throw new Error("Only confirmed orders can be shipped");
282
- }
283
- this.setState({ ...this.state, status: "shipped" }, true);
284
- }
285
- }
286
-
287
- // Usage
288
- const order = Order.create("order-123" as OrderId, "customer-456");
289
- order.addItem("product-1", 2, 10.0);
290
- order.confirm();
291
- order.ship();
292
-
293
- console.log(order.version); // 3 (manually bumped)
294
- console.log(order.state.status); // "shipped"
295
- ```
296
-
297
- #### With Typed Domain Events
298
-
299
- Use the optional third type parameter to get compile-time event validation:
300
-
301
- ```typescript
302
- type OrderDomainEvent =
303
- | { type: "OrderConfirmed" }
304
- | { type: "OrderShipped"; trackingNumber: string };
305
-
306
- class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
307
- confirm(): void {
308
- this.setState({ ...this.state, status: "confirmed" }, true);
309
- this.addDomainEvent({ type: "OrderConfirmed" }); // type-safe
310
- }
311
-
312
- ship(trackingNumber: string): void {
313
- this.setState({ ...this.state, status: "shipped" }, true);
314
- this.addDomainEvent({ type: "OrderShipped", trackingNumber }); // type-safe
315
- }
316
- }
317
-
318
- // order.pendingEvents is ReadonlyArray<OrderDomainEvent> — no cast needed
319
- // order.addDomainEvent({ type: "WrongEvent" }) → compile error
320
- ```
321
-
322
- > **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.
323
- >
324
- > Two paths give you that ordering for free, so you don't have to remember the rule:
325
- >
326
- > - **`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.
327
- > - **`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:
328
- >
329
- > ```ts
330
- > confirm(): void {
331
- > if (this.state.status === "confirmed") throw new OrderAlreadyConfirmedError(this.id);
332
- > this.commit(
333
- > { ...this.state, status: "confirmed" },
334
- > { type: "OrderConfirmed", orderId: this.id },
335
- > );
336
- > }
337
- > ```
338
- >
339
- > `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).
340
-
341
- > **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.
342
-
343
- ### Event-Sourcing Schema Evolution (Upcasting)
344
-
345
- `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:
346
-
347
- ```ts
348
- // At the infrastructure boundary, before passing events to loadFromHistory:
349
- function upcast(event: PersistedEvent): DomainEvent {
350
- if (event.type === "OrderCreated" && event.version === 1) {
351
- // v1 → v2 migration; produce a new DomainEvent
352
- return { ...event, version: 2, payload: { ...event.payload, currency: "EUR" } };
353
- }
354
- return event;
355
- }
356
-
357
- const history = await eventStore.read(aggregateId);
358
- const upcasted = history.map(upcast);
359
- aggregate.loadFromHistory(upcasted);
360
- ```
361
-
362
- 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.
363
-
364
- ### Creating an Aggregate WITH Event Sourcing
365
-
366
- ```typescript
367
- import {
368
- EventSourcedAggregate,
369
- createDomainEvent,
370
- type AggregateRoot,
371
- type Id,
372
- type DomainEvent,
373
- } from "@shirudo/ddd-kit";
374
-
375
- type OrderId = Id<"OrderId">;
376
-
377
- type OrderState = {
378
- id: OrderId;
379
- customerId: string;
380
- items: string[];
381
- status: "pending" | "confirmed" | "shipped";
382
- };
383
-
384
- type OrderCreated = DomainEvent<"OrderCreated", { customerId: string }>;
385
- type OrderConfirmed = DomainEvent<"OrderConfirmed">;
386
- type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
387
-
388
- type OrderEvent = OrderCreated | OrderConfirmed | OrderShipped;
389
-
390
- class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
391
- static create(id: OrderId, customerId: string): Order {
392
- const initialState: OrderState = {
393
- id,
394
- customerId,
395
- items: [],
396
- status: "pending",
397
- };
398
- const order = new Order(id, initialState);
399
- order.apply(
400
- createDomainEvent("OrderCreated", { customerId }) as OrderCreated
401
- );
402
- return order;
403
- }
404
-
405
- confirm(): void {
406
- this.apply(createDomainEvent("OrderConfirmed") as OrderConfirmed);
407
- }
408
-
409
- ship(trackingNumber: string): void {
410
- this.apply(
411
- createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
412
- );
413
- }
414
-
415
- // Override `validateEvent` to throw a DomainError subclass when an invariant
416
- // is violated (e.g. confirming an already-confirmed order). `apply()` itself
417
- // throws `MissingHandlerError` when no handler is registered for the event.
418
- protected validateEvent(event: OrderEvent): void {
419
- if (event.type === "OrderConfirmed" && this.state.status === "confirmed") {
420
- throw new OrderAlreadyConfirmedError(this.id);
421
- }
422
- }
423
-
424
- protected readonly handlers = {
425
- OrderCreated: (state: OrderState, event: OrderCreated): OrderState => ({
426
- ...state,
427
- customerId: event.payload.customerId,
428
- status: "pending",
429
- }),
430
- OrderConfirmed: (state: OrderState): OrderState => ({
431
- ...state,
432
- status: "confirmed",
433
- }),
434
- OrderShipped: (state: OrderState, event: OrderShipped): OrderState => ({
435
- ...state,
436
- status: "shipped",
437
- }),
438
- };
439
- }
440
-
441
- // Usage
442
- const orderId = "order-123" as OrderId;
443
- const order = Order.create(orderId, "customer-456");
444
- order.confirm();
445
- order.ship("TRACK-789");
446
-
447
- // Access pending events
448
- console.log(order.pendingEvents); // Array of events not yet persisted
449
- console.log(order.pendingEvents.length); // 3
450
- console.log(order.pendingEvents.at(-1)?.type); // "OrderShipped"
451
- console.log(order.version); // 3 (automatically bumped)
452
- ```
453
-
454
- ### Aggregate Features: Snapshots and Configuration
455
-
456
- ```typescript
457
- import {
458
- AggregateRoot,
459
- EventSourcedAggregate,
460
- sameVersion,
461
- type Id,
462
- } from "@shirudo/ddd-kit";
463
-
464
- type OrderId = Id<"OrderId">;
465
- type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
466
-
467
- // Snapshots work with both aggregate types
468
- const order = Order.create("order-123" as OrderId, "customer-456");
469
- order.confirm();
470
-
471
- const snapshot = order.createSnapshot();
472
- // Save snapshot to database...
473
-
474
- // Later: restore from snapshot (without events)
475
- const restoredOrder = Order.create("order-123" as OrderId, "customer-456");
476
- restoredOrder.restoreFromSnapshot(snapshot);
477
-
478
- // For Event-Sourced aggregates: restore with events after snapshot
479
- const eventSourcedOrder = EventSourcedOrder.create("order-123" as OrderId, "customer-456");
480
- const eventsAfterSnapshot = [/* events that occurred after snapshot */];
481
- eventSourcedOrder.restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot);
482
-
483
- // Optimistic concurrency check
484
- const order1 = await repository.getById(id);
485
- // ... some operations ...
486
- const order2 = await repository.getById(id);
487
- if (!sameVersion(order1, order2)) {
488
- throw new Error("Aggregate was modified by another process");
489
- }
490
- ```
491
-
492
- ### Event Validation (Event-Sourced Aggregates Only)
493
-
494
- ```typescript
495
- import {
496
- EventSourcedAggregate,
497
- createDomainEvent,
498
- err,
499
- ok,
500
- type AggregateRoot,
501
- type Id,
502
- type DomainEvent,
503
- type Result,
504
- } from "@shirudo/ddd-kit";
505
-
506
- type OrderId = Id<"OrderId">;
507
- type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
508
- type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
509
- type OrderEvent = OrderShipped;
510
-
511
- class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
512
- // Event validation
513
- protected validateEvent(event: OrderEvent): Result<true, string> {
514
- if (event.type === "OrderShipped" && this.state.status !== "confirmed") {
515
- return err("Order must be confirmed before shipping");
516
- }
517
- return ok(true);
518
- }
519
-
520
- ship(trackingNumber: string): void {
521
- this.apply(
522
- createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
523
- );
524
- }
525
-
526
- protected readonly handlers = {
527
- OrderShipped: (state: OrderState, event: OrderShipped): OrderState => ({
528
- ...state,
529
- status: "shipped",
530
- }),
531
- };
532
- }
533
- ```
534
-
535
- ### Using CQRS: Commands and Queries
536
-
537
- The library ships an in-memory `CommandBus` and `QueryBus`. These are zero-config in-process dispatchers — they fit:
538
-
539
- - **Edge runtimes** (Cloudflare Workers, Vercel Edge, Deno Deploy, Bun): each worker invocation handles one command in-process; external brokers would defeat edge latency.
540
- - **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.
541
- - **Tests and local development**: stand-in for production buses without infrastructure.
542
- - **Small CLIs and scripts**: CQRS structure without infrastructure.
543
-
544
- 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.
545
-
546
- 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.
547
-
548
- #### Commands (Write Operations)
549
-
550
- Commands represent write operations that change system state. They return `Result` for explicit error handling.
551
-
552
- ```typescript
553
- import { Command, CommandHandler, CommandBus } from "@shirudo/ddd-kit";
554
- import { ok, err, type Result } from "@shirudo/result";
555
-
556
- // Define a command
557
- type CreateOrderCommand = Command & {
558
- type: "CreateOrder";
559
- customerId: string;
560
- items: Array<{ productId: string; quantity: number; price: number }>;
561
- };
562
-
563
- // Create a command handler
564
- const createOrderHandler: CommandHandler<CreateOrderCommand, string> = async (
565
- cmd
566
- ) => {
567
- // Validate input
568
- if (cmd.items.length === 0) {
569
- return err("Order must have at least one item");
570
- }
571
-
572
- // Perform business logic
573
- const orderId = `order-${Date.now()}` as OrderId;
574
- const order = Order.create(orderId, cmd.customerId);
575
-
576
- // Add items to the order
577
- for (const item of cmd.items) {
578
- order.addItem(item.productId, item.quantity, item.price);
579
- }
580
-
581
- await repository.save(order);
582
-
583
- return ok(order.id);
584
- };
585
-
586
- // Use directly
587
- const result = await createOrderHandler({
588
- type: "CreateOrder",
589
- customerId: "customer-123",
590
- items: [{ productId: "product-1", quantity: 2, price: 10.0 }],
591
- });
592
-
593
- if (result.isOk()) {
594
- console.log("Order created:", result.value);
595
- } else {
596
- console.error("Error:", result.error);
597
- }
598
-
599
- // Or use with Command Bus (basic in-memory implementation)
600
- // Note: For production, consider using external buses (RabbitMQ, AWS SQS) with typed handlers
601
- const commandBus = new CommandBus();
602
- commandBus.register("CreateOrder", createOrderHandler);
603
-
604
- const busResult = await commandBus.execute({
605
- type: "CreateOrder",
606
- customerId: "customer-123",
607
- items: [{ productId: "product-1", quantity: 2, price: 10.0 }],
608
- });
609
- ```
610
-
611
- #### Queries (Read Operations)
612
-
613
- Queries represent read operations that don't change system state. They return data directly.
614
-
615
- ```typescript
616
- import {
617
- Query,
618
- QueryHandler,
619
- QueryBus,
620
- } from "@shirudo/ddd-kit";
621
-
622
- // Define a query
623
- type GetOrderQuery = Query & {
624
- type: "GetOrder";
625
- orderId: string;
626
- };
627
-
628
- // Create a query handler
629
- const getOrderHandler: QueryHandler<GetOrderQuery, Order | null> = async (
630
- query
631
- ) => {
632
- return await repository.getById(query.orderId);
633
- };
634
-
635
- // Use directly
636
- const order = await getOrderHandler({
637
- type: "GetOrder",
638
- orderId: "order-123",
639
- });
640
-
641
- // Or use with Query Bus (basic in-memory implementation)
642
- // Note: For production, consider using external buses (RabbitMQ, AWS SQS) with typed handlers
643
- const queryBus = new QueryBus();
644
- queryBus.register("GetOrder", getOrderHandler);
645
-
646
- // Safe variant (returns Result)
647
- const result = await queryBus.execute({
648
- type: "GetOrder",
649
- orderId: "order-123",
650
- });
651
-
652
- if (result.isOk()) {
653
- const orderFromBus = result.value;
654
- // Use orderFromBus...
655
- } else {
656
- console.error(result.error);
657
- }
658
-
659
- // Or use unsafe variant (throws exception)
660
- const orderFromBusUnsafe = await queryBus.executeUnsafe({
661
- type: "GetOrder",
662
- orderId: "order-123",
663
- });
664
- ```
665
-
666
- #### Combining Commands with Transactions
667
-
668
- ```typescript
669
- import { withCommit } from "@shirudo/ddd-kit";
670
-
671
- const createOrderHandler: CommandHandler<CreateOrderCommand, string> = async (
672
- cmd
673
- ) => {
674
- return await withCommit(
675
- { outbox, bus, uow },
676
- async () => {
677
- const orderId = `order-${Date.now()}` as OrderId;
678
- const order = Order.create(orderId, cmd.customerId);
679
-
680
- // Add items to the order
681
- for (const item of cmd.items) {
682
- order.addItem(item.productId, item.quantity, item.price);
683
- }
684
-
685
- await repository.save(order);
686
-
687
- return {
688
- result: order.id,
689
- aggregates: [order],
690
- };
691
- }
692
- );
693
- };
694
- ```
695
-
696
- #### Using Commands/Queries with External Frameworks
697
-
698
- The `Command` and `Query` interfaces, along with `CommandHandler` and `QueryHandler` types, can be used as type markers even when using external frameworks like RabbitMQ, AWS SQS, or Kafka. This ensures type safety across different bus implementations.
699
-
700
- **Important:** The included `CommandBus` and `QueryBus` are basic in-memory implementations suitable for development and simple use cases. For production environments, use external production-grade message buses (RabbitMQ, AWS SQS, Kafka, etc.) with typed handlers to get features like:
701
- - Middleware/Pipeline support (logging, validation, authorization)
702
- - Error handling and retry logic
703
- - Timeout handling
704
- - Metrics and observability
705
- - Dead letter queues
706
- - Transaction management
707
-
708
- ```typescript
709
- import {
710
- Command,
711
- CommandHandler,
712
- Query,
713
- QueryHandler,
714
- ok,
715
- type Result,
716
- } from "@shirudo/ddd-kit";
717
-
718
- // Define commands/queries using marker interfaces
719
- type CreateOrderCommand = Command & {
720
- type: "CreateOrder";
721
- customerId: string;
722
- items: Array<{ productId: string; quantity: number; price: number }>;
723
- };
724
-
725
- type GetOrderQuery = Query & {
726
- type: "GetOrder";
727
- orderId: OrderId;
728
- };
729
-
730
- // Handler typed with CommandHandler for type safety
731
- const createOrderHandler: CommandHandler<CreateOrderCommand, OrderId> = async (
732
- cmd
733
- ) => {
734
- const orderId = `order-${Date.now()}` as OrderId;
735
- const order = Order.create(orderId, cmd.customerId);
736
-
737
- // Add items to the order
738
- for (const item of cmd.items) {
739
- order.addItem(item.productId, item.quantity, item.price);
740
- }
741
-
742
- await repository.save(order);
743
- return ok(order.id);
744
- };
745
-
746
- // Handler typed with QueryHandler for type safety
747
- const getOrderHandler: QueryHandler<GetOrderQuery, Order | null> = async (
748
- query
749
- ) => {
750
- return await repository.getById(query.orderId);
751
- };
752
-
753
- // Use with RabbitMQ (or any external framework)
754
- import amqp from "amqplib";
755
-
756
- const connection = await amqp.connect("amqp://localhost");
757
- const channel = await connection.createChannel();
758
-
759
- // Command handler for RabbitMQ
760
- channel.consume("order.commands", async (message) => {
761
- if (!message) return;
762
-
763
- const command = JSON.parse(message.content.toString()) as CreateOrderCommand;
764
- const result = await createOrderHandler(command);
765
-
766
- if (result.isOk()) {
767
- channel.ack(message);
768
- } else {
769
- channel.nack(message, false, true); // Requeue on error
770
- }
771
- });
772
-
773
- // Query handler for RabbitMQ
774
- channel.consume("order.queries", async (message) => {
775
- if (!message) return;
776
-
777
- const query = JSON.parse(message.content.toString()) as GetOrderQuery;
778
- const result = await getOrderHandler(query);
779
-
780
- channel.sendToQueue(
781
- message.properties.replyTo,
782
- Buffer.from(JSON.stringify(result)),
783
- { correlationId: message.properties.correlationId }
784
- );
785
- channel.ack(message);
786
- });
787
-
788
- // Same handlers work with AWS SQS, Kafka, etc.
789
- ```
790
-
791
- ### Using Event Bus for Event Handling
792
-
793
- The Event Bus provides a pub/sub pattern for handling domain events. Multiple handlers can subscribe to the same event type.
794
-
795
- ```typescript
796
- import {
797
- EventBusImpl,
798
- createDomainEvent,
799
- type DomainEvent,
800
- } from "@shirudo/ddd-kit";
801
-
802
- type OrderCreated = DomainEvent<"OrderCreated", { orderId: string; customerId: string }>;
803
- type OrderEvent = OrderCreated;
804
-
805
- // Create event bus
806
- const eventBus = new EventBusImpl<OrderEvent>();
807
-
808
- // Subscribe handlers to events
809
- eventBus.subscribe("OrderCreated", async (event) => {
810
- await sendEmail(event.payload.customerId);
811
- });
812
-
813
- eventBus.subscribe("OrderCreated", async (event) => {
814
- await logEvent(event);
815
- });
816
-
817
- // Unsubscribe if needed
818
- const unsubscribe = eventBus.subscribe("OrderCreated", async (event) => {
819
- console.log("Order created:", event.payload.orderId);
820
- });
821
- // Later: unsubscribe();
822
-
823
- // Publish events (all subscribed handlers will be called)
824
- const orderCreated = createDomainEvent("OrderCreated", {
825
- orderId: "order-123",
826
- customerId: "customer-456",
827
- }) as OrderCreated;
828
-
829
- await eventBus.publish([orderCreated]);
830
- // Both email and logging handlers will be called
831
-
832
- // Wait for the next event of a given type (useful for tests and workflows)
833
- const event = await eventBus.once<OrderCreated>("OrderCreated");
834
- console.log("Order created:", event.payload.orderId);
835
- // Automatically unsubscribes after the first event
836
- ```
837
-
838
- ### Creating Events with Metadata for Traceability
839
-
840
- ```typescript
841
- import {
842
- createDomainEventWithMetadata,
843
- copyMetadata,
844
- type EventMetadata,
845
- } from "@shirudo/ddd-kit";
846
-
847
- // Create event with metadata for distributed tracing
848
- const orderCreated = createDomainEventWithMetadata(
849
- "OrderCreated",
850
- { orderId: "123", customerId: "cust-456" },
851
- {
852
- correlationId: "corr-123", // Trace across services
853
- causationId: "cmd-456", // Parent command/event
854
- userId: "user-789", // Who triggered it
855
- source: "order-service", // Service name
856
- }
857
- );
858
-
859
- // Create follow-up event maintaining correlation chain
860
- const orderShipped = createDomainEventWithMetadata(
861
- "OrderShipped",
862
- { orderId: "123", trackingNumber: "TRACK-789" },
863
- copyMetadata(orderCreated, {
864
- causationId: orderCreated.type, // New causation
865
- })
866
- );
867
-
868
- // Events support versioning for schema evolution
869
- const eventV1 = createDomainEvent("OrderCreated", { orderId: "123" }, {
870
- version: 1,
871
- });
872
-
873
- const eventV2 = createDomainEvent(
874
- "OrderCreated",
875
- { orderId: "123", customerId: "cust-456" }, // Additional field
876
- { version: 2 }
877
- );
878
- ```
879
-
880
- ### Working with Child Entities
881
-
882
- An Aggregate Root Entity can contain multiple child entities. Child entities have identity (id) and state, but no own version - they are versioned through the Aggregate Root.
883
-
884
- #### Approach 1: Functional-Style Child Entities (Simple Data)
885
-
886
- For simple child entities without business logic, use the functional approach with intersection types:
887
-
888
- ```typescript
889
- import {
890
- AggregateRoot,
891
- Identifiable,
892
- findEntityById,
893
- updateEntityById,
894
- type IAggregateRoot,
895
- type Id,
896
- } from "@shirudo/ddd-kit";
897
-
898
- type OrderId = Id<"OrderId">;
899
- type ItemId = Id<"ItemId">;
900
-
901
- // Functional-style child entity (simple data, no logic)
902
- type OrderItem = Identifiable<ItemId> & {
903
- productId: string;
904
- quantity: number;
905
- price: number;
906
- };
907
-
908
- // Aggregate state contains child entities
909
- type OrderState = {
910
- id: OrderId;
911
- customerId: string;
912
- items: OrderItem[];
913
- total: number;
914
- };
915
-
916
- // Order is the Aggregate Root (an Entity with id + version)
917
- class Order extends AggregateRoot<OrderState, OrderId>
918
- implements IAggregateRoot<OrderId> {
919
- static create(id: OrderId, customerId: string): Order {
920
- const initialState: OrderState = {
921
- id,
922
- customerId,
923
- items: [], // Child entities
924
- total: 0,
925
- };
926
- return new Order(id, initialState);
927
- }
928
-
929
- // Operations on child entities are versioned through the Aggregate Root
930
- addItem(productId: string, quantity: number, price: number): ItemId {
931
- const itemId = `item-${Date.now()}` as ItemId;
932
- const item: OrderItem = {
933
- id: itemId,
934
- productId,
935
- quantity,
936
- price,
937
- };
938
-
939
- this.setState({
940
- ...this.state,
941
- items: [...this.state.items, item],
942
- total: this.state.total + price * quantity,
943
- }, true); // true = bump version (versions the entire aggregate including child entities)
944
- return itemId;
945
- }
946
-
947
- updateItemQuantity(itemId: ItemId, newQuantity: number): void {
948
- const item = findEntityById(this.state.items, itemId);
949
- if (!item) {
950
- throw new Error("Item not found");
951
- }
952
-
953
- this.setState({
954
- ...this.state,
955
- items: updateEntityById(this.state.items, itemId, (i) => ({ ...i, quantity: newQuantity })),
956
- total: this.state.total - item.price * item.quantity + item.price * newQuantity,
957
- }, true);
958
- }
959
-
960
- removeItem(itemId: ItemId): void {
961
- const item = findEntityById(this.state.items, itemId);
962
- if (!item) {
963
- throw new Error("Item not found");
964
- }
965
-
966
- this.setState({
967
- ...this.state,
968
- items: removeEntityById(this.state.items, itemId),
969
- total: this.state.total - item.price * item.quantity,
970
- }, true);
971
- }
972
-
973
- getItem(itemId: ItemId): OrderItem | undefined {
974
- return findEntityById(this.state.items, itemId);
975
- }
976
- }
977
-
978
- // Usage
979
- const order = Order.create("order-123" as OrderId, "customer-456");
980
- const itemId = order.addItem("product-1", 2, 10.0); // Adds child entity
981
- order.updateItemQuantity(itemId, 3); // Updates child entity
982
- order.removeItem(itemId); // Removes child entity
983
-
984
- // All changes version the Aggregate Root (order.version increments)
985
- console.log(order.version); // 3 (one for each operation)
986
- ```
987
-
988
- #### Approach 2: Class-Based Child Entities (With Business Logic)
989
-
990
- For child entities that need business logic, extend `Entity<TState, TId>`:
991
-
992
- ```typescript
993
- import {
994
- AggregateRoot,
995
- Entity,
996
- findEntityById,
997
- type IAggregateRoot,
998
- type Id,
999
- } from "@shirudo/ddd-kit";
1000
-
1001
- type OrderId = Id<"OrderId">;
1002
- type ItemId = Id<"ItemId">;
1003
-
1004
- // State of OrderItem
1005
- type OrderItemState = {
1006
- productId: string;
1007
- quantity: number;
1008
- price: number;
1009
- };
1010
-
1011
- // Class-based child entity with business logic
1012
- class OrderItem extends Entity<OrderItemState, ItemId> {
1013
- constructor(id: ItemId, productId: string, quantity: number, price: number) {
1014
- const initialState: OrderItemState = { productId, quantity, price };
1015
- super(id, initialState);
1016
- }
1017
-
1018
- // Entity-specific business logic
1019
- updateQuantity(newQuantity: number): void {
1020
- if (newQuantity <= 0) {
1021
- throw new Error("Quantity must be greater than 0");
1022
- }
1023
- this.setState({ ...this.state, quantity: newQuantity });
1024
- }
1025
-
1026
- calculateSubtotal(): number {
1027
- return this.state.price * this.state.quantity;
1028
- }
1029
-
1030
- isForProduct(productId: string): boolean {
1031
- return this.state.productId === productId;
1032
- }
1033
-
1034
- protected validateState(state: OrderItemState): void {
1035
- if (state.quantity <= 0) throw new Error("Quantity must be greater than 0");
1036
- if (state.price < 0) throw new Error("Price cannot be negative");
1037
- if (!state.productId) throw new Error("Product ID is required");
1038
- }
1039
- }
1040
-
1041
- // Aggregate state contains child entity instances
1042
- type OrderState = {
1043
- id: OrderId;
1044
- customerId: string;
1045
- items: OrderItem[]; // Child entities with logic
1046
- status: "pending" | "confirmed";
1047
- };
1048
-
1049
- // Aggregate Root
1050
- class Order extends AggregateRoot<OrderState, OrderId>
1051
- implements IAggregateRoot<OrderId> {
1052
- private itemCounter = 0;
1053
-
1054
- static create(id: OrderId, customerId: string): Order {
1055
- const initialState: OrderState = {
1056
- id,
1057
- customerId,
1058
- items: [],
1059
- status: "pending",
1060
- };
1061
- return new Order(id, initialState);
1062
- }
1063
-
1064
- addItem(productId: string, quantity: number, price: number): ItemId {
1065
- const itemId = `item-${++this.itemCounter}` as ItemId;
1066
- const item = new OrderItem(itemId, productId, quantity, price);
1067
-
1068
- this.setState({
1069
- ...this.state,
1070
- items: [...this.state.items, item],
1071
- }, true);
1072
- return itemId;
1073
- }
1074
-
1075
- // Delegate to entity's business logic
1076
- updateItemQuantity(itemId: ItemId, newQuantity: number): void {
1077
- const item = findEntityById(this.state.items, itemId);
1078
- if (!item) throw new Error("Item not found");
1079
-
1080
- item.updateQuantity(newQuantity); // Uses entity's logic
1081
- this.bumpVersion();
1082
- }
1083
-
1084
- // Use entity's business logic
1085
- calculateTotal(): number {
1086
- return this.state.items.reduce(
1087
- (total, item) => total + item.calculateSubtotal(),
1088
- 0
1089
- );
1090
- }
1091
-
1092
- confirm(): void {
1093
- if (this.state.items.length === 0) {
1094
- throw new Error("Cannot confirm an order without items");
1095
- }
1096
- this.setState({ ...this.state, status: "confirmed" }, true);
1097
- }
1098
- }
1099
-
1100
- // Usage
1101
- const order = Order.create("order-1" as OrderId, "customer-1");
1102
- const itemId = order.addItem("product-1", 2, 10.0);
1103
- order.updateItemQuantity(itemId, 3); // Uses entity's validation
1104
- const total = order.calculateTotal(); // Uses entity's calculateSubtotal()
1105
- console.log(total); // 30.0
1106
- ```
1107
-
1108
- ### Using Result Type for Error Handling
1109
-
1110
- The `Result<T, E>` type provides composition utilities to avoid repetitive `if (isErr)` checks:
1111
-
1112
- **Import Result utilities from the dedicated export path:**
1113
-
1114
- ```typescript
1115
- import { ok, err, type Result } from "@shirudo/result";
1116
-
1117
- type UserId = string;
1118
-
1119
- function validateUserId(id: string): Result<UserId, string> {
1120
- return id.length > 0 ? ok(id as UserId) : err("User ID cannot be empty");
1121
- }
1122
-
1123
- const result = validateUserId("user-123");
1124
- if (result.isOk()) {
1125
- console.log("Valid:", result.value);
1126
- } else {
1127
- console.error("Invalid:", result.error);
1128
- }
1129
- ```
1130
-
1131
- `ddd-kit` declares `@shirudo/result` as a `peerDependency`, so install it once in your app:
1132
-
1133
- ```bash
1134
- pnpm add @shirudo/result
1135
44
  ```
1136
45
 
1137
- 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).
1138
-
1139
- **Where `ddd-kit` uses `Result` vs `throw`:**
1140
-
1141
- - **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`.
1142
- - **Infrastructure boundary returns `Result`** where corruption is an expected recoverable failure: `EventSourcedAggregate.loadFromHistory()`, `restoreFromSnapshotWithEvents()`.
1143
- - **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.
1144
- - **`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()`).
1145
- - **`voValidated`** collects multiple field violations into one `ValidationError` (a Result-axis value you destructure, never catch). The opt-in `@shirudo/ddd-kit/http` entry point renders it as RFC 9457 via `toProblemDetails`.
1146
-
1147
- ## API Documentation
1148
-
1149
- 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`.
1150
-
1151
- Key exports include:
1152
- - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()`, `voValidated()` - Value Object utilities (`voWithValidation` / `voValidated` are for the App-Service boundary — the latter collects every field violation into one `ValidationError`; Domain construction goes through the `ValueObject` base class which throws via `validate()`)
1153
- - `toProblemDetails()` from `@shirudo/ddd-kit/http` - renders a `ValidationError` as an RFC 9457 Problem Details object (opt-in subpath; keeps transport out of the core)
1154
- - `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
1155
- - `AggregateRoot<TState, TId, TEvent?>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId, TEvent>`). Optional `TEvent` parameter enables type-safe domain events
1156
- - `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
1157
- - `AggregateConfig` - Configuration interface for `AggregateRoot` (controls per-call `setState` version-bump behavior)
1158
- - `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
1159
- - `sameVersion()` - Optimistic concurrency check (same ID and version)
1160
- - `Entity<TState, TId>` - Base class for entities with state and business logic
1161
- - `IEntity<TId, TState>` - Entity interface
1162
- - `Identifiable<TId>` - Minimal interface for objects with id
1163
- - `sameEntity()`, `findEntityById()`, `hasEntityId()`, `removeEntityById()`, `updateEntityById()`, `replaceEntityById()`, `entityIds()` - Entity helper functions
1164
- - `Command`, `CommandHandler<C, R>` - Command interface and handler type for CQRS
1165
- - `Query`, `QueryHandler<Q, R>` - Query interface and handler type for CQRS
1166
- - `CommandBus`, `ICommandBus` - Command bus for centralized command execution
1167
- - `QueryBus`, `IQueryBus` - Query bus for centralized query execution (with `execute()` returning Result and `executeUnsafe()` throwing exceptions)
1168
- - `withCommit()` - Helper for transactional command execution with events
1169
- - `DomainEvent<T, P?>` - Domain event interface (`P` defaults to `void` for payload-less events)
1170
- - `EventMetadata` - Event metadata interface for traceability
1171
- - `createDomainEvent()` - Event creation helper (payload is optional for payload-less events)
1172
- - `createDomainEventWithMetadata()` - Event creation with metadata
1173
- - `copyMetadata()`, `mergeMetadata()` - Metadata utilities
1174
- - `EventBus<Evt>`, `EventBusImpl<Evt>` - Event bus interface and implementation for pub/sub pattern
1175
- - `EventHandler<Evt>` - Event handler function type
1176
- - `EventBus.subscribe()` - Subscribe handlers to event types
1177
- - `EventBus.publish()` - Publish events to all subscribers (uses `Promise.allSettled` — all handlers run even if one fails)
1178
- - `EventBus.once()` - Wait for the next event of a given type (returns Promise, auto-unsubscribes)
1179
- - `Id<Tag>` - Branded ID type (Result type and operators come from the `@shirudo/result` peer dep)
1180
- - `IRepository<TAgg, TId>` - Repository interface
1181
- - `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.
1182
- - `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.
1183
- - `DomainError` - Abstract base for domain exceptions (Consumer subclasses for their aggregate-specific errors)
1184
- - `MissingHandlerError`, `AggregateNotFoundError` - Concrete library-internal `DomainError` subclasses
1185
-
1186
- ## Concurrency & Thread Safety
46
+ For a complete walkthrough a minimal `Order` aggregate with typed events, `commit()`, and the App-Service boundary — see [Getting Started](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/getting-started.md).
1187
47
 
1188
- ### Understanding "Operations" in Different Contexts
48
+ ## Core concepts
1189
49
 
1190
- When we talk about **operations** or **executions**, we mean:
50
+ Each building block has a dedicated guide. Start with [Design Decisions](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/design-decisions.md) for the non-obvious calls (Result at the App boundary, no Specification pattern, no Fowler Unit of Work, class-based aggregates).
1191
51
 
1192
- 1. **HTTP Request** - In a web API: One incoming HTTP request (GET, POST, etc.)
1193
- 2. **Command Execution** - In CQRS: Execution of a single command (CreateOrder, UpdateQuantity, etc.)
1194
- 3. **Query Execution** - In CQRS: Execution of a single query (GetOrder, ListOrders, etc.)
1195
- 4. **Background Job** - Asynchronous task processing (email sending, report generation, etc.)
1196
- 5. **Event Handler** - Processing of a single domain event
52
+ | Concept | Guide |
53
+ |---|---|
54
+ | Value Objects (`vo`, `ValueObject`, `voWithValidation`, `voValidated`) | [Value Objects](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/value-objects.md) |
55
+ | Entities and identity | [Entities](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/entities.md) |
56
+ | Aggregate Roots, factories, reconstitution | [Aggregate Roots](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/aggregates.md) |
57
+ | Event sourcing (`apply`, replay, snapshots) | [Event Sourcing](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/event-sourcing.md) |
58
+ | Domain Events (`createDomainEvent`, metadata) | [Domain Events](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/domain-events.md) |
59
+ | Errors: throw vs Result, `ValidationError`, RFC 9457 | [Result vs Throw](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/result-vs-throw.md) |
60
+ | Commands, queries, buses | [CQRS & Buses](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/cqrs-and-buses.md) |
61
+ | Repositories, Identity Map, OCC | [Repository](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/repository.md) |
62
+ | Outbox, `withCommit`, transactions | [Outbox & Transactions](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/outbox.md) |
63
+ | Read-side projections | [Projections](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/projections.md) |
64
+ | Concurrency & operation-scoped aggregates | [Concurrency](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/concurrency.md) |
65
+ | Edge runtimes (Workers, Deno, Bun) | [Edge Runtimes](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/edge-runtimes.md) |
1197
66
 
1198
- **Key principle**: Each operation should load fresh aggregate instances, make changes, and save them. Never share aggregate instances across operations.
67
+ ## Documentation
1199
68
 
1200
- ### The Problem: Race Conditions with Shared State
69
+ - **[LLM.md](https://github.com/shi-rudo/ddd-kit-ts/blob/main/LLM.md)** hand-curated, high-signal guide for LLM coding tools and a fast human skim of the whole surface.
70
+ - **[Common Mistakes](https://github.com/shi-rudo/ddd-kit-ts/blob/main/docs/guide/common-mistakes.md)** — the footgun catalogue; read it before writing consumer code.
71
+ - **API reference** — full type definitions ship with the package (`node_modules/@shirudo/ddd-kit/dist/index.d.ts`); the `@shirudo/ddd-kit/http` subpath exports the RFC 9457 presenter.
72
+ - **[CHANGELOG](https://github.com/shi-rudo/ddd-kit-ts/blob/main/CHANGELOG.md)** — release history with a migration path for every breaking change.
1201
73
 
1202
- JavaScript is single-threaded, but `async/await` creates concurrency risks:
74
+ ## TypeScript support
1203
75
 
1204
- ```typescript
1205
- // ❌ DANGEROUS - Race Condition!
1206
- class OrderService {
1207
- private cachedOrder: Order; // NEVER cache aggregates!
1208
-
1209
- async updateQuantity(itemId: ItemId, quantity: number) {
1210
- // Request 1 reads quantity = 5
1211
- const item = this.cachedOrder.getItem(itemId);
1212
- const oldQty = item.state.quantity; // 5
1213
-
1214
- await someAsyncOperation(); // ⚠️ Context switch here!
1215
-
1216
- // Request 2 updates quantity to 10 while we wait
1217
- // Request 1 continues with stale data
1218
- item.updateQuantity(oldQty + 1); // Writes 6, should be 11!
1219
- }
1220
- }
1221
- ```
1222
-
1223
- **Why this happens:**
1224
- - `await` yields control to event loop
1225
- - Other async operations can run
1226
- - Your aggregate instance has stale data
1227
- - Last write wins (data loss!)
1228
-
1229
- ### ✅ Solution 1: Operation-Scoped Aggregates (Recommended)
1230
-
1231
- **Pattern**: Each operation gets its own aggregate instance. Load → Mutate → Save → Discard.
1232
-
1233
- This works the **SAME** for both function handlers and class-based handlers!
1234
-
1235
- #### Approach A: Function-Based Handlers (Simple)
1236
-
1237
- ```typescript
1238
- // ✅ SAFE - Fresh instance per operation
1239
- async function updateOrderQuantity(
1240
- orderId: OrderId,
1241
- itemId: ItemId,
1242
- quantity: number
1243
- ) {
1244
- // 1. Load fresh from database
1245
- const order = await repository.getById(orderId);
1246
-
1247
- // 2. Make ALL changes synchronously (no await!)
1248
- const item = order.getItem(itemId);
1249
- item.updateQuantity(quantity);
1250
- order.recalculateTotal();
1251
-
1252
- // 3. Save with optimistic locking
1253
- await repository.save(order); // Throws if version mismatch
1254
-
1255
- // 4. Instance is garbage collected (no shared state)
1256
- }
1257
-
1258
- // ✅ SAFE - Command Handler function
1259
- async function createOrderHandler(cmd: CreateOrderCommand) {
1260
- const orderId = generateId() as OrderId;
1261
- const order = Order.create(orderId, cmd.customerId);
1262
-
1263
- // All mutations synchronous
1264
- for (const item of cmd.items) {
1265
- order.addItem(item.productId, item.quantity, item.price);
1266
- }
1267
- order.confirm();
1268
-
1269
- await repository.save(order);
1270
- return order.id;
1271
- }
1272
- ```
1273
-
1274
- #### Approach B: Class-Based Handlers (MUST be Stateless!)
1275
-
1276
- The key difference with classes: **Dependencies in constructor, aggregates in methods**.
1277
-
1278
- ```typescript
1279
- // ✅ SAFE - Stateless handler class
1280
- class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId> {
1281
- constructor(
1282
- private readonly repository: OrderRepository,
1283
- private readonly eventBus: EventBus
1284
- ) {
1285
- // ✅ Only infrastructure dependencies here!
1286
- // ❌ NEVER store aggregates here!
1287
- }
1288
-
1289
- async execute(cmd: CreateOrderCommand): Promise<Result<OrderId, string>> {
1290
- // 1. Aggregate is LOCAL to this method call
1291
- const orderId = generateId() as OrderId;
1292
- const order = Order.create(orderId, cmd.customerId);
1293
-
1294
- // 2. All mutations synchronous
1295
- for (const item of cmd.items) {
1296
- order.addItem(item.productId, item.quantity, item.price);
1297
- }
1298
- order.confirm();
1299
-
1300
- // 3. Save
1301
- await this.repository.save(order);
1302
- await this.eventBus.publish(order.pendingEvents);
1303
-
1304
- return ok(order.id);
1305
- // 4. Aggregate is garbage collected when method returns
1306
- }
1307
- }
1308
-
1309
- // ✅ SAFE - Another handler instance
1310
- class UpdateOrderQuantityHandler {
1311
- constructor(private readonly repository: OrderRepository) {}
1312
-
1313
- async execute(cmd: UpdateQuantityCommand): Promise<Result<void, string>> {
1314
- // Fresh load per call
1315
- const order = await this.repository.getById(cmd.orderId);
1316
-
1317
- order.updateItemQuantity(cmd.itemId, cmd.quantity);
1318
-
1319
- await this.repository.save(order);
1320
- return ok(undefined);
1321
- }
1322
- }
1323
-
1324
- // Usage - Handler instances are singletons, but aggregates are not!
1325
- const handler = new CreateOrderHandler(repository, eventBus);
1326
-
1327
- // Each call gets fresh aggregate
1328
- await handler.execute(cmd1); // order1 created and discarded
1329
- await handler.execute(cmd2); // order2 created and discarded
1330
- await handler.execute(cmd3); // order3 created and discarded
1331
- ```
1332
-
1333
- #### ❌ DANGEROUS: Stateful Handler Class
1334
-
1335
- ```typescript
1336
- // ❌ DANGEROUS - Storing aggregates in class fields!
1337
- class OrderService {
1338
- private currentOrder: Order; // NEVER DO THIS!
1339
- private orderCache = new Map<OrderId, Order>(); // NEVER!
1340
-
1341
- constructor(private readonly repository: OrderRepository) {}
1342
-
1343
- async loadOrder(orderId: OrderId) {
1344
- this.currentOrder = await this.repository.getById(orderId);
1345
- // ❌ Stored in instance field - shared across operations!
1346
- }
1347
-
1348
- async updateQuantity(itemId: ItemId, quantity: number) {
1349
- // ❌ Using shared state from previous operation
1350
- this.currentOrder.updateItemQuantity(itemId, quantity);
1351
- // Race condition if another request called loadOrder()!
1352
- }
1353
- }
1354
- ```
1355
-
1356
- #### The Key Difference
1357
-
1358
- | | Function Handlers | Class Handlers |
1359
- |---|---|---|
1360
- | **Handler Instance** | Created per call | Singleton (DI container) |
1361
- | **Aggregate Instance** | Local variable | MUST be local variable in method |
1362
- | **Dependencies** | Parameters | Constructor injection |
1363
- | **Risk** | Low (naturally scoped) | Medium (tempting to store in fields) |
1364
-
1365
- **Important**:
1366
- - ✅ Handler **class** can be singleton
1367
- - ❌ Aggregate **instance** must NEVER be stored in handler class
1368
- - ✅ Aggregates are **always** local to method execution
1369
-
1370
- **Rules for safe aggregate usage (applies to BOTH):**
1371
- 1. ✅ Load aggregate at start of operation (method call)
1372
- 2. ✅ All mutations synchronous (no `await` between state changes)
1373
- 3. ✅ Save at end of operation
1374
- 4. ✅ Let garbage collector clean up
1375
- 5. ❌ Never store aggregates in class fields (if using classes)
1376
- 6. ❌ Never cache aggregates between operations
1377
- 7. ❌ Never pass aggregates between operations
1378
-
1379
- ### ✅ Solution 2: Optimistic Locking (Already Built-in!)
1380
-
1381
- Your `AggregateRoot` includes a `version` field for Optimistic Concurrency Control:
1382
-
1383
- ```typescript
1384
- // Repository implementation with optimistic locking
1385
- class OrderRepository {
1386
- async save(order: Order): Promise<void> {
1387
- const current = await db.orders.findOne({ id: order.id });
1388
-
1389
- // Check if someone else modified it
1390
- if (current && current.version !== order.version) {
1391
- throw new ConcurrencyError(
1392
- `Order ${order.id} was modified by another operation. ` +
1393
- `Expected version ${order.version}, but found ${current.version}`
1394
- );
1395
- }
1396
-
1397
- // Save with incremented version
1398
- await db.orders.update({
1399
- id: order.id,
1400
- ...order.state,
1401
- version: order.version + 1 // Increment version
1402
- });
1403
- }
1404
- }
1405
-
1406
- // Usage - retry on conflict
1407
- async function updateOrderWithRetry(orderId: OrderId, itemId: ItemId, qty: number) {
1408
- const maxRetries = 3;
1409
-
1410
- for (let attempt = 0; attempt < maxRetries; attempt++) {
1411
- try {
1412
- const order = await repository.getById(orderId);
1413
- order.updateItemQuantity(itemId, qty);
1414
- await repository.save(order);
1415
- return; // Success!
1416
- } catch (error) {
1417
- if (error instanceof ConcurrencyError && attempt < maxRetries - 1) {
1418
- // Retry with fresh data
1419
- continue;
1420
- }
1421
- throw error;
1422
- }
1423
- }
1424
- }
1425
- ```
1426
-
1427
- ### ✅ Solution 3: Unit of Work Pattern
1428
-
1429
- Use transactions to ensure consistency:
1430
-
1431
- ```typescript
1432
- import { withCommit } from "@shirudo/ddd-kit";
1433
-
1434
- async function createOrderCommand(cmd: CreateOrderCommand) {
1435
- return await withCommit({ uow, eventBus, outbox }, async () => {
1436
- const orderId = generateId() as OrderId;
1437
- const order = Order.create(orderId, cmd.customerId);
1438
-
1439
- // All synchronous mutations within transaction
1440
- for (const item of cmd.items) {
1441
- order.addItem(item.productId, item.quantity, item.price);
1442
- }
1443
-
1444
- await repository.save(order);
1445
-
1446
- return {
1447
- result: order.id,
1448
- aggregates: [order], // withCommit harvests pendingEvents and dispatches
1449
- };
1450
- }); // Commits or rollbacks everything
1451
- }
1452
- ```
1453
-
1454
- ### Safe Async Patterns
1455
-
1456
- ```typescript
1457
- // ✅ SAFE - Async I/O BEFORE mutations
1458
- async function processOrder(orderId: OrderId) {
1459
- // 1. Do all async I/O first
1460
- const order = await repository.getById(orderId);
1461
- const pricing = await pricingService.getPrices(order.state.items);
1462
- const inventory = await inventoryService.check(order.state.items);
1463
-
1464
- // 2. Then do all mutations synchronously
1465
- if (inventory.available) {
1466
- order.confirm();
1467
- for (const [itemId, price] of pricing) {
1468
- order.updateItemPrice(itemId, price);
1469
- }
1470
- } else {
1471
- order.cancel();
1472
- }
1473
-
1474
- // 3. Single save at end
1475
- await repository.save(order);
1476
- }
1477
-
1478
- // ❌ DANGEROUS - Interleaved async/mutations
1479
- async function processOrderWrong(orderId: OrderId) {
1480
- const order = await repository.getById(orderId);
1481
-
1482
- order.confirm(); // Mutation
1483
- await inventoryService.reserve(order.id); // ⚠️ Yield point!
1484
- order.addItem(...); // Another operation might have modified order!
1485
-
1486
- await repository.save(order);
1487
- }
1488
- ```
1489
-
1490
- ### Stateless Services Pattern
1491
-
1492
- ```typescript
1493
- // ✅ SAFE - Stateless service, aggregates are local
1494
- class OrderService {
1495
- constructor(
1496
- private readonly repository: OrderRepository,
1497
- private readonly eventBus: EventBus
1498
- ) {}
1499
-
1500
- async createOrder(cmd: CreateOrderCommand): Promise<Result<OrderId, string>> {
1501
- // Fresh instance per call
1502
- const order = Order.create(generateId(), cmd.customerId);
1503
-
1504
- for (const item of cmd.items) {
1505
- order.addItem(item.productId, item.quantity, item.price);
1506
- }
1507
-
1508
- await this.repository.save(order);
1509
- await this.eventBus.publish(order.pendingEvents);
1510
-
1511
- return ok(order.id);
1512
- // order is garbage collected here
1513
- }
1514
- }
1515
-
1516
- // ❌ DANGEROUS - Stateful service
1517
- class OrderServiceBad {
1518
- private orders = new Map<OrderId, Order>(); // NEVER!
1519
-
1520
- async updateOrder(orderId: OrderId) {
1521
- const order = this.orders.get(orderId); // Shared mutable state!
1522
- // Race conditions everywhere!
1523
- }
1524
- }
1525
- ```
1526
-
1527
- ### Multi-Tenant Considerations
1528
-
1529
- Even in single-threaded JavaScript, concurrent operations are real:
1530
-
1531
- ```typescript
1532
- // Scenario: Two users updating same order simultaneously
1533
- // Time | Request A (User 1) | Request B (User 2)
1534
- // ------|------------------------------|---------------------------
1535
- // T1 | order = load(id) v=1 |
1536
- // T2 | | order = load(id) v=1
1537
- // T3 | order.addItem(...) |
1538
- // T4 | | order.updateQty(...)
1539
- // T5 | save(order) → v=2 ✅ |
1540
- // T6 | | save(order) → v=1 ❌ Error!
1541
-
1542
- // With optimistic locking:
1543
- // Request B fails with ConcurrencyError
1544
- // Client retries with fresh data
1545
- ```
1546
-
1547
- ### Summary: Concurrency Best Practices
1548
-
1549
- | ✅ DO | ❌ DON'T |
1550
- |-------|----------|
1551
- | Load aggregate per operation | Cache aggregates in memory |
1552
- | All mutations synchronous | Mix async I/O with mutations |
1553
- | Use optimistic locking | Assume single-threaded = safe |
1554
- | Operation-scoped instances | Share instances across operations |
1555
- | Stateless services | Stateful services with aggregates |
1556
- | Retry on concurrency errors | Ignore version conflicts |
1557
-
1558
- **Remember**: JavaScript's single thread doesn't mean you're safe from race conditions. `async/await` creates concurrency, and multiple operations can be "in flight" simultaneously. Always treat aggregates as operation-scoped, use optimistic locking, and keep mutations synchronous.
1559
-
1560
- ## TypeScript Support
1561
-
1562
- This package is built with TypeScript and provides comprehensive type safety. All APIs are fully typed, leveraging TypeScript's type system to ensure correctness at compile time. The package requires TypeScript 5.9.2 or higher and takes advantage of advanced TypeScript features like branded types, conditional types, and mapped types to provide a type-safe DDD experience.
76
+ Requires TypeScript 5.9+. The kit leans on branded, conditional, and mapped types for a type-safe DDD experience; all APIs are fully typed.
1563
77
 
1564
78
  ## Contributing
1565
79
 
1566
- Contributions are welcome! Please read our contributing guidelines in [CONTRIBUTING.md](./CONTRIBUTING.md) before submitting pull requests. For bug reports and feature requests, please use the [GitHub issue tracker](https://github.com/shi-rudo/ddd-kit-ts/issues).
80
+ Contributions are welcome. For bugs and feature requests, use the [issue tracker](https://github.com/shi-rudo/ddd-kit-ts/issues); open a pull request against `main`.
1567
81
 
1568
82
  ## License
1569
83
 
1570
- This project is licensed under the MIT License.
84
+ MIT.
1571
85
 
1572
86
  ## Author
1573
87
 
1574
- **Shirudo**
1575
-
1576
- - GitHub: [@shi-rudo](https://github.com/shi-rudo)
1577
- - Package: [@shirudo/ddd-kit](https://www.npmjs.com/package/@shirudo/ddd-kit)
1578
- - Repository: [ddd-kit-ts](https://github.com/shi-rudo/ddd-kit-ts)
88
+ **Shirudo** — [@shi-rudo](https://github.com/shi-rudo) · [npm](https://www.npmjs.com/package/@shirudo/ddd-kit) · [repo](https://github.com/shi-rudo/ddd-kit-ts)