@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.
Files changed (2) hide show
  1. package/README.md +374 -0
  2. 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.14.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",