@shirudo/ddd-kit 0.16.0 → 1.0.0-rc.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 +61 -75
- package/dist/index.d.ts +324 -318
- package/dist/index.js +881 -1
- package/dist/index.js.map +1 -1
- package/dist/result.js +297 -1
- package/dist/result.js.map +1 -1
- package/dist/utils-array.js +241 -1
- package/dist/utils-array.js.map +1 -1
- package/dist/utils.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Composable TypeScript toolkit for tactical Domain-Driven Design.
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> **Release Candidate**
|
|
6
6
|
>
|
|
7
|
-
> This library is
|
|
7
|
+
> This library is in Release Candidate phase. The API is considered stable and ready for production evaluation. Please report any issues before the final 1.0.0 release.
|
|
8
8
|
|
|
9
9
|
## Badges
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@ Composable TypeScript toolkit for tactical Domain-Driven Design.
|
|
|
20
20
|
- **Repositories** - Persistence abstraction layer for aggregates with specification pattern support
|
|
21
21
|
- **Specifications** - Reusable query specifications for complex domain queries
|
|
22
22
|
- **Unit of Work** - Transaction management for maintaining consistency across operations
|
|
23
|
-
- **Result Type** - Functional error handling with `Result<T, E>` type for explicit success/failure states
|
|
23
|
+
- **Result Type** - Functional error handling with `Result<T, E>` type for explicit success/failure states. For advanced error handling with typed error hierarchies, see [`@shirudo/base-error`](https://www.npmjs.com/package/@shirudo/base-error)
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
26
26
|
|
|
@@ -72,7 +72,7 @@ In Domain-Driven Design, Entities are objects with identity and state. Unlike Va
|
|
|
72
72
|
- Has identity (id), state, and version for optimistic concurrency control
|
|
73
73
|
- Represents the aggregate externally
|
|
74
74
|
- Loaded/saved through repositories
|
|
75
|
-
- Created by extending `AggregateRoot` or `
|
|
75
|
+
- Created by extending `AggregateRoot` (state-based) or `EventSourcedAggregate` (event-sourced)
|
|
76
76
|
- Implements `IAggregateRoot<TId>`
|
|
77
77
|
|
|
78
78
|
2. **Child Entities**: Entities within an aggregate.
|
|
@@ -107,7 +107,7 @@ The library provides:
|
|
|
107
107
|
|
|
108
108
|
- **`AggregateRoot<TState, TId, TEvent?>`** - Base class for creating Aggregate Root Entities without Event Sourcing. Implements `IAggregateRoot<TId>`. The optional `TEvent` parameter (defaults to `unknown`) enables type-safe domain events — only aggregates that specify it get compile-time event validation. Provides ID and version management, state management, domain event tracking, and snapshot support. Use this when you don't need Event Sourcing but still want aggregate patterns with versioning and state management.
|
|
109
109
|
|
|
110
|
-
- **`
|
|
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
111
|
|
|
112
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
113
|
|
|
@@ -117,7 +117,7 @@ CQRS separates read operations (Queries) from write operations (Commands), provi
|
|
|
117
117
|
|
|
118
118
|
### Domain Events
|
|
119
119
|
|
|
120
|
-
Domain Events represent something meaningful that happened in your domain. They are immutable records with a type, payload, timestamp,
|
|
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
121
|
|
|
122
122
|
### Repositories
|
|
123
123
|
|
|
@@ -247,32 +247,29 @@ class Order extends AggregateRoot<OrderState, OrderId> {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
addItem(productId: string, quantity: number, price: number): void {
|
|
250
|
-
if (this.
|
|
250
|
+
if (this.state.status !== "pending") {
|
|
251
251
|
throw new Error("Cannot add items to a non-pending order");
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
this.
|
|
255
|
-
...this.
|
|
256
|
-
items: [...this.
|
|
257
|
-
total: this.
|
|
258
|
-
};
|
|
259
|
-
this.bumpVersion(); // Manual version bump for optimistic concurrency control
|
|
254
|
+
this.setState({
|
|
255
|
+
...this.state,
|
|
256
|
+
items: [...this.state.items, { productId, quantity, price }],
|
|
257
|
+
total: this.state.total + quantity * price,
|
|
258
|
+
}, true); // true = bump version for optimistic concurrency control
|
|
260
259
|
}
|
|
261
260
|
|
|
262
261
|
confirm(): void {
|
|
263
|
-
if (this.
|
|
262
|
+
if (this.state.status !== "pending") {
|
|
264
263
|
throw new Error("Only pending orders can be confirmed");
|
|
265
264
|
}
|
|
266
|
-
this.
|
|
267
|
-
this.bumpVersion();
|
|
265
|
+
this.setState({ ...this.state, status: "confirmed" }, true);
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
ship(): void {
|
|
271
|
-
if (this.
|
|
269
|
+
if (this.state.status !== "confirmed") {
|
|
272
270
|
throw new Error("Only confirmed orders can be shipped");
|
|
273
271
|
}
|
|
274
|
-
this.
|
|
275
|
-
this.bumpVersion();
|
|
272
|
+
this.setState({ ...this.state, status: "shipped" }, true);
|
|
276
273
|
}
|
|
277
274
|
}
|
|
278
275
|
|
|
@@ -297,15 +294,13 @@ type OrderDomainEvent =
|
|
|
297
294
|
|
|
298
295
|
class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
|
|
299
296
|
confirm(): void {
|
|
300
|
-
this.
|
|
297
|
+
this.setState({ ...this.state, status: "confirmed" }, true);
|
|
301
298
|
this.addDomainEvent({ type: "OrderConfirmed" }); // type-safe
|
|
302
|
-
this.bumpVersion();
|
|
303
299
|
}
|
|
304
300
|
|
|
305
301
|
ship(trackingNumber: string): void {
|
|
306
|
-
this.
|
|
302
|
+
this.setState({ ...this.state, status: "shipped" }, true);
|
|
307
303
|
this.addDomainEvent({ type: "OrderShipped", trackingNumber }); // type-safe
|
|
308
|
-
this.bumpVersion();
|
|
309
304
|
}
|
|
310
305
|
}
|
|
311
306
|
|
|
@@ -317,7 +312,7 @@ class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
|
|
|
317
312
|
|
|
318
313
|
```typescript
|
|
319
314
|
import {
|
|
320
|
-
|
|
315
|
+
EventSourcedAggregate,
|
|
321
316
|
createDomainEvent,
|
|
322
317
|
type AggregateRoot,
|
|
323
318
|
type Id,
|
|
@@ -339,7 +334,7 @@ type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
|
|
|
339
334
|
|
|
340
335
|
type OrderEvent = OrderCreated | OrderConfirmed | OrderShipped;
|
|
341
336
|
|
|
342
|
-
class Order extends
|
|
337
|
+
class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
|
|
343
338
|
static create(id: OrderId, customerId: string): Order {
|
|
344
339
|
const initialState: OrderState = {
|
|
345
340
|
id,
|
|
@@ -417,8 +412,8 @@ console.log(order.version); // 3 (automatically bumped)
|
|
|
417
412
|
```typescript
|
|
418
413
|
import {
|
|
419
414
|
AggregateRoot,
|
|
420
|
-
|
|
421
|
-
|
|
415
|
+
EventSourcedAggregate,
|
|
416
|
+
sameVersion,
|
|
422
417
|
type Id,
|
|
423
418
|
} from "@shirudo/ddd-kit";
|
|
424
419
|
|
|
@@ -441,11 +436,11 @@ const eventSourcedOrder = EventSourcedOrder.create("order-123" as OrderId, "cust
|
|
|
441
436
|
const eventsAfterSnapshot = [/* events that occurred after snapshot */];
|
|
442
437
|
eventSourcedOrder.restoreFromSnapshotWithEvents(snapshot, eventsAfterSnapshot);
|
|
443
438
|
|
|
444
|
-
//
|
|
439
|
+
// Optimistic concurrency check
|
|
445
440
|
const order1 = await repository.getById(id);
|
|
446
441
|
// ... some operations ...
|
|
447
442
|
const order2 = await repository.getById(id);
|
|
448
|
-
if (!
|
|
443
|
+
if (!sameVersion(order1, order2)) {
|
|
449
444
|
throw new Error("Aggregate was modified by another process");
|
|
450
445
|
}
|
|
451
446
|
```
|
|
@@ -454,7 +449,7 @@ if (!sameAggregate(order1, order2)) {
|
|
|
454
449
|
|
|
455
450
|
```typescript
|
|
456
451
|
import {
|
|
457
|
-
|
|
452
|
+
EventSourcedAggregate,
|
|
458
453
|
createDomainEvent,
|
|
459
454
|
err,
|
|
460
455
|
ok,
|
|
@@ -469,7 +464,7 @@ type OrderState = { id: OrderId; status: "pending" | "confirmed" | "shipped" };
|
|
|
469
464
|
type OrderShipped = DomainEvent<"OrderShipped", { trackingNumber: string }>;
|
|
470
465
|
type OrderEvent = OrderShipped;
|
|
471
466
|
|
|
472
|
-
class Order extends
|
|
467
|
+
class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
|
|
473
468
|
// Event validation
|
|
474
469
|
protected validateEvent(event: OrderEvent): Result<true, string> {
|
|
475
470
|
if (event.type === "OrderShipped" && this.state.status !== "confirmed") {
|
|
@@ -892,49 +887,42 @@ class Order extends AggregateRoot<OrderState, OrderId>
|
|
|
892
887
|
price,
|
|
893
888
|
};
|
|
894
889
|
|
|
895
|
-
this.
|
|
896
|
-
...this.
|
|
897
|
-
items: [...this.
|
|
898
|
-
total: this.
|
|
899
|
-
};
|
|
900
|
-
this.bumpVersion(); // Versions the entire aggregate (including child entities)
|
|
890
|
+
this.setState({
|
|
891
|
+
...this.state,
|
|
892
|
+
items: [...this.state.items, item],
|
|
893
|
+
total: this.state.total + price * quantity,
|
|
894
|
+
}, true); // true = bump version (versions the entire aggregate including child entities)
|
|
901
895
|
return itemId;
|
|
902
896
|
}
|
|
903
897
|
|
|
904
898
|
updateItemQuantity(itemId: ItemId, newQuantity: number): void {
|
|
905
|
-
const item = findEntityById(this.
|
|
899
|
+
const item = findEntityById(this.state.items, itemId);
|
|
906
900
|
if (!item) {
|
|
907
901
|
throw new Error("Item not found");
|
|
908
902
|
}
|
|
909
903
|
|
|
910
|
-
this.
|
|
911
|
-
...this.
|
|
912
|
-
items: updateEntityById(
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
(i) => ({ ...i, quantity: newQuantity })
|
|
916
|
-
),
|
|
917
|
-
total: this._state.total - item.price * item.quantity + item.price * newQuantity,
|
|
918
|
-
};
|
|
919
|
-
this.bumpVersion(); // Versions the entire aggregate
|
|
904
|
+
this.setState({
|
|
905
|
+
...this.state,
|
|
906
|
+
items: updateEntityById(this.state.items, itemId, (i) => ({ ...i, quantity: newQuantity })),
|
|
907
|
+
total: this.state.total - item.price * item.quantity + item.price * newQuantity,
|
|
908
|
+
}, true);
|
|
920
909
|
}
|
|
921
910
|
|
|
922
911
|
removeItem(itemId: ItemId): void {
|
|
923
|
-
const item = findEntityById(this.
|
|
912
|
+
const item = findEntityById(this.state.items, itemId);
|
|
924
913
|
if (!item) {
|
|
925
914
|
throw new Error("Item not found");
|
|
926
915
|
}
|
|
927
916
|
|
|
928
|
-
this.
|
|
929
|
-
...this.
|
|
930
|
-
items: removeEntityById(this.
|
|
931
|
-
total: this.
|
|
932
|
-
};
|
|
933
|
-
this.bumpVersion(); // Versions the entire aggregate
|
|
917
|
+
this.setState({
|
|
918
|
+
...this.state,
|
|
919
|
+
items: removeEntityById(this.state.items, itemId),
|
|
920
|
+
total: this.state.total - item.price * item.quantity,
|
|
921
|
+
}, true);
|
|
934
922
|
}
|
|
935
923
|
|
|
936
924
|
getItem(itemId: ItemId): OrderItem | undefined {
|
|
937
|
-
return findEntityById(this.
|
|
925
|
+
return findEntityById(this.state.items, itemId);
|
|
938
926
|
}
|
|
939
927
|
}
|
|
940
928
|
|
|
@@ -983,15 +971,15 @@ class OrderItem extends Entity<OrderItemState, ItemId> {
|
|
|
983
971
|
if (newQuantity <= 0) {
|
|
984
972
|
throw new Error("Quantity must be greater than 0");
|
|
985
973
|
}
|
|
986
|
-
this.
|
|
974
|
+
this.setState({ ...this.state, quantity: newQuantity });
|
|
987
975
|
}
|
|
988
976
|
|
|
989
977
|
calculateSubtotal(): number {
|
|
990
|
-
return this.
|
|
978
|
+
return this.state.price * this.state.quantity;
|
|
991
979
|
}
|
|
992
980
|
|
|
993
981
|
isForProduct(productId: string): boolean {
|
|
994
|
-
return this.
|
|
982
|
+
return this.state.productId === productId;
|
|
995
983
|
}
|
|
996
984
|
|
|
997
985
|
protected validateState(state: OrderItemState): void {
|
|
@@ -1028,17 +1016,16 @@ class Order extends AggregateRoot<OrderState, OrderId>
|
|
|
1028
1016
|
const itemId = `item-${++this.itemCounter}` as ItemId;
|
|
1029
1017
|
const item = new OrderItem(itemId, productId, quantity, price);
|
|
1030
1018
|
|
|
1031
|
-
this.
|
|
1032
|
-
...this.
|
|
1033
|
-
items: [...this.
|
|
1034
|
-
};
|
|
1035
|
-
this.bumpVersion();
|
|
1019
|
+
this.setState({
|
|
1020
|
+
...this.state,
|
|
1021
|
+
items: [...this.state.items, item],
|
|
1022
|
+
}, true);
|
|
1036
1023
|
return itemId;
|
|
1037
1024
|
}
|
|
1038
1025
|
|
|
1039
1026
|
// Delegate to entity's business logic
|
|
1040
1027
|
updateItemQuantity(itemId: ItemId, newQuantity: number): void {
|
|
1041
|
-
const item = findEntityById(this.
|
|
1028
|
+
const item = findEntityById(this.state.items, itemId);
|
|
1042
1029
|
if (!item) throw new Error("Item not found");
|
|
1043
1030
|
|
|
1044
1031
|
item.updateQuantity(newQuantity); // Uses entity's logic
|
|
@@ -1047,18 +1034,17 @@ class Order extends AggregateRoot<OrderState, OrderId>
|
|
|
1047
1034
|
|
|
1048
1035
|
// Use entity's business logic
|
|
1049
1036
|
calculateTotal(): number {
|
|
1050
|
-
return this.
|
|
1037
|
+
return this.state.items.reduce(
|
|
1051
1038
|
(total, item) => total + item.calculateSubtotal(),
|
|
1052
1039
|
0
|
|
1053
1040
|
);
|
|
1054
1041
|
}
|
|
1055
1042
|
|
|
1056
1043
|
confirm(): void {
|
|
1057
|
-
if (this.
|
|
1044
|
+
if (this.state.items.length === 0) {
|
|
1058
1045
|
throw new Error("Cannot confirm an order without items");
|
|
1059
1046
|
}
|
|
1060
|
-
this.
|
|
1061
|
-
this.bumpVersion();
|
|
1047
|
+
this.setState({ ...this.state, status: "confirmed" }, true);
|
|
1062
1048
|
}
|
|
1063
1049
|
}
|
|
1064
1050
|
|
|
@@ -1201,10 +1187,10 @@ Key exports include:
|
|
|
1201
1187
|
- `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()`, `voWithValidationUnsafe()` - Value Object utilities
|
|
1202
1188
|
- `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
|
|
1203
1189
|
- `AggregateRoot<TState, TId, TEvent?>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId>`). Optional `TEvent` parameter enables type-safe domain events
|
|
1204
|
-
- `
|
|
1205
|
-
- `AggregateConfig`, `
|
|
1190
|
+
- `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
|
|
1191
|
+
- `AggregateConfig`, `EventSourcedAggregateConfig` - Configuration interfaces
|
|
1206
1192
|
- `AggregateSnapshot<TState>` - Snapshot interface for performance optimization
|
|
1207
|
-
- `
|
|
1193
|
+
- `sameVersion()` - Optimistic concurrency check (same ID and version)
|
|
1208
1194
|
- `Entity<TState, TId>` - Base class for entities with state and business logic
|
|
1209
1195
|
- `IEntity<TId, TState>` - Entity interface
|
|
1210
1196
|
- `Identifiable<TId>` - Minimal interface for objects with id
|
|
@@ -1228,7 +1214,7 @@ Key exports include:
|
|
|
1228
1214
|
- `andThen()`, `map()`, `mapErr()` - Result composition utilities
|
|
1229
1215
|
- `unwrapOr()`, `unwrapOrElse()`, `match()` - Result unwrapping and pattern matching
|
|
1230
1216
|
- `Id<Tag>` - Branded ID type
|
|
1231
|
-
- `IRepository<
|
|
1217
|
+
- `IRepository<TAgg, TId>` - Repository interface
|
|
1232
1218
|
- `ISpecification<T>` - Specification interface
|
|
1233
1219
|
- `UnitOfWork` - Unit of Work interface
|
|
1234
1220
|
- `guard()` - Guard/validation helper
|
|
@@ -1349,7 +1335,7 @@ class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId>
|
|
|
1349
1335
|
|
|
1350
1336
|
// 3. Save
|
|
1351
1337
|
await this.repository.save(order);
|
|
1352
|
-
await this.eventBus.publish(order.
|
|
1338
|
+
await this.eventBus.publish(order.domainEvents);
|
|
1353
1339
|
|
|
1354
1340
|
return ok(order.id);
|
|
1355
1341
|
// 4. Aggregate is garbage collected when method returns
|
|
@@ -1556,7 +1542,7 @@ class OrderService {
|
|
|
1556
1542
|
}
|
|
1557
1543
|
|
|
1558
1544
|
await this.repository.save(order);
|
|
1559
|
-
await this.eventBus.publish(order.
|
|
1545
|
+
await this.eventBus.publish(order.domainEvents);
|
|
1560
1546
|
|
|
1561
1547
|
return ok(order.id);
|
|
1562
1548
|
// order is garbage collected here
|