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