@shirudo/ddd-kit 0.14.0 → 0.15.0
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 +374 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1197,6 +1197,380 @@ Key exports include:
|
|
|
1197
1197
|
- `UnitOfWork` - Unit of Work interface
|
|
1198
1198
|
- `guard()` - Guard/validation helper
|
|
1199
1199
|
|
|
1200
|
+
## Concurrency & Thread Safety
|
|
1201
|
+
|
|
1202
|
+
### Understanding "Operations" in Different Contexts
|
|
1203
|
+
|
|
1204
|
+
When we talk about **operations** or **executions**, we mean:
|
|
1205
|
+
|
|
1206
|
+
1. **HTTP Request** - In a web API: One incoming HTTP request (GET, POST, etc.)
|
|
1207
|
+
2. **Command Execution** - In CQRS: Execution of a single command (CreateOrder, UpdateQuantity, etc.)
|
|
1208
|
+
3. **Query Execution** - In CQRS: Execution of a single query (GetOrder, ListOrders, etc.)
|
|
1209
|
+
4. **Background Job** - Asynchronous task processing (email sending, report generation, etc.)
|
|
1210
|
+
5. **Event Handler** - Processing of a single domain event
|
|
1211
|
+
|
|
1212
|
+
**Key principle**: Each operation should load fresh aggregate instances, make changes, and save them. Never share aggregate instances across operations.
|
|
1213
|
+
|
|
1214
|
+
### The Problem: Race Conditions with Shared State
|
|
1215
|
+
|
|
1216
|
+
JavaScript is single-threaded, but `async/await` creates concurrency risks:
|
|
1217
|
+
|
|
1218
|
+
```typescript
|
|
1219
|
+
// ❌ DANGEROUS - Race Condition!
|
|
1220
|
+
class OrderService {
|
|
1221
|
+
private cachedOrder: Order; // NEVER cache aggregates!
|
|
1222
|
+
|
|
1223
|
+
async updateQuantity(itemId: ItemId, quantity: number) {
|
|
1224
|
+
// Request 1 reads quantity = 5
|
|
1225
|
+
const item = this.cachedOrder.getItem(itemId);
|
|
1226
|
+
const oldQty = item.state.quantity; // 5
|
|
1227
|
+
|
|
1228
|
+
await someAsyncOperation(); // ⚠️ Context switch here!
|
|
1229
|
+
|
|
1230
|
+
// Request 2 updates quantity to 10 while we wait
|
|
1231
|
+
// Request 1 continues with stale data
|
|
1232
|
+
item.updateQuantity(oldQty + 1); // Writes 6, should be 11!
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
**Why this happens:**
|
|
1238
|
+
- `await` yields control to event loop
|
|
1239
|
+
- Other async operations can run
|
|
1240
|
+
- Your aggregate instance has stale data
|
|
1241
|
+
- Last write wins (data loss!)
|
|
1242
|
+
|
|
1243
|
+
### ✅ Solution 1: Operation-Scoped Aggregates (Recommended)
|
|
1244
|
+
|
|
1245
|
+
**Pattern**: Each operation gets its own aggregate instance. Load → Mutate → Save → Discard.
|
|
1246
|
+
|
|
1247
|
+
This works the **SAME** for both function handlers and class-based handlers!
|
|
1248
|
+
|
|
1249
|
+
#### Approach A: Function-Based Handlers (Simple)
|
|
1250
|
+
|
|
1251
|
+
```typescript
|
|
1252
|
+
// ✅ SAFE - Fresh instance per operation
|
|
1253
|
+
async function updateOrderQuantity(
|
|
1254
|
+
orderId: OrderId,
|
|
1255
|
+
itemId: ItemId,
|
|
1256
|
+
quantity: number
|
|
1257
|
+
) {
|
|
1258
|
+
// 1. Load fresh from database
|
|
1259
|
+
const order = await repository.getById(orderId);
|
|
1260
|
+
|
|
1261
|
+
// 2. Make ALL changes synchronously (no await!)
|
|
1262
|
+
const item = order.getItem(itemId);
|
|
1263
|
+
item.updateQuantity(quantity);
|
|
1264
|
+
order.recalculateTotal();
|
|
1265
|
+
|
|
1266
|
+
// 3. Save with optimistic locking
|
|
1267
|
+
await repository.save(order); // Throws if version mismatch
|
|
1268
|
+
|
|
1269
|
+
// 4. Instance is garbage collected (no shared state)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ✅ SAFE - Command Handler function
|
|
1273
|
+
async function createOrderHandler(cmd: CreateOrderCommand) {
|
|
1274
|
+
const orderId = generateId() as OrderId;
|
|
1275
|
+
const order = Order.create(orderId, cmd.customerId);
|
|
1276
|
+
|
|
1277
|
+
// All mutations synchronous
|
|
1278
|
+
for (const item of cmd.items) {
|
|
1279
|
+
order.addItem(item.productId, item.quantity, item.price);
|
|
1280
|
+
}
|
|
1281
|
+
order.confirm();
|
|
1282
|
+
|
|
1283
|
+
await repository.save(order);
|
|
1284
|
+
return order.id;
|
|
1285
|
+
}
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
#### Approach B: Class-Based Handlers (MUST be Stateless!)
|
|
1289
|
+
|
|
1290
|
+
The key difference with classes: **Dependencies in constructor, aggregates in methods**.
|
|
1291
|
+
|
|
1292
|
+
```typescript
|
|
1293
|
+
// ✅ SAFE - Stateless handler class
|
|
1294
|
+
class CreateOrderHandler implements CommandHandler<CreateOrderCommand, OrderId> {
|
|
1295
|
+
constructor(
|
|
1296
|
+
private readonly repository: OrderRepository,
|
|
1297
|
+
private readonly eventBus: EventBus
|
|
1298
|
+
) {
|
|
1299
|
+
// ✅ Only infrastructure dependencies here!
|
|
1300
|
+
// ❌ NEVER store aggregates here!
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
async execute(cmd: CreateOrderCommand): Promise<Result<OrderId, string>> {
|
|
1304
|
+
// 1. Aggregate is LOCAL to this method call
|
|
1305
|
+
const orderId = generateId() as OrderId;
|
|
1306
|
+
const order = Order.create(orderId, cmd.customerId);
|
|
1307
|
+
|
|
1308
|
+
// 2. All mutations synchronous
|
|
1309
|
+
for (const item of cmd.items) {
|
|
1310
|
+
order.addItem(item.productId, item.quantity, item.price);
|
|
1311
|
+
}
|
|
1312
|
+
order.confirm();
|
|
1313
|
+
|
|
1314
|
+
// 3. Save
|
|
1315
|
+
await this.repository.save(order);
|
|
1316
|
+
await this.eventBus.publish(order.pendingEvents);
|
|
1317
|
+
|
|
1318
|
+
return ok(order.id);
|
|
1319
|
+
// 4. Aggregate is garbage collected when method returns
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ✅ SAFE - Another handler instance
|
|
1324
|
+
class UpdateOrderQuantityHandler {
|
|
1325
|
+
constructor(private readonly repository: OrderRepository) {}
|
|
1326
|
+
|
|
1327
|
+
async execute(cmd: UpdateQuantityCommand): Promise<Result<void, string>> {
|
|
1328
|
+
// Fresh load per call
|
|
1329
|
+
const order = await this.repository.getById(cmd.orderId);
|
|
1330
|
+
|
|
1331
|
+
order.updateItemQuantity(cmd.itemId, cmd.quantity);
|
|
1332
|
+
|
|
1333
|
+
await this.repository.save(order);
|
|
1334
|
+
return ok(undefined);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Usage - Handler instances are singletons, but aggregates are not!
|
|
1339
|
+
const handler = new CreateOrderHandler(repository, eventBus);
|
|
1340
|
+
|
|
1341
|
+
// Each call gets fresh aggregate
|
|
1342
|
+
await handler.execute(cmd1); // order1 created and discarded
|
|
1343
|
+
await handler.execute(cmd2); // order2 created and discarded
|
|
1344
|
+
await handler.execute(cmd3); // order3 created and discarded
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
#### ❌ DANGEROUS: Stateful Handler Class
|
|
1348
|
+
|
|
1349
|
+
```typescript
|
|
1350
|
+
// ❌ DANGEROUS - Storing aggregates in class fields!
|
|
1351
|
+
class OrderService {
|
|
1352
|
+
private currentOrder: Order; // NEVER DO THIS!
|
|
1353
|
+
private orderCache = new Map<OrderId, Order>(); // NEVER!
|
|
1354
|
+
|
|
1355
|
+
constructor(private readonly repository: OrderRepository) {}
|
|
1356
|
+
|
|
1357
|
+
async loadOrder(orderId: OrderId) {
|
|
1358
|
+
this.currentOrder = await this.repository.getById(orderId);
|
|
1359
|
+
// ❌ Stored in instance field - shared across operations!
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async updateQuantity(itemId: ItemId, quantity: number) {
|
|
1363
|
+
// ❌ Using shared state from previous operation
|
|
1364
|
+
this.currentOrder.updateItemQuantity(itemId, quantity);
|
|
1365
|
+
// Race condition if another request called loadOrder()!
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
```
|
|
1369
|
+
|
|
1370
|
+
#### The Key Difference
|
|
1371
|
+
|
|
1372
|
+
| | Function Handlers | Class Handlers |
|
|
1373
|
+
|---|---|---|
|
|
1374
|
+
| **Handler Instance** | Created per call | Singleton (DI container) |
|
|
1375
|
+
| **Aggregate Instance** | Local variable | MUST be local variable in method |
|
|
1376
|
+
| **Dependencies** | Parameters | Constructor injection |
|
|
1377
|
+
| **Risk** | Low (naturally scoped) | Medium (tempting to store in fields) |
|
|
1378
|
+
|
|
1379
|
+
**Important**:
|
|
1380
|
+
- ✅ Handler **class** can be singleton
|
|
1381
|
+
- ❌ Aggregate **instance** must NEVER be stored in handler class
|
|
1382
|
+
- ✅ Aggregates are **always** local to method execution
|
|
1383
|
+
|
|
1384
|
+
**Rules for safe aggregate usage (applies to BOTH):**
|
|
1385
|
+
1. ✅ Load aggregate at start of operation (method call)
|
|
1386
|
+
2. ✅ All mutations synchronous (no `await` between state changes)
|
|
1387
|
+
3. ✅ Save at end of operation
|
|
1388
|
+
4. ✅ Let garbage collector clean up
|
|
1389
|
+
5. ❌ Never store aggregates in class fields (if using classes)
|
|
1390
|
+
6. ❌ Never cache aggregates between operations
|
|
1391
|
+
7. ❌ Never pass aggregates between operations
|
|
1392
|
+
|
|
1393
|
+
### ✅ Solution 2: Optimistic Locking (Already Built-in!)
|
|
1394
|
+
|
|
1395
|
+
Your `AggregateRoot` includes a `version` field for Optimistic Concurrency Control:
|
|
1396
|
+
|
|
1397
|
+
```typescript
|
|
1398
|
+
// Repository implementation with optimistic locking
|
|
1399
|
+
class OrderRepository {
|
|
1400
|
+
async save(order: Order): Promise<void> {
|
|
1401
|
+
const current = await db.orders.findOne({ id: order.id });
|
|
1402
|
+
|
|
1403
|
+
// Check if someone else modified it
|
|
1404
|
+
if (current && current.version !== order.version) {
|
|
1405
|
+
throw new ConcurrencyError(
|
|
1406
|
+
`Order ${order.id} was modified by another operation. ` +
|
|
1407
|
+
`Expected version ${order.version}, but found ${current.version}`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Save with incremented version
|
|
1412
|
+
await db.orders.update({
|
|
1413
|
+
id: order.id,
|
|
1414
|
+
...order.state,
|
|
1415
|
+
version: order.version + 1 // Increment version
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Usage - retry on conflict
|
|
1421
|
+
async function updateOrderWithRetry(orderId: OrderId, itemId: ItemId, qty: number) {
|
|
1422
|
+
const maxRetries = 3;
|
|
1423
|
+
|
|
1424
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1425
|
+
try {
|
|
1426
|
+
const order = await repository.getById(orderId);
|
|
1427
|
+
order.updateItemQuantity(itemId, qty);
|
|
1428
|
+
await repository.save(order);
|
|
1429
|
+
return; // Success!
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
if (error instanceof ConcurrencyError && attempt < maxRetries - 1) {
|
|
1432
|
+
// Retry with fresh data
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
throw error;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
### ✅ Solution 3: Unit of Work Pattern
|
|
1442
|
+
|
|
1443
|
+
Use transactions to ensure consistency:
|
|
1444
|
+
|
|
1445
|
+
```typescript
|
|
1446
|
+
import { withCommit } from "@shirudo/ddd-kit";
|
|
1447
|
+
|
|
1448
|
+
async function createOrderCommand(cmd: CreateOrderCommand) {
|
|
1449
|
+
return await withCommit({ uow, eventBus, outbox }, async () => {
|
|
1450
|
+
const orderId = generateId() as OrderId;
|
|
1451
|
+
const order = Order.create(orderId, cmd.customerId);
|
|
1452
|
+
|
|
1453
|
+
// All synchronous mutations within transaction
|
|
1454
|
+
for (const item of cmd.items) {
|
|
1455
|
+
order.addItem(item.productId, item.quantity, item.price);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
await repository.save(order);
|
|
1459
|
+
|
|
1460
|
+
return {
|
|
1461
|
+
result: order.id,
|
|
1462
|
+
events: order.pendingEvents // Published atomically
|
|
1463
|
+
};
|
|
1464
|
+
}); // Commits or rollbacks everything
|
|
1465
|
+
}
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
### Safe Async Patterns
|
|
1469
|
+
|
|
1470
|
+
```typescript
|
|
1471
|
+
// ✅ SAFE - Async I/O BEFORE mutations
|
|
1472
|
+
async function processOrder(orderId: OrderId) {
|
|
1473
|
+
// 1. Do all async I/O first
|
|
1474
|
+
const order = await repository.getById(orderId);
|
|
1475
|
+
const pricing = await pricingService.getPrices(order.state.items);
|
|
1476
|
+
const inventory = await inventoryService.check(order.state.items);
|
|
1477
|
+
|
|
1478
|
+
// 2. Then do all mutations synchronously
|
|
1479
|
+
if (inventory.available) {
|
|
1480
|
+
order.confirm();
|
|
1481
|
+
for (const [itemId, price] of pricing) {
|
|
1482
|
+
order.updateItemPrice(itemId, price);
|
|
1483
|
+
}
|
|
1484
|
+
} else {
|
|
1485
|
+
order.cancel();
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// 3. Single save at end
|
|
1489
|
+
await repository.save(order);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// ❌ DANGEROUS - Interleaved async/mutations
|
|
1493
|
+
async function processOrderWrong(orderId: OrderId) {
|
|
1494
|
+
const order = await repository.getById(orderId);
|
|
1495
|
+
|
|
1496
|
+
order.confirm(); // Mutation
|
|
1497
|
+
await inventoryService.reserve(order.id); // ⚠️ Yield point!
|
|
1498
|
+
order.addItem(...); // Another operation might have modified order!
|
|
1499
|
+
|
|
1500
|
+
await repository.save(order);
|
|
1501
|
+
}
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
### Stateless Services Pattern
|
|
1505
|
+
|
|
1506
|
+
```typescript
|
|
1507
|
+
// ✅ SAFE - Stateless service, aggregates are local
|
|
1508
|
+
class OrderService {
|
|
1509
|
+
constructor(
|
|
1510
|
+
private readonly repository: OrderRepository,
|
|
1511
|
+
private readonly eventBus: EventBus
|
|
1512
|
+
) {}
|
|
1513
|
+
|
|
1514
|
+
async createOrder(cmd: CreateOrderCommand): Promise<Result<OrderId, string>> {
|
|
1515
|
+
// Fresh instance per call
|
|
1516
|
+
const order = Order.create(generateId(), cmd.customerId);
|
|
1517
|
+
|
|
1518
|
+
for (const item of cmd.items) {
|
|
1519
|
+
order.addItem(item.productId, item.quantity, item.price);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
await this.repository.save(order);
|
|
1523
|
+
await this.eventBus.publish(order.pendingEvents);
|
|
1524
|
+
|
|
1525
|
+
return ok(order.id);
|
|
1526
|
+
// order is garbage collected here
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// ❌ DANGEROUS - Stateful service
|
|
1531
|
+
class OrderServiceBad {
|
|
1532
|
+
private orders = new Map<OrderId, Order>(); // NEVER!
|
|
1533
|
+
|
|
1534
|
+
async updateOrder(orderId: OrderId) {
|
|
1535
|
+
const order = this.orders.get(orderId); // Shared mutable state!
|
|
1536
|
+
// Race conditions everywhere!
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
### Multi-Tenant Considerations
|
|
1542
|
+
|
|
1543
|
+
Even in single-threaded JavaScript, concurrent operations are real:
|
|
1544
|
+
|
|
1545
|
+
```typescript
|
|
1546
|
+
// Scenario: Two users updating same order simultaneously
|
|
1547
|
+
// Time | Request A (User 1) | Request B (User 2)
|
|
1548
|
+
// ------|------------------------------|---------------------------
|
|
1549
|
+
// T1 | order = load(id) v=1 |
|
|
1550
|
+
// T2 | | order = load(id) v=1
|
|
1551
|
+
// T3 | order.addItem(...) |
|
|
1552
|
+
// T4 | | order.updateQty(...)
|
|
1553
|
+
// T5 | save(order) → v=2 ✅ |
|
|
1554
|
+
// T6 | | save(order) → v=1 ❌ Error!
|
|
1555
|
+
|
|
1556
|
+
// With optimistic locking:
|
|
1557
|
+
// Request B fails with ConcurrencyError
|
|
1558
|
+
// Client retries with fresh data
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
### Summary: Concurrency Best Practices
|
|
1562
|
+
|
|
1563
|
+
| ✅ DO | ❌ DON'T |
|
|
1564
|
+
|-------|----------|
|
|
1565
|
+
| Load aggregate per operation | Cache aggregates in memory |
|
|
1566
|
+
| All mutations synchronous | Mix async I/O with mutations |
|
|
1567
|
+
| Use optimistic locking | Assume single-threaded = safe |
|
|
1568
|
+
| Operation-scoped instances | Share instances across operations |
|
|
1569
|
+
| Stateless services | Stateful services with aggregates |
|
|
1570
|
+
| Retry on concurrency errors | Ignore version conflicts |
|
|
1571
|
+
|
|
1572
|
+
**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.
|
|
1573
|
+
|
|
1200
1574
|
## TypeScript Support
|
|
1201
1575
|
|
|
1202
1576
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shirudo/ddd-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Composable TypeScript toolkit for tactical DDD",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"clean": "rm -rf dist",
|
|
37
37
|
"lint": "biome lint .",
|
|
38
38
|
"format": "pnpm exec biome format --write . && pnpm exec biome lint --write . && pnpm exec biome check --write .",
|
|
39
|
-
"typecheck": "tsc --noEmit"
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"prepublishOnly": "npm run clean && vitest run && npm run build"
|
|
40
41
|
},
|
|
41
42
|
"keywords": [
|
|
42
43
|
"ddd",
|