@shirudo/ddd-kit 0.8.8 → 0.9.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 +896 -19
- package/dist/index.d.ts +1328 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,43 +1,920 @@
|
|
|
1
1
|
# @shirudo/ddd-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Composable TypeScript toolkit for tactical Domain-Driven Design.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Badges
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+

|
|
8
|
+

|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
## Features
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
- **Value Objects** - Immutable objects defined by their attributes, ensuring data integrity
|
|
13
|
+
- **Entities** - Optional interface and helpers for entities with identity, useful for nested entities within aggregates
|
|
14
|
+
- **Aggregates** - Event-sourced aggregates with versioning for optimistic concurrency control
|
|
15
|
+
- **Domain Events** - Type-safe domain events with versioning and metadata for schema evolution and traceability
|
|
16
|
+
- **Repositories** - Persistence abstraction layer for aggregates with specification pattern support
|
|
17
|
+
- **Specifications** - Reusable query specifications for complex domain queries
|
|
18
|
+
- **Unit of Work** - Transaction management for maintaining consistency across operations
|
|
19
|
+
- **Result Type** - Functional error handling with `Result<T, E>` type for explicit success/failure states
|
|
12
20
|
|
|
13
|
-
##
|
|
21
|
+
## Installation
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
Install the package using npm:
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
```bash
|
|
26
|
+
npm install @shirudo/ddd-kit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or using pnpm:
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
```bash
|
|
32
|
+
pnpm add @shirudo/ddd-kit
|
|
33
|
+
```
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
## Quick Start
|
|
22
36
|
|
|
23
|
-
|
|
37
|
+
Here's a minimal example showing how to create and use a Value Object:
|
|
24
38
|
|
|
25
|
-
```
|
|
26
|
-
|
|
39
|
+
```typescript
|
|
40
|
+
import { vo, type ValueObject } from "@shirudo/ddd-kit";
|
|
41
|
+
|
|
42
|
+
type EmailAddress = ValueObject<{
|
|
43
|
+
value: string;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
function createEmail(value: string): EmailAddress {
|
|
47
|
+
if (!value.includes("@")) {
|
|
48
|
+
throw new Error("Invalid email address");
|
|
49
|
+
}
|
|
50
|
+
return vo({ value });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const email = createEmail("user@example.com");
|
|
54
|
+
// email.value is readonly and immutable
|
|
27
55
|
```
|
|
28
56
|
|
|
29
|
-
|
|
57
|
+
## Core Concepts
|
|
30
58
|
|
|
31
|
-
###
|
|
59
|
+
### Value Objects
|
|
32
60
|
|
|
33
|
-
|
|
61
|
+
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, `voWithValidation()` for creating validated value objects (returns Result), and `voWithValidationUnsafe()` for the exception-throwing variant.
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
### Entities
|
|
64
|
+
|
|
65
|
+
Entities are objects with unique identity that are defined by their ID rather than their attributes. The optional `Entity<TId>` interface can be used for nested entities within aggregates or entities that are not aggregate roots. Helper functions like `sameEntity()`, `findEntityById()`, and `hasEntityId()` provide utilities for working with entity collections.
|
|
66
|
+
|
|
67
|
+
### Aggregates
|
|
68
|
+
|
|
69
|
+
Aggregates are clusters of entities and value objects that form a consistency boundary. The library provides:
|
|
70
|
+
|
|
71
|
+
- **`AggregateRoot<TId>`** - Marker interface for Aggregate Roots. Aggregate Roots are the entry points for modifying aggregates in DDD. They have identity (id) and version for optimistic concurrency control. All aggregate base classes implement this interface.
|
|
72
|
+
|
|
73
|
+
- **`AggregateBase<TState, TId>`** - Base class for aggregates without Event Sourcing. Implements `AggregateRoot<TId>`. Provides ID and version management, state management, and snapshot support. Use this when you don't need Event Sourcing but still want aggregate patterns with optimistic concurrency control.
|
|
74
|
+
|
|
75
|
+
- **`AggregateEventSourced<TState, TEvent, TId>`** - Base class for Event-Sourced aggregates. Extends `AggregateBase` (and thus implements `AggregateRoot<TId>`). Adds event tracking, event handlers, event validation, and history replay capabilities. Use this when you want full Event Sourcing with event tracking and replay.
|
|
76
|
+
|
|
77
|
+
Both classes support automatic versioning (configurable), snapshot creation/restoration, and optimistic concurrency control.
|
|
78
|
+
|
|
79
|
+
### CQRS (Command Query Responsibility Segregation)
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
### Domain Events
|
|
84
|
+
|
|
85
|
+
Domain Events represent something meaningful that happened in your domain. They are immutable records with a type, payload, timestamp, optional version for schema evolution, and metadata for traceability. Events support versioning for handling schema changes over time and include metadata fields like `correlationId`, `causationId`, `userId`, and `source` for tracking event flow in distributed systems. Events are automatically tracked by aggregates and can be published to event buses or stored in outboxes for eventual consistency.
|
|
86
|
+
|
|
87
|
+
### Repositories
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
### Specifications
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
### Result Type
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
## Usage Examples
|
|
100
|
+
|
|
101
|
+
### Creating a Value Object
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
import { vo, voEquals, voWithValidation, type ValueObject } from "@shirudo/ddd-kit";
|
|
105
|
+
|
|
106
|
+
// Simple value object
|
|
107
|
+
type Money = ValueObject<{
|
|
108
|
+
amount: number;
|
|
109
|
+
currency: string;
|
|
110
|
+
}>;
|
|
111
|
+
|
|
112
|
+
const price = vo({ amount: 99.99, currency: "USD" });
|
|
113
|
+
// price is deeply immutable - nested objects and arrays are also frozen
|
|
114
|
+
|
|
115
|
+
// Value object with validation (returns Result)
|
|
116
|
+
const result = voWithValidation(
|
|
117
|
+
{ amount: 100, currency: "USD" },
|
|
118
|
+
(m) => m.amount >= 0 && m.currency.length === 3,
|
|
119
|
+
"Amount must be non-negative and currency must be 3 characters"
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (result.ok) {
|
|
123
|
+
const validMoney = result.value;
|
|
124
|
+
// Use validMoney...
|
|
125
|
+
} else {
|
|
126
|
+
console.error(result.error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Or use unsafe variant (throws exception)
|
|
130
|
+
const validMoneyUnsafe = voWithValidationUnsafe(
|
|
131
|
+
{ amount: 100, currency: "USD" },
|
|
132
|
+
(m) => m.amount >= 0 && m.currency.length === 3,
|
|
133
|
+
"Amount must be non-negative and currency must be 3 characters"
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Value object with nested structures (deep freeze)
|
|
137
|
+
const address = vo({
|
|
138
|
+
street: "Main St",
|
|
139
|
+
city: "Berlin",
|
|
140
|
+
coordinates: { lat: 52.5, lng: 13.4 }
|
|
141
|
+
});
|
|
142
|
+
// address.coordinates.lat = 99; // ❌ Error: Cannot assign to read-only property
|
|
143
|
+
|
|
144
|
+
// Equality comparison
|
|
145
|
+
const money1 = vo({ amount: 100, currency: "USD" });
|
|
146
|
+
const money2 = vo({ amount: 100, currency: "USD" });
|
|
147
|
+
voEquals(money1, money2); // true (value equality, not reference)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Creating an Aggregate WITHOUT Event Sourcing
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import {
|
|
154
|
+
AggregateBase,
|
|
155
|
+
type AggregateRoot,
|
|
156
|
+
type Id,
|
|
157
|
+
} from "@shirudo/ddd-kit";
|
|
158
|
+
|
|
159
|
+
type OrderId = Id<"OrderId">;
|
|
160
|
+
|
|
161
|
+
type OrderState = {
|
|
162
|
+
id: OrderId;
|
|
163
|
+
customerId: string;
|
|
164
|
+
items: Array<{ productId: string; quantity: number; price: number }>;
|
|
165
|
+
total: number;
|
|
166
|
+
status: "pending" | "confirmed" | "shipped";
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
class Order extends AggregateBase<OrderState, OrderId> implements AggregateRoot<OrderId> {
|
|
170
|
+
static create(id: OrderId, customerId: string): Order {
|
|
171
|
+
const initialState: OrderState = {
|
|
172
|
+
id,
|
|
173
|
+
customerId,
|
|
174
|
+
items: [],
|
|
175
|
+
total: 0,
|
|
176
|
+
status: "pending",
|
|
177
|
+
};
|
|
178
|
+
return new Order(id, initialState);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
addItem(productId: string, quantity: number, price: number): void {
|
|
182
|
+
if (this._state.status !== "pending") {
|
|
183
|
+
throw new Error("Cannot add items to a non-pending order");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this._state = {
|
|
187
|
+
...this._state,
|
|
188
|
+
items: [...this._state.items, { productId, quantity, price }],
|
|
189
|
+
total: this._state.total + quantity * price,
|
|
190
|
+
};
|
|
191
|
+
this.bumpVersion(); // Manual version bump for optimistic concurrency control
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
confirm(): void {
|
|
195
|
+
if (this._state.status !== "pending") {
|
|
196
|
+
throw new Error("Only pending orders can be confirmed");
|
|
197
|
+
}
|
|
198
|
+
this._state = { ...this._state, status: "confirmed" };
|
|
199
|
+
this.bumpVersion();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
ship(): void {
|
|
203
|
+
if (this._state.status !== "confirmed") {
|
|
204
|
+
throw new Error("Only confirmed orders can be shipped");
|
|
205
|
+
}
|
|
206
|
+
this._state = { ...this._state, status: "shipped" };
|
|
207
|
+
this.bumpVersion();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Usage
|
|
212
|
+
const order = Order.create("order-123" as OrderId, "customer-456");
|
|
213
|
+
order.addItem("product-1", 2, 10.0);
|
|
214
|
+
order.confirm();
|
|
215
|
+
order.ship();
|
|
216
|
+
|
|
217
|
+
console.log(order.version); // 3 (manually bumped)
|
|
218
|
+
console.log(order.state.status); // "shipped"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Creating an Aggregate WITH Event Sourcing
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
import {
|
|
225
|
+
AggregateEventSourced,
|
|
226
|
+
createDomainEvent,
|
|
227
|
+
type AggregateRoot,
|
|
228
|
+
type Id,
|
|
229
|
+
type DomainEvent,
|
|
230
|
+
} from "@shirudo/ddd-kit";
|
|
231
|
+
|
|
232
|
+
type OrderId = Id<"OrderId">;
|
|
233
|
+
|
|
234
|
+
type OrderState = {
|
|
235
|
+
id: OrderId;
|
|
236
|
+
customerId: string;
|
|
237
|
+
items: string[];
|
|
238
|
+
status: "pending" | "confirmed" | "shipped";
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
type OrderCreated = DomainEvent<"OrderCreated", { customerId: string }>;
|
|
242
|
+
type OrderConfirmed = DomainEvent<"OrderConfirmed", {}>;
|
|
243
|
+
type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
|
|
244
|
+
|
|
245
|
+
type OrderEvent = OrderCreated | OrderConfirmed | OrderShipped;
|
|
246
|
+
|
|
247
|
+
class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
|
|
248
|
+
static create(id: OrderId, customerId: string): Order {
|
|
249
|
+
const initialState: OrderState = {
|
|
250
|
+
id,
|
|
251
|
+
customerId,
|
|
252
|
+
items: [],
|
|
253
|
+
status: "pending",
|
|
254
|
+
};
|
|
255
|
+
const order = new Order(id, initialState);
|
|
256
|
+
order.apply(
|
|
257
|
+
createDomainEvent("OrderCreated", { customerId }) as OrderCreated
|
|
258
|
+
);
|
|
259
|
+
return order;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
confirm(): void {
|
|
263
|
+
const result = this.apply(
|
|
264
|
+
createDomainEvent("OrderConfirmed", {}) as OrderConfirmed
|
|
265
|
+
);
|
|
266
|
+
if (!result.ok) {
|
|
267
|
+
throw new Error(result.error);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
ship(trackingNumber: string): void {
|
|
272
|
+
const result = this.apply(
|
|
273
|
+
createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
|
|
274
|
+
);
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
throw new Error(result.error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Or use unsafe variant (throws exception directly)
|
|
281
|
+
confirmUnsafe(): void {
|
|
282
|
+
this.applyUnsafe(
|
|
283
|
+
createDomainEvent("OrderConfirmed", {}) as OrderConfirmed
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
protected readonly handlers = {
|
|
288
|
+
OrderCreated: (state: OrderState, event: OrderCreated): OrderState => ({
|
|
289
|
+
...state,
|
|
290
|
+
customerId: event.payload.customerId,
|
|
291
|
+
status: "pending",
|
|
292
|
+
}),
|
|
293
|
+
OrderConfirmed: (state: OrderState): OrderState => ({
|
|
294
|
+
...state,
|
|
295
|
+
status: "confirmed",
|
|
296
|
+
}),
|
|
297
|
+
OrderShipped: (state: OrderState, event: OrderShipped): OrderState => ({
|
|
298
|
+
...state,
|
|
299
|
+
status: "shipped",
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Usage
|
|
305
|
+
const orderId = "order-123" as OrderId;
|
|
306
|
+
const order = Order.create(orderId, "customer-456");
|
|
307
|
+
order.confirm();
|
|
308
|
+
order.ship("TRACK-789");
|
|
309
|
+
|
|
310
|
+
// Access pending events
|
|
311
|
+
console.log(order.pendingEvents); // Array of events not yet persisted
|
|
312
|
+
|
|
313
|
+
// Helper methods
|
|
314
|
+
console.log(order.hasPendingEvents()); // true
|
|
315
|
+
console.log(order.getEventCount()); // 3
|
|
316
|
+
console.log(order.getLatestEvent()?.type); // "OrderShipped"
|
|
317
|
+
console.log(order.version); // 3 (automatically bumped)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Aggregate Features: Snapshots and Configuration
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import {
|
|
324
|
+
AggregateBase,
|
|
325
|
+
AggregateEventSourced,
|
|
326
|
+
sameAggregate,
|
|
327
|
+
type Id,
|
|
328
|
+
} from "@shirudo/ddd-kit";
|
|
329
|
+
|
|
330
|
+
type OrderId = Id<"OrderId">;
|
|
331
|
+
type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
|
|
332
|
+
|
|
333
|
+
// Snapshots work with both aggregate types
|
|
334
|
+
const order = Order.create("order-123" as OrderId, "customer-456");
|
|
335
|
+
order.confirm();
|
|
336
|
+
|
|
337
|
+
const snapshot = order.createSnapshot();
|
|
338
|
+
// Save snapshot to database...
|
|
339
|
+
|
|
340
|
+
// Later: restore from snapshot (without events)
|
|
341
|
+
const restoredOrder = Order.create("order-123" as OrderId, "customer-456");
|
|
342
|
+
restoredOrder.restoreFromSnapshot(snapshot);
|
|
343
|
+
|
|
344
|
+
// For Event-Sourced aggregates: restore with events after snapshot
|
|
345
|
+
const eventSourcedOrder = EventSourcedOrder.create("order-123" as OrderId, "customer-456");
|
|
346
|
+
const eventsAfterSnapshot = [/* events that occurred after snapshot */];
|
|
347
|
+
eventSourcedOrder.restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot);
|
|
348
|
+
|
|
349
|
+
// Aggregate equality check
|
|
350
|
+
const order1 = await repository.getById(id);
|
|
351
|
+
// ... some operations ...
|
|
352
|
+
const order2 = await repository.getById(id);
|
|
353
|
+
if (!sameAggregate(order1, order2)) {
|
|
354
|
+
throw new Error("Aggregate was modified by another process");
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Event Validation (Event-Sourced Aggregates Only)
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
import {
|
|
362
|
+
AggregateEventSourced,
|
|
363
|
+
createDomainEvent,
|
|
364
|
+
err,
|
|
365
|
+
ok,
|
|
366
|
+
type AggregateRoot,
|
|
367
|
+
type Id,
|
|
368
|
+
type DomainEvent,
|
|
369
|
+
type Result,
|
|
370
|
+
} from "@shirudo/ddd-kit";
|
|
371
|
+
|
|
372
|
+
type OrderId = Id<"OrderId">;
|
|
373
|
+
type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
|
|
374
|
+
type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
|
|
375
|
+
type OrderEvent = OrderShipped;
|
|
376
|
+
|
|
377
|
+
class Order extends AggregateEventSourced<OrderState, OrderEvent, OrderId> implements AggregateRoot<OrderId> {
|
|
378
|
+
// Event validation
|
|
379
|
+
protected validateEvent(event: OrderEvent): Result<true, string> {
|
|
380
|
+
if (event.type === "OrderShipped" && this.state.status !== "confirmed") {
|
|
381
|
+
return err("Order must be confirmed before shipping");
|
|
382
|
+
}
|
|
383
|
+
return ok(true);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
ship(trackingNumber: string): void {
|
|
387
|
+
this.apply(
|
|
388
|
+
createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
protected readonly handlers = {
|
|
393
|
+
OrderShipped: (state: OrderState, event: OrderShipped): OrderState => ({
|
|
394
|
+
...state,
|
|
395
|
+
status: "shipped",
|
|
396
|
+
}),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Using CQRS: Commands and Queries
|
|
402
|
+
|
|
403
|
+
#### Commands (Write Operations)
|
|
404
|
+
|
|
405
|
+
Commands represent write operations that change system state. They return `Result` for explicit error handling.
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
import {
|
|
409
|
+
Command,
|
|
410
|
+
CommandHandler,
|
|
411
|
+
CommandBus,
|
|
412
|
+
ok,
|
|
413
|
+
err,
|
|
414
|
+
type Result,
|
|
415
|
+
} from "@shirudo/ddd-kit";
|
|
416
|
+
|
|
417
|
+
// Define a command
|
|
418
|
+
type CreateOrderCommand = Command & {
|
|
419
|
+
type: "CreateOrder";
|
|
420
|
+
customerId: string;
|
|
421
|
+
items: Array<{ productId: string; quantity: number }>;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Create a command handler
|
|
425
|
+
const createOrderHandler: CommandHandler<CreateOrderCommand, string> = async (
|
|
426
|
+
cmd
|
|
427
|
+
) => {
|
|
428
|
+
// Validate input
|
|
429
|
+
if (cmd.items.length === 0) {
|
|
430
|
+
return err("Order must have at least one item");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Perform business logic
|
|
434
|
+
const order = Order.create(cmd.customerId, cmd.items);
|
|
435
|
+
await repository.save(order);
|
|
436
|
+
|
|
437
|
+
return ok(order.id);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Use directly
|
|
441
|
+
const result = await createOrderHandler({
|
|
442
|
+
type: "CreateOrder",
|
|
443
|
+
customerId: "customer-123",
|
|
444
|
+
items: [{ productId: "product-1", quantity: 2 }],
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (result.ok) {
|
|
448
|
+
console.log("Order created:", result.value);
|
|
449
|
+
} else {
|
|
450
|
+
console.error("Error:", result.error);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Or use with Command Bus (basic in-memory implementation)
|
|
454
|
+
// Note: For production, consider using external buses (RabbitMQ, AWS SQS) with typed handlers
|
|
455
|
+
const commandBus = new CommandBus();
|
|
456
|
+
commandBus.register("CreateOrder", createOrderHandler);
|
|
457
|
+
|
|
458
|
+
const busResult = await commandBus.execute({
|
|
459
|
+
type: "CreateOrder",
|
|
460
|
+
customerId: "customer-123",
|
|
461
|
+
items: [{ productId: "product-1", quantity: 2 }],
|
|
462
|
+
});
|
|
37
463
|
```
|
|
38
464
|
|
|
39
|
-
|
|
465
|
+
#### Queries (Read Operations)
|
|
466
|
+
|
|
467
|
+
Queries represent read operations that don't change system state. They return data directly.
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
import {
|
|
471
|
+
Query,
|
|
472
|
+
QueryHandler,
|
|
473
|
+
QueryBus,
|
|
474
|
+
} from "@shirudo/ddd-kit";
|
|
475
|
+
|
|
476
|
+
// Define a query
|
|
477
|
+
type GetOrderQuery = Query & {
|
|
478
|
+
type: "GetOrder";
|
|
479
|
+
orderId: string;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Create a query handler
|
|
483
|
+
const getOrderHandler: QueryHandler<GetOrderQuery, Order | null> = async (
|
|
484
|
+
query
|
|
485
|
+
) => {
|
|
486
|
+
return await repository.getById(query.orderId);
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// Use directly
|
|
490
|
+
const order = await getOrderHandler({
|
|
491
|
+
type: "GetOrder",
|
|
492
|
+
orderId: "order-123",
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Or use with Query Bus (basic in-memory implementation)
|
|
496
|
+
// Note: For production, consider using external buses (RabbitMQ, AWS SQS) with typed handlers
|
|
497
|
+
const queryBus = new QueryBus();
|
|
498
|
+
queryBus.register("GetOrder", getOrderHandler);
|
|
499
|
+
|
|
500
|
+
// Safe variant (returns Result)
|
|
501
|
+
const result = await queryBus.execute({
|
|
502
|
+
type: "GetOrder",
|
|
503
|
+
orderId: "order-123",
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
if (result.ok) {
|
|
507
|
+
const orderFromBus = result.value;
|
|
508
|
+
// Use orderFromBus...
|
|
509
|
+
} else {
|
|
510
|
+
console.error(result.error);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Or use unsafe variant (throws exception)
|
|
514
|
+
const orderFromBusUnsafe = await queryBus.executeUnsafe({
|
|
515
|
+
type: "GetOrder",
|
|
516
|
+
orderId: "order-123",
|
|
517
|
+
});
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
#### Combining Commands with Transactions
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { withCommit } from "@shirudo/ddd-kit";
|
|
524
|
+
|
|
525
|
+
const createOrderHandler: CommandHandler<CreateOrderCommand, string> = async (
|
|
526
|
+
cmd
|
|
527
|
+
) => {
|
|
528
|
+
return await withCommit(
|
|
529
|
+
{ outbox, bus, uow },
|
|
530
|
+
async () => {
|
|
531
|
+
const order = Order.create(cmd.customerId, cmd.items);
|
|
532
|
+
await repository.save(order);
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
result: order.id,
|
|
536
|
+
events: order.pendingEvents,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
);
|
|
540
|
+
};
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
#### Using Commands/Queries with External Frameworks
|
|
544
|
+
|
|
545
|
+
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.
|
|
546
|
+
|
|
547
|
+
**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:
|
|
548
|
+
- Middleware/Pipeline support (logging, validation, authorization)
|
|
549
|
+
- Error handling and retry logic
|
|
550
|
+
- Timeout handling
|
|
551
|
+
- Metrics and observability
|
|
552
|
+
- Dead letter queues
|
|
553
|
+
- Transaction management
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
import {
|
|
557
|
+
Command,
|
|
558
|
+
CommandHandler,
|
|
559
|
+
Query,
|
|
560
|
+
QueryHandler,
|
|
561
|
+
ok,
|
|
562
|
+
type Result,
|
|
563
|
+
} from "@shirudo/ddd-kit";
|
|
564
|
+
|
|
565
|
+
// Define commands/queries using marker interfaces
|
|
566
|
+
type CreateOrderCommand = Command & {
|
|
567
|
+
type: "CreateOrder";
|
|
568
|
+
customerId: string;
|
|
569
|
+
items: OrderItem[];
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
type GetOrderQuery = Query & {
|
|
573
|
+
type: "GetOrder";
|
|
574
|
+
orderId: OrderId;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Handler typed with CommandHandler for type safety
|
|
578
|
+
const createOrderHandler: CommandHandler<CreateOrderCommand, OrderId> = async (
|
|
579
|
+
cmd
|
|
580
|
+
) => {
|
|
581
|
+
const order = Order.create(cmd.customerId, cmd.items);
|
|
582
|
+
await repository.save(order);
|
|
583
|
+
return ok(order.id);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// Handler typed with QueryHandler for type safety
|
|
587
|
+
const getOrderHandler: QueryHandler<GetOrderQuery, Order | null> = async (
|
|
588
|
+
query
|
|
589
|
+
) => {
|
|
590
|
+
return await repository.getById(query.orderId);
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
// Use with RabbitMQ (or any external framework)
|
|
594
|
+
import amqp from "amqplib";
|
|
595
|
+
|
|
596
|
+
const connection = await amqp.connect("amqp://localhost");
|
|
597
|
+
const channel = await connection.createChannel();
|
|
598
|
+
|
|
599
|
+
// Command handler for RabbitMQ
|
|
600
|
+
channel.consume("order.commands", async (message) => {
|
|
601
|
+
if (!message) return;
|
|
602
|
+
|
|
603
|
+
const command = JSON.parse(message.content.toString()) as CreateOrderCommand;
|
|
604
|
+
const result = await createOrderHandler(command);
|
|
605
|
+
|
|
606
|
+
if (result.ok) {
|
|
607
|
+
channel.ack(message);
|
|
608
|
+
} else {
|
|
609
|
+
channel.nack(message, false, true); // Requeue on error
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Query handler for RabbitMQ
|
|
614
|
+
channel.consume("order.queries", async (message) => {
|
|
615
|
+
if (!message) return;
|
|
616
|
+
|
|
617
|
+
const query = JSON.parse(message.content.toString()) as GetOrderQuery;
|
|
618
|
+
const result = await getOrderHandler(query);
|
|
619
|
+
|
|
620
|
+
channel.sendToQueue(
|
|
621
|
+
message.properties.replyTo,
|
|
622
|
+
Buffer.from(JSON.stringify(result)),
|
|
623
|
+
{ correlationId: message.properties.correlationId }
|
|
624
|
+
);
|
|
625
|
+
channel.ack(message);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Same handlers work with AWS SQS, Kafka, etc.
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
### Using Event Bus for Event Handling
|
|
632
|
+
|
|
633
|
+
The Event Bus provides a pub/sub pattern for handling domain events. Multiple handlers can subscribe to the same event type.
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
636
|
+
import {
|
|
637
|
+
EventBusImpl,
|
|
638
|
+
createDomainEvent,
|
|
639
|
+
type DomainEvent,
|
|
640
|
+
} from "@shirudo/ddd-kit";
|
|
641
|
+
|
|
642
|
+
type OrderCreated = DomainEvent<"OrderCreated", { orderId: string; customerId: string }>;
|
|
643
|
+
type OrderEvent = OrderCreated;
|
|
644
|
+
|
|
645
|
+
// Create event bus
|
|
646
|
+
const eventBus = new EventBusImpl<OrderEvent>();
|
|
647
|
+
|
|
648
|
+
// Subscribe handlers to events
|
|
649
|
+
eventBus.subscribe("OrderCreated", async (event) => {
|
|
650
|
+
await sendEmail(event.payload.customerId);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
eventBus.subscribe("OrderCreated", async (event) => {
|
|
654
|
+
await logEvent(event);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Unsubscribe if needed
|
|
658
|
+
const unsubscribe = eventBus.subscribe("OrderCreated", async (event) => {
|
|
659
|
+
console.log("Order created:", event.payload.orderId);
|
|
660
|
+
});
|
|
661
|
+
// Later: unsubscribe();
|
|
662
|
+
|
|
663
|
+
// Publish events (all subscribed handlers will be called)
|
|
664
|
+
const orderCreated = createDomainEvent("OrderCreated", {
|
|
665
|
+
orderId: "order-123",
|
|
666
|
+
customerId: "customer-456",
|
|
667
|
+
}) as OrderCreated;
|
|
668
|
+
|
|
669
|
+
await eventBus.publish([orderCreated]);
|
|
670
|
+
// Both email and logging handlers will be called
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Creating Events with Metadata for Traceability
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
import {
|
|
677
|
+
createDomainEventWithMetadata,
|
|
678
|
+
copyMetadata,
|
|
679
|
+
type EventMetadata,
|
|
680
|
+
} from "@shirudo/ddd-kit";
|
|
681
|
+
|
|
682
|
+
// Create event with metadata for distributed tracing
|
|
683
|
+
const orderCreated = createDomainEventWithMetadata(
|
|
684
|
+
"OrderCreated",
|
|
685
|
+
{ orderId: "123", customerId: "cust-456" },
|
|
686
|
+
{
|
|
687
|
+
correlationId: "corr-123", // Trace across services
|
|
688
|
+
causationId: "cmd-456", // Parent command/event
|
|
689
|
+
userId: "user-789", // Who triggered it
|
|
690
|
+
source: "order-service", // Service name
|
|
691
|
+
}
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// Create follow-up event maintaining correlation chain
|
|
695
|
+
const orderShipped = createDomainEventWithMetadata(
|
|
696
|
+
"OrderShipped",
|
|
697
|
+
{ orderId: "123", trackingNumber: "TRACK-789" },
|
|
698
|
+
copyMetadata(orderCreated, {
|
|
699
|
+
causationId: orderCreated.type, // New causation
|
|
700
|
+
})
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// Events support versioning for schema evolution
|
|
704
|
+
const eventV1 = createDomainEvent("OrderCreated", { orderId: "123" }, {
|
|
705
|
+
version: 1,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
const eventV2 = createDomainEvent(
|
|
709
|
+
"OrderCreated",
|
|
710
|
+
{ orderId: "123", customerId: "cust-456" }, // Additional field
|
|
711
|
+
{ version: 2 }
|
|
712
|
+
);
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### Working with Nested Entities
|
|
716
|
+
|
|
717
|
+
```typescript
|
|
718
|
+
import {
|
|
719
|
+
AggregateBase,
|
|
720
|
+
createDomainEvent,
|
|
721
|
+
Entity,
|
|
722
|
+
findEntityById,
|
|
723
|
+
hasEntityId,
|
|
724
|
+
removeEntityById,
|
|
725
|
+
sameEntity,
|
|
726
|
+
type Id,
|
|
727
|
+
type DomainEvent,
|
|
728
|
+
} from "@shirudo/ddd-kit";
|
|
729
|
+
|
|
730
|
+
type OrderId = Id<"OrderId">;
|
|
731
|
+
type ItemId = Id<"ItemId">;
|
|
732
|
+
|
|
733
|
+
// Define nested entity
|
|
734
|
+
type OrderItem = Entity<ItemId> & {
|
|
735
|
+
productId: string;
|
|
736
|
+
quantity: number;
|
|
737
|
+
price: number;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
type OrderState = {
|
|
741
|
+
id: OrderId;
|
|
742
|
+
customerId: string;
|
|
743
|
+
items: OrderItem[];
|
|
744
|
+
total: number;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
type ItemAdded = DomainEvent<"ItemAdded", { item: OrderItem }>;
|
|
748
|
+
type ItemRemoved = DomainEvent<"ItemRemoved", { itemId: ItemId }>;
|
|
749
|
+
type OrderEvent = ItemAdded | ItemRemoved;
|
|
750
|
+
|
|
751
|
+
class Order extends AggregateBase<OrderState, OrderEvent, OrderId> {
|
|
752
|
+
static create(id: OrderId, customerId: string): Order {
|
|
753
|
+
const initialState: OrderState = {
|
|
754
|
+
id,
|
|
755
|
+
customerId,
|
|
756
|
+
items: [],
|
|
757
|
+
total: 0,
|
|
758
|
+
};
|
|
759
|
+
const order = new Order(id, initialState);
|
|
760
|
+
const result = order.apply(createDomainEvent("OrderCreated", { customerId }) as any);
|
|
761
|
+
if (!result.ok) {
|
|
762
|
+
throw new Error(result.error);
|
|
763
|
+
}
|
|
764
|
+
return order;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
addItem(item: OrderItem): void {
|
|
768
|
+
if (hasEntityId(this.state.items, item.id)) {
|
|
769
|
+
throw new Error("Item already exists");
|
|
770
|
+
}
|
|
771
|
+
const result = this.apply(createDomainEvent("ItemAdded", { item }) as ItemAdded);
|
|
772
|
+
if (!result.ok) {
|
|
773
|
+
throw new Error(result.error);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
removeItem(itemId: ItemId): void {
|
|
778
|
+
if (!hasEntityId(this.state.items, itemId)) {
|
|
779
|
+
throw new Error("Item not found");
|
|
780
|
+
}
|
|
781
|
+
const result = this.apply(createDomainEvent("ItemRemoved", { itemId }) as ItemRemoved);
|
|
782
|
+
if (!result.ok) {
|
|
783
|
+
throw new Error(result.error);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
getItem(itemId: ItemId): OrderItem | undefined {
|
|
788
|
+
return findEntityById(this.state.items, itemId);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
protected readonly handlers = {
|
|
792
|
+
ItemAdded: (state: OrderState, event: ItemAdded): OrderState => ({
|
|
793
|
+
...state,
|
|
794
|
+
items: [...state.items, event.payload.item],
|
|
795
|
+
total: state.total + event.payload.item.price * event.payload.item.quantity,
|
|
796
|
+
}),
|
|
797
|
+
ItemRemoved: (state: OrderState, event: ItemRemoved): OrderState => {
|
|
798
|
+
const item = findEntityById(state.items, event.payload.itemId);
|
|
799
|
+
if (!item) return state;
|
|
800
|
+
return {
|
|
801
|
+
...state,
|
|
802
|
+
items: removeEntityById(state.items, event.payload.itemId),
|
|
803
|
+
total: state.total - item.price * item.quantity,
|
|
804
|
+
};
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Usage
|
|
810
|
+
const orderId = "order-123" as OrderId;
|
|
811
|
+
const order = Order.create(orderId, "customer-456");
|
|
812
|
+
|
|
813
|
+
const item: OrderItem = {
|
|
814
|
+
id: "item-1" as ItemId,
|
|
815
|
+
productId: "prod-123",
|
|
816
|
+
quantity: 2,
|
|
817
|
+
price: 10.99,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
order.addItem(item);
|
|
821
|
+
const foundItem = order.getItem(item.id);
|
|
822
|
+
console.log(sameEntity(item, foundItem!)); // true
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
### Using Result Type for Error Handling
|
|
826
|
+
|
|
827
|
+
```typescript
|
|
828
|
+
import { ok, err, isOk, isErr, type Result, guard } from "@shirudo/ddd-kit";
|
|
829
|
+
|
|
830
|
+
type UserId = string;
|
|
831
|
+
|
|
832
|
+
function validateUserId(id: string): Result<UserId, string> {
|
|
833
|
+
const validation = guard(id.length > 0, "User ID cannot be empty");
|
|
834
|
+
if (isErr(validation)) {
|
|
835
|
+
return err(validation.error);
|
|
836
|
+
}
|
|
837
|
+
return ok(id as UserId);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function createUser(id: string): Result<{ id: UserId; name: string }, string> {
|
|
841
|
+
const userIdResult = validateUserId(id);
|
|
842
|
+
if (isErr(userIdResult)) {
|
|
843
|
+
return err(userIdResult.error);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return ok({
|
|
847
|
+
id: userIdResult.value,
|
|
848
|
+
name: "John Doe",
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Usage with type guards (recommended)
|
|
853
|
+
const result = createUser("user-123");
|
|
854
|
+
if (isOk(result)) {
|
|
855
|
+
console.log("User created:", result.value); // TypeScript knows result is Ok
|
|
856
|
+
} else {
|
|
857
|
+
console.error("Error:", result.error); // TypeScript knows result is Err
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Usage with ok property (also works)
|
|
861
|
+
const result2 = createUser("user-123");
|
|
862
|
+
if (result2.ok) {
|
|
863
|
+
console.log("User created:", result2.value);
|
|
864
|
+
} else {
|
|
865
|
+
console.error("Error:", result2.error);
|
|
866
|
+
}
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
## API Documentation
|
|
870
|
+
|
|
871
|
+
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`.
|
|
872
|
+
|
|
873
|
+
Key exports include:
|
|
874
|
+
- `vo()`, `voEquals()`, `voWithValidation()`, `voWithValidationUnsafe()` - Value Object utilities
|
|
875
|
+
- `AggregateRoot<TId>` - Marker interface for Aggregate Roots
|
|
876
|
+
- `AggregateBase<TState, TId>` - Base class for aggregates without Event Sourcing (implements `AggregateRoot<TId>`)
|
|
877
|
+
- `AggregateEventSourced<TState, TEvent, TId>` - Base class for Event-Sourced aggregates (extends `AggregateBase`, implements `AggregateRoot<TId>`)
|
|
878
|
+
- `AggregateConfig`, `AggregateEventSourcedConfig` - Configuration interfaces
|
|
879
|
+
- `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
|
|
880
|
+
- `sameAggregate()` - Aggregate equality helper
|
|
881
|
+
- `Entity<TId>` - Optional interface for entities with identity
|
|
882
|
+
- `sameEntity()`, `findEntityById()`, `hasEntityId()`, `removeEntityById()` - Entity helpers
|
|
883
|
+
- `Command`, `CommandHandler<C, R>` - Command interface and handler type for CQRS
|
|
884
|
+
- `Query`, `QueryHandler<Q, R>` - Query interface and handler type for CQRS
|
|
885
|
+
- `CommandBus`, `ICommandBus` - Command bus for centralized command execution
|
|
886
|
+
- `QueryBus`, `IQueryBus` - Query bus for centralized query execution (with `execute()` returning Result and `executeUnsafe()` throwing exceptions)
|
|
887
|
+
- `withCommit()` - Helper for transactional command execution with events
|
|
888
|
+
- `DomainEvent<T, P>`, `EventMetadata` - Domain event interfaces
|
|
889
|
+
- `createDomainEvent()`, `createDomainEventWithMetadata()` - Event creation helpers
|
|
890
|
+
- `copyMetadata()`, `mergeMetadata()` - Metadata utilities
|
|
891
|
+
- `EventBus<Evt>`, `EventBusImpl<Evt>` - Event bus interface and implementation for pub/sub pattern
|
|
892
|
+
- `EventHandler<Evt>` - Event handler function type
|
|
893
|
+
- `EventBus.subscribe()` - Subscribe handlers to event types
|
|
894
|
+
- `EventBus.publish()` - Publish events to all subscribers
|
|
895
|
+
- `Result<T, E>`, `ok()`, `err()`, `isOk()`, `isErr()` - Result type and helpers
|
|
896
|
+
- `Id<Tag>` - Branded ID type
|
|
897
|
+
- `IRepository<TState, TEvent, TAgg, TId>` - Repository interface
|
|
898
|
+
- `ISpecification<T>` - Specification interface
|
|
899
|
+
- `UnitOfWork` - Unit of Work interface
|
|
900
|
+
- `guard()` - Guard/validation helper
|
|
901
|
+
|
|
902
|
+
## TypeScript Support
|
|
903
|
+
|
|
904
|
+
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.
|
|
905
|
+
|
|
906
|
+
## Contributing
|
|
907
|
+
|
|
908
|
+
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).
|
|
40
909
|
|
|
41
910
|
## License
|
|
42
911
|
|
|
43
912
|
This project is licensed under the MIT License.
|
|
913
|
+
|
|
914
|
+
## Author
|
|
915
|
+
|
|
916
|
+
**Shirudo**
|
|
917
|
+
|
|
918
|
+
- GitHub: [@shi-rudo](https://github.com/shi-rudo)
|
|
919
|
+
- Package: [@shirudo/ddd-kit](https://www.npmjs.com/package/@shirudo/ddd-kit)
|
|
920
|
+
- Repository: [ddd-kit-ts](https://github.com/shi-rudo/ddd-kit-ts)
|