@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 +44 -1518
- package/dist/http.d.ts +46 -0
- package/dist/http.js +18 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +28 -28
- package/package.json +10 -5
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
|
-
> **
|
|
5
|
+
> **Stable — 1.0**
|
|
6
6
|
>
|
|
7
|
-
>
|
|
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
|

|
|
12
10
|

|
|
13
11
|
|
|
14
12
|
## Features
|
|
15
13
|
|
|
16
|
-
- **Value Objects** -
|
|
17
|
-
- **Entities**
|
|
18
|
-
- **
|
|
19
|
-
- **Domain Events**
|
|
20
|
-
- **Repositories** -
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **Result
|
|
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
|
-
|
|
29
|
+
`@shirudo/result` and `@shirudo/base-error` are peer dependencies — install them once in the consuming app.
|
|
40
30
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
+
## Core concepts
|
|
375
49
|
|
|
376
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
396
|
-
this.apply(
|
|
397
|
-
createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
|
|
398
|
-
);
|
|
399
|
-
}
|
|
67
|
+
## Documentation
|
|
400
68
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|