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