@vytches/ddd-aggregates 0.26.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 ADDED
@@ -0,0 +1,1393 @@
1
+ # @vytches/ddd-aggregates
2
+
3
+ [![npm version](https://badge.fury.io/js/%40vytches%2Fddd-aggregates.svg)](https://badge.fury.io/js/%40vytches%2Fddd-aggregates)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue?logo=typescript)](https://www.typescriptlang.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ > **Enterprise-grade aggregate root implementations for Domain-Driven Design**
8
+
9
+ Complete aggregate root implementation with built-in event sourcing, capability
10
+ system, and automatic event management. Designed for complex domain models with
11
+ strong consistency boundaries.
12
+
13
+ ## ๐Ÿ“š Detailed Documentation
14
+
15
+ **NEW**: Enhanced Metadata System V2 with YAML Front Matter for AI-optimized
16
+ documentation:
17
+
18
+ - ๐ŸŽฏ **Generated JSDoc**: Full API documentation with hierarchical metadata
19
+ system
20
+ - ๐Ÿ“Š **Live Documentation**: View at `/docs/api/aggregates/index.html` after
21
+ running `pnpm jsdoc:publish`
22
+ - ๐Ÿง  **AI-Optimized**: Structured metadata for LLM consumption and code
23
+ generation
24
+ - โšก **Performance**: ~12s initial build, ~2s incremental, ~0.5s no changes
25
+
26
+ **Core Documentation:**
27
+
28
+ - ๐Ÿ“– **AggregateRoot Class** - Base aggregate implementation with event sourcing
29
+ - ๐Ÿ”ง **AggregateBuilder Class** - Fluent builder for aggregates with
30
+ capabilities
31
+ - ๐Ÿ› ๏ธ **Utility Functions** - Type-safe capability management and utilities
32
+ - ๐Ÿ“‹ **Error Classes** - Comprehensive aggregate-specific error handling
33
+
34
+ > **Enhanced Metadata System V2** provides hierarchical documentation (Global โ†’
35
+ > Package โ†’ Class โ†’ Method) with format-specific content, custom tags, and
36
+ > automatic example injection.
37
+
38
+ ## ๐Ÿ“‹ Table of Contents
39
+
40
+ - [Installation](#installation)
41
+ - [Key Features](#key-features)
42
+ - [Core Concepts](#core-concepts)
43
+ - [Quick Start](#quick-start)
44
+ - [Advanced Features](#advanced-features)
45
+ - [API Reference](#api-reference)
46
+ - [Patterns & Best Practices](#patterns--best-practices)
47
+ - [Event Sourcing](#event-sourcing)
48
+ - [Capabilities](#capabilities)
49
+ - [Testing](#testing)
50
+ - [Performance](#performance)
51
+ - [Contributing](#contributing)
52
+
53
+ ## ๐Ÿš€ Installation
54
+
55
+ ```bash
56
+ # npm
57
+ npm install @vytches/ddd-aggregates
58
+
59
+ # yarn
60
+ yarn add @vytches/ddd-aggregates
61
+
62
+ # pnpm
63
+ pnpm add @vytches/ddd-aggregates
64
+ ```
65
+
66
+ ### Peer Dependencies
67
+
68
+ ```bash
69
+ # Required for full functionality
70
+ npm install @vytches/ddd-domain-primitives @vytches/ddd-events @vytches/ddd-utils
71
+ ```
72
+
73
+ ## โœจ Key Features
74
+
75
+ ### Aggregate Root Foundation
76
+
77
+ - **Event Sourcing**: Built-in event sourcing with automatic event management
78
+ - **Capability System**: Extensible capabilities for cross-cutting concerns
79
+ - **Concurrency Control**: Optimistic concurrency with version management
80
+ - **Snapshot Support**: Automatic snapshots for performance optimization
81
+
82
+ ### Enterprise Features
83
+
84
+ - **Strong Consistency**: Aggregate consistency boundary enforcement
85
+ - **Event Management**: Automatic event collection and publishing
86
+ - **State Validation**: Built-in invariant validation
87
+ - **Audit Trail**: Complete audit trail with event history
88
+
89
+ ### Developer Experience
90
+
91
+ - **Type Safety**: Full TypeScript support with generic typing
92
+ - **Fluent API**: Intuitive API for domain modeling
93
+ - **Testing Support**: Built-in testing utilities and builders
94
+ - **Documentation**: Comprehensive documentation and examples
95
+
96
+ ## ๐ŸŽฏ Core Concepts
97
+
98
+ ### Aggregate Root
99
+
100
+ An aggregate root is the only member of an aggregate that outside objects are
101
+ allowed to hold references to. It's the gatekeeper for the aggregate, ensuring
102
+ consistency and managing the lifecycle of the aggregate.
103
+
104
+ ```typescript
105
+ // Base aggregate root interface
106
+ interface IAggregateRoot {
107
+ readonly id: EntityId;
108
+ readonly version: number;
109
+
110
+ // Event management
111
+ addDomainEvent(event: IDomainEvent): void;
112
+ getUncommittedEvents(): IDomainEvent[];
113
+ markEventsAsCommitted(): void;
114
+
115
+ // Capability system
116
+ addCapability<T extends IAggregateCapability>(capability: T): void;
117
+ getCapability<T extends IAggregateCapability>(type: new () => T): T | null;
118
+ hasCapability<T extends IAggregateCapability>(type: new () => T): boolean;
119
+
120
+ // Lifecycle
121
+ toSnapshot(): AggregateSnapshot;
122
+ fromSnapshot(snapshot: AggregateSnapshot): void;
123
+ }
124
+ ```
125
+
126
+ ### Event Sourcing
127
+
128
+ Event sourcing is a pattern where state changes are stored as a sequence of
129
+ events. The current state is derived by replaying these events.
130
+
131
+ ```typescript
132
+ // Event sourced aggregate
133
+ abstract class EventSourcedAggregate extends AggregateRoot {
134
+ // Apply event to current state
135
+ protected apply(event: IDomainEvent): void;
136
+
137
+ // Replay events to rebuild state
138
+ static fromEventStream<T extends EventSourcedAggregate>(
139
+ events: IDomainEvent[]
140
+ ): T;
141
+
142
+ // Event handlers (implemented by subclasses)
143
+ protected abstract getEventHandlers(): EventHandlerMap;
144
+ }
145
+ ```
146
+
147
+ ### Capabilities
148
+
149
+ Capabilities are modular extensions that can be added to aggregates to provide
150
+ cross-cutting concerns like auditing, caching, or validation.
151
+
152
+ ```typescript
153
+ // Capability interface
154
+ interface IAggregateCapability {
155
+ readonly name: string;
156
+
157
+ // Lifecycle hooks
158
+ onEventApplied?(event: IDomainEvent): void;
159
+ onSnapshotCreated?(snapshot: AggregateSnapshot): void;
160
+ beforeSave?(): void;
161
+ afterSave?(): void;
162
+ }
163
+ ```
164
+
165
+ ## ๐Ÿš€ Quick Start
166
+
167
+ ### 1. Basic Aggregate Root
168
+
169
+ ```typescript
170
+ import { AggregateRoot, EntityId } from '@vytches/ddd-aggregates';
171
+ import { DomainEvent } from '@vytches/ddd-events';
172
+ import { Result } from '@vytches/ddd-utils';
173
+
174
+ // Domain events
175
+ class OrderCreatedEvent extends DomainEvent<{
176
+ orderId: string;
177
+ customerId: string;
178
+ totalAmount: number;
179
+ currency: string;
180
+ }> {
181
+ constructor(payload: OrderCreatedData) {
182
+ super(payload, undefined, 'OrderCreated');
183
+ }
184
+ }
185
+
186
+ class OrderItemAddedEvent extends DomainEvent<{
187
+ orderId: string;
188
+ productId: string;
189
+ quantity: number;
190
+ price: number;
191
+ }> {
192
+ constructor(payload: OrderItemAddedData) {
193
+ super(payload, undefined, 'OrderItemAdded');
194
+ }
195
+ }
196
+
197
+ // Value objects
198
+ class OrderStatus {
199
+ static readonly PENDING = new OrderStatus('PENDING');
200
+ static readonly CONFIRMED = new OrderStatus('CONFIRMED');
201
+ static readonly SHIPPED = new OrderStatus('SHIPPED');
202
+ static readonly DELIVERED = new OrderStatus('DELIVERED');
203
+ static readonly CANCELLED = new OrderStatus('CANCELLED');
204
+
205
+ private constructor(private readonly value: string) {}
206
+
207
+ equals(other: OrderStatus): boolean {
208
+ return this.value === other.value;
209
+ }
210
+
211
+ toString(): string {
212
+ return this.value;
213
+ }
214
+ }
215
+
216
+ // Aggregate root implementation
217
+ class OrderAggregate extends AggregateRoot {
218
+ private customerId: EntityId;
219
+ private items: OrderItem[] = [];
220
+ private status: OrderStatus = OrderStatus.PENDING;
221
+ private totalAmount: number = 0;
222
+ private currency: string = 'USD';
223
+
224
+ private constructor(id: EntityId, customerId: EntityId) {
225
+ super(id);
226
+ this.customerId = customerId;
227
+ }
228
+
229
+ // Factory method
230
+ static create(customerId: EntityId): OrderAggregate {
231
+ const order = new OrderAggregate(EntityId.create(), customerId);
232
+
233
+ // Add creation event
234
+ order.addDomainEvent(
235
+ new OrderCreatedEvent({
236
+ orderId: order.id.value,
237
+ customerId: customerId.value,
238
+ totalAmount: 0,
239
+ currency: 'USD',
240
+ })
241
+ );
242
+
243
+ return order;
244
+ }
245
+
246
+ // Business methods
247
+ addItem(
248
+ productId: EntityId,
249
+ quantity: number,
250
+ price: number
251
+ ): Result<void, Error> {
252
+ // Validate business rules
253
+ if (quantity <= 0) {
254
+ return Result.fail(new Error('Quantity must be positive'));
255
+ }
256
+
257
+ if (price < 0) {
258
+ return Result.fail(new Error('Price cannot be negative'));
259
+ }
260
+
261
+ if (!this.status.equals(OrderStatus.PENDING)) {
262
+ return Result.fail(new Error('Cannot add items to non-pending order'));
263
+ }
264
+
265
+ // Apply changes
266
+ const item = new OrderItem(productId, quantity, price);
267
+ this.items.push(item);
268
+ this.totalAmount += quantity * price;
269
+
270
+ // Add domain event
271
+ this.addDomainEvent(
272
+ new OrderItemAddedEvent({
273
+ orderId: this.id.value,
274
+ productId: productId.value,
275
+ quantity,
276
+ price,
277
+ })
278
+ );
279
+
280
+ return Result.ok();
281
+ }
282
+
283
+ confirm(): Result<void, Error> {
284
+ if (!this.status.equals(OrderStatus.PENDING)) {
285
+ return Result.fail(new Error('Order must be pending to confirm'));
286
+ }
287
+
288
+ if (this.items.length === 0) {
289
+ return Result.fail(new Error('Cannot confirm empty order'));
290
+ }
291
+
292
+ this.status = OrderStatus.CONFIRMED;
293
+
294
+ this.addDomainEvent(
295
+ new OrderConfirmedEvent({
296
+ orderId: this.id.value,
297
+ confirmedAt: new Date(),
298
+ totalAmount: this.totalAmount,
299
+ })
300
+ );
301
+
302
+ return Result.ok();
303
+ }
304
+
305
+ // Getters
306
+ getCustomerId(): EntityId {
307
+ return this.customerId;
308
+ }
309
+
310
+ getItems(): ReadonlyArray<OrderItem> {
311
+ return this.items;
312
+ }
313
+
314
+ getStatus(): OrderStatus {
315
+ return this.status;
316
+ }
317
+
318
+ getTotalAmount(): number {
319
+ return this.totalAmount;
320
+ }
321
+
322
+ getCurrency(): string {
323
+ return this.currency;
324
+ }
325
+ }
326
+
327
+ // Usage
328
+ const order = OrderAggregate.create(EntityId.from('customer-123'));
329
+
330
+ const addResult = order.addItem(EntityId.from('product-456'), 2, 29.99);
331
+
332
+ if (addResult.isSuccess()) {
333
+ const confirmResult = order.confirm();
334
+
335
+ if (confirmResult.isSuccess()) {
336
+ console.log('Order confirmed:', order.id.value);
337
+ console.log('Events:', order.getUncommittedEvents());
338
+ }
339
+ }
340
+ ```
341
+
342
+ ### 2. Event-Sourced Aggregate
343
+
344
+ ```typescript
345
+ import {
346
+ EventSourcedAggregate,
347
+ EventHandlerMap,
348
+ } from '@vytches/ddd-aggregates';
349
+
350
+ class BankAccountAggregate extends EventSourcedAggregate {
351
+ private balance: number = 0;
352
+ private status: AccountStatus = AccountStatus.ACTIVE;
353
+ private overdraftLimit: number = 0;
354
+
355
+ private constructor() {
356
+ super();
357
+ }
358
+
359
+ // Factory method
360
+ static create(
361
+ accountId: EntityId,
362
+ initialDeposit: number,
363
+ overdraftLimit: number = 0
364
+ ): BankAccountAggregate {
365
+ const account = new BankAccountAggregate();
366
+
367
+ // Apply creation event
368
+ account.apply(
369
+ new AccountOpenedEvent({
370
+ accountId: accountId.value,
371
+ initialDeposit,
372
+ overdraftLimit,
373
+ openedAt: new Date(),
374
+ })
375
+ );
376
+
377
+ return account;
378
+ }
379
+
380
+ // Business methods
381
+ deposit(amount: number): Result<void, Error> {
382
+ if (amount <= 0) {
383
+ return Result.fail(new Error('Deposit amount must be positive'));
384
+ }
385
+
386
+ if (this.status !== AccountStatus.ACTIVE) {
387
+ return Result.fail(new Error('Account is not active'));
388
+ }
389
+
390
+ this.apply(
391
+ new MoneyDepositedEvent({
392
+ accountId: this.id.value,
393
+ amount,
394
+ balance: this.balance + amount,
395
+ depositedAt: new Date(),
396
+ })
397
+ );
398
+
399
+ return Result.ok();
400
+ }
401
+
402
+ withdraw(amount: number): Result<void, Error> {
403
+ if (amount <= 0) {
404
+ return Result.fail(new Error('Withdrawal amount must be positive'));
405
+ }
406
+
407
+ if (this.status !== AccountStatus.ACTIVE) {
408
+ return Result.fail(new Error('Account is not active'));
409
+ }
410
+
411
+ const newBalance = this.balance - amount;
412
+ if (newBalance < -this.overdraftLimit) {
413
+ return Result.fail(new Error('Insufficient funds'));
414
+ }
415
+
416
+ this.apply(
417
+ new MoneyWithdrawnEvent({
418
+ accountId: this.id.value,
419
+ amount,
420
+ balance: newBalance,
421
+ withdrawnAt: new Date(),
422
+ })
423
+ );
424
+
425
+ return Result.ok();
426
+ }
427
+
428
+ // Event handlers
429
+ protected getEventHandlers(): EventHandlerMap {
430
+ return {
431
+ AccountOpened: this.onAccountOpened.bind(this),
432
+ MoneyDeposited: this.onMoneyDeposited.bind(this),
433
+ MoneyWithdrawn: this.onMoneyWithdrawn.bind(this),
434
+ };
435
+ }
436
+
437
+ private onAccountOpened(event: AccountOpenedEvent): void {
438
+ this.id = EntityId.from(event.payload.accountId);
439
+ this.balance = event.payload.initialDeposit;
440
+ this.overdraftLimit = event.payload.overdraftLimit;
441
+ this.status = AccountStatus.ACTIVE;
442
+ }
443
+
444
+ private onMoneyDeposited(event: MoneyDepositedEvent): void {
445
+ this.balance = event.payload.balance;
446
+ }
447
+
448
+ private onMoneyWithdrawn(event: MoneyWithdrawnEvent): void {
449
+ this.balance = event.payload.balance;
450
+ }
451
+
452
+ // Getters
453
+ getBalance(): number {
454
+ return this.balance;
455
+ }
456
+
457
+ getStatus(): AccountStatus {
458
+ return this.status;
459
+ }
460
+
461
+ getOverdraftLimit(): number {
462
+ return this.overdraftLimit;
463
+ }
464
+ }
465
+
466
+ // Reconstitute from event stream
467
+ const events = await eventStore.getEventStream('account-123');
468
+ const account = BankAccountAggregate.fromEventStream(events);
469
+
470
+ console.log('Current balance:', account.getBalance());
471
+ ```
472
+
473
+ ### 3. Aggregate with Capabilities
474
+
475
+ ```typescript
476
+ import {
477
+ AggregateRoot,
478
+ AuditCapability,
479
+ CacheCapability,
480
+ ValidationCapability,
481
+ } from '@vytches/ddd-aggregates';
482
+
483
+ class ProductAggregate extends AggregateRoot {
484
+ private name: string;
485
+ private price: number;
486
+ private stock: number;
487
+ private category: string;
488
+
489
+ private constructor(
490
+ id: EntityId,
491
+ name: string,
492
+ price: number,
493
+ stock: number,
494
+ category: string
495
+ ) {
496
+ super(id);
497
+ this.name = name;
498
+ this.price = price;
499
+ this.stock = stock;
500
+ this.category = category;
501
+ }
502
+
503
+ static create(
504
+ name: string,
505
+ price: number,
506
+ initialStock: number,
507
+ category: string
508
+ ): ProductAggregate {
509
+ const product = new ProductAggregate(
510
+ EntityId.create(),
511
+ name,
512
+ price,
513
+ initialStock,
514
+ category
515
+ );
516
+
517
+ // Add capabilities
518
+ product.addCapability(new AuditCapability());
519
+ product.addCapability(new CacheCapability({ ttl: 300 })); // 5 minutes
520
+ product.addCapability(new ValidationCapability());
521
+
522
+ product.addDomainEvent(
523
+ new ProductCreatedEvent({
524
+ productId: product.id.value,
525
+ name,
526
+ price,
527
+ stock: initialStock,
528
+ category,
529
+ })
530
+ );
531
+
532
+ return product;
533
+ }
534
+
535
+ updatePrice(newPrice: number): Result<void, Error> {
536
+ // Validation capability automatically validates
537
+ if (newPrice < 0) {
538
+ return Result.fail(new Error('Price cannot be negative'));
539
+ }
540
+
541
+ const oldPrice = this.price;
542
+ this.price = newPrice;
543
+
544
+ // Audit capability automatically logs changes
545
+ this.addDomainEvent(
546
+ new ProductPriceUpdatedEvent({
547
+ productId: this.id.value,
548
+ oldPrice,
549
+ newPrice,
550
+ updatedAt: new Date(),
551
+ })
552
+ );
553
+
554
+ return Result.ok();
555
+ }
556
+
557
+ adjustStock(quantity: number): Result<void, Error> {
558
+ const newStock = this.stock + quantity;
559
+
560
+ if (newStock < 0) {
561
+ return Result.fail(new Error('Insufficient stock'));
562
+ }
563
+
564
+ this.stock = newStock;
565
+
566
+ this.addDomainEvent(
567
+ new ProductStockAdjustedEvent({
568
+ productId: this.id.value,
569
+ adjustment: quantity,
570
+ newStock,
571
+ adjustedAt: new Date(),
572
+ })
573
+ );
574
+
575
+ return Result.ok();
576
+ }
577
+
578
+ // Getters
579
+ getName(): string {
580
+ return this.name;
581
+ }
582
+
583
+ getPrice(): number {
584
+ return this.price;
585
+ }
586
+
587
+ getStock(): number {
588
+ return this.stock;
589
+ }
590
+
591
+ getCategory(): string {
592
+ return this.category;
593
+ }
594
+ }
595
+
596
+ // Usage with capabilities
597
+ const product = ProductAggregate.create(
598
+ 'Gaming Laptop',
599
+ 1299.99,
600
+ 50,
601
+ 'Electronics'
602
+ );
603
+
604
+ // Audit capability tracks all changes
605
+ product.updatePrice(1199.99);
606
+ product.adjustStock(-5);
607
+
608
+ // Cache capability optimizes repeated access
609
+ const auditCapability = product.getCapability(AuditCapability);
610
+ console.log('Audit trail:', auditCapability.getAuditTrail());
611
+
612
+ // Validation capability ensures data integrity
613
+ const validationCapability = product.getCapability(ValidationCapability);
614
+ console.log('Validation rules:', validationCapability.getValidationRules());
615
+ ```
616
+
617
+ ## ๐Ÿ”ง Advanced Features
618
+
619
+ ### Optimistic Concurrency Control
620
+
621
+ ```typescript
622
+ class OrderAggregate extends AggregateRoot {
623
+ // Version is automatically managed by base class
624
+
625
+ updateShippingAddress(newAddress: Address): Result<void, Error> {
626
+ // Check if aggregate is in correct state
627
+ if (this.status !== OrderStatus.CONFIRMED) {
628
+ return Result.fail(new Error('Cannot update shipping address'));
629
+ }
630
+
631
+ // Apply change
632
+ this.shippingAddress = newAddress;
633
+
634
+ // Version is automatically incremented
635
+ this.addDomainEvent(
636
+ new OrderShippingAddressUpdatedEvent({
637
+ orderId: this.id.value,
638
+ newAddress: newAddress.toJSON(),
639
+ version: this.version + 1,
640
+ })
641
+ );
642
+
643
+ return Result.ok();
644
+ }
645
+ }
646
+
647
+ // Repository handles concurrency conflicts
648
+ class OrderRepository {
649
+ async save(order: OrderAggregate): Promise<void> {
650
+ try {
651
+ await this.db.orders.update({
652
+ where: {
653
+ id: order.id.value,
654
+ version: order.version - 1, // Expected version
655
+ },
656
+ data: {
657
+ ...order.toSnapshot(),
658
+ version: order.version,
659
+ },
660
+ });
661
+ } catch (error) {
662
+ if (error.code === 'P2025') {
663
+ // Prisma: Record not found
664
+ throw new OptimisticConcurrencyError(
665
+ `Order ${order.id.value} was modified by another process`
666
+ );
667
+ }
668
+ throw error;
669
+ }
670
+ }
671
+ }
672
+ ```
673
+
674
+ ### Snapshot Support
675
+
676
+ ```typescript
677
+ class OrderAggregate extends AggregateRoot {
678
+ // Snapshot creation
679
+ toSnapshot(): AggregateSnapshot {
680
+ return {
681
+ id: this.id.value,
682
+ version: this.version,
683
+ data: {
684
+ customerId: this.customerId.value,
685
+ items: this.items.map(item => item.toJSON()),
686
+ status: this.status.toString(),
687
+ totalAmount: this.totalAmount,
688
+ currency: this.currency,
689
+ createdAt: this.createdAt.toISOString(),
690
+ updatedAt: new Date().toISOString(),
691
+ },
692
+ };
693
+ }
694
+
695
+ // Snapshot restoration
696
+ fromSnapshot(snapshot: AggregateSnapshot): void {
697
+ this.id = EntityId.from(snapshot.id);
698
+ this.version = snapshot.version;
699
+ this.customerId = EntityId.from(snapshot.data.customerId);
700
+ this.items = snapshot.data.items.map(item => OrderItem.fromJSON(item));
701
+ this.status = OrderStatus.fromString(snapshot.data.status);
702
+ this.totalAmount = snapshot.data.totalAmount;
703
+ this.currency = snapshot.data.currency;
704
+ this.createdAt = new Date(snapshot.data.createdAt);
705
+ this.updatedAt = new Date(snapshot.data.updatedAt);
706
+ }
707
+ }
708
+
709
+ // Automatic snapshot creation
710
+ class OrderRepository {
711
+ async save(order: OrderAggregate): Promise<void> {
712
+ // Save aggregate snapshot
713
+ await this.db.orders.upsert({
714
+ where: { id: order.id.value },
715
+ update: order.toSnapshot(),
716
+ create: order.toSnapshot(),
717
+ });
718
+
719
+ // Save events
720
+ const events = order.getUncommittedEvents();
721
+ await this.eventStore.append(order.id.value, events);
722
+
723
+ // Create snapshot if threshold reached
724
+ if (events.length >= this.snapshotThreshold) {
725
+ await this.snapshotStore.createSnapshot(
726
+ order.id.value,
727
+ order.toSnapshot()
728
+ );
729
+ }
730
+ }
731
+ }
732
+ ```
733
+
734
+ ### Invariant Validation
735
+
736
+ ```typescript
737
+ class OrderAggregate extends AggregateRoot {
738
+ // Invariant validation
739
+ private validateInvariants(): void {
740
+ // Business rules that must always be true
741
+ if (this.totalAmount < 0) {
742
+ throw new InvariantViolationError('Total amount cannot be negative');
743
+ }
744
+
745
+ if (this.items.length === 0 && this.status !== OrderStatus.PENDING) {
746
+ throw new InvariantViolationError('Non-pending orders must have items');
747
+ }
748
+
749
+ if (this.status === OrderStatus.CONFIRMED && this.totalAmount === 0) {
750
+ throw new InvariantViolationError(
751
+ 'Confirmed orders must have positive total'
752
+ );
753
+ }
754
+
755
+ // Validate item consistency
756
+ const calculatedTotal = this.items.reduce(
757
+ (sum, item) => sum + item.quantity * item.price,
758
+ 0
759
+ );
760
+
761
+ if (Math.abs(calculatedTotal - this.totalAmount) > 0.01) {
762
+ throw new InvariantViolationError('Total amount does not match items');
763
+ }
764
+ }
765
+
766
+ // Called automatically before events are applied
767
+ protected beforeApplyEvent(event: IDomainEvent): void {
768
+ this.validateInvariants();
769
+ }
770
+
771
+ // Called automatically after events are applied
772
+ protected afterApplyEvent(event: IDomainEvent): void {
773
+ this.validateInvariants();
774
+ }
775
+ }
776
+ ```
777
+
778
+ ## ๐Ÿ“š API Reference
779
+
780
+ ### AggregateRoot
781
+
782
+ **Base Class:**
783
+
784
+ ```typescript
785
+ abstract class AggregateRoot {
786
+ protected readonly id: EntityId;
787
+ protected version: number;
788
+
789
+ // Event management
790
+ addDomainEvent(event: IDomainEvent): void;
791
+ getUncommittedEvents(): IDomainEvent[];
792
+ markEventsAsCommitted(): void;
793
+ clearEvents(): void;
794
+
795
+ // Capability system
796
+ addCapability<T extends IAggregateCapability>(capability: T): void;
797
+ getCapability<T extends IAggregateCapability>(type: new () => T): T | null;
798
+ hasCapability<T extends IAggregateCapability>(type: new () => T): boolean;
799
+ removeCapability<T extends IAggregateCapability>(type: new () => T): void;
800
+
801
+ // Snapshot support
802
+ toSnapshot(): AggregateSnapshot;
803
+ fromSnapshot(snapshot: AggregateSnapshot): void;
804
+
805
+ // Lifecycle hooks
806
+ protected beforeApplyEvent?(event: IDomainEvent): void;
807
+ protected afterApplyEvent?(event: IDomainEvent): void;
808
+ protected beforeSave?(): void;
809
+ protected afterSave?(): void;
810
+ }
811
+ ```
812
+
813
+ ### EventSourcedAggregate
814
+
815
+ **Event Sourcing Support:**
816
+
817
+ ```typescript
818
+ abstract class EventSourcedAggregate extends AggregateRoot {
819
+ // Event application
820
+ protected apply(event: IDomainEvent): void;
821
+
822
+ // Event stream reconstruction
823
+ static fromEventStream<T extends EventSourcedAggregate>(
824
+ events: IDomainEvent[]
825
+ ): T;
826
+
827
+ // Event handlers (must be implemented)
828
+ protected abstract getEventHandlers(): EventHandlerMap;
829
+
830
+ // Replay events
831
+ protected replayEvents(events: IDomainEvent[]): void;
832
+
833
+ // Get aggregate version from events
834
+ protected getVersionFromEvents(events: IDomainEvent[]): number;
835
+ }
836
+ ```
837
+
838
+ ### Built-in Capabilities
839
+
840
+ **Audit Capability:**
841
+
842
+ ```typescript
843
+ class AuditCapability implements IAggregateCapability {
844
+ readonly name = 'audit';
845
+
846
+ // Configuration
847
+ constructor(config?: AuditConfig) {}
848
+
849
+ // Audit trail
850
+ getAuditTrail(): AuditEntry[];
851
+ clearAuditTrail(): void;
852
+
853
+ // Hooks
854
+ onEventApplied(event: IDomainEvent): void;
855
+ onSnapshotCreated(snapshot: AggregateSnapshot): void;
856
+ }
857
+ ```
858
+
859
+ **Cache Capability:**
860
+
861
+ ```typescript
862
+ class CacheCapability implements IAggregateCapability {
863
+ readonly name = 'cache';
864
+
865
+ // Configuration
866
+ constructor(config: CacheConfig) {}
867
+
868
+ // Cache management
869
+ get<T>(key: string): T | undefined;
870
+ set<T>(key: string, value: T): void;
871
+ remove(key: string): void;
872
+ clear(): void;
873
+
874
+ // Statistics
875
+ getStats(): CacheStats;
876
+ }
877
+ ```
878
+
879
+ **Validation Capability:**
880
+
881
+ ```typescript
882
+ class ValidationCapability implements IAggregateCapability {
883
+ readonly name = 'validation';
884
+
885
+ // Configuration
886
+ constructor(config?: ValidationConfig) {}
887
+
888
+ // Validation rules
889
+ addRule(rule: ValidationRule): void;
890
+ removeRule(ruleName: string): void;
891
+ getValidationRules(): ValidationRule[];
892
+
893
+ // Validation
894
+ validate(aggregate: AggregateRoot): ValidationResult;
895
+
896
+ // Hooks
897
+ beforeApplyEvent(event: IDomainEvent): void;
898
+ }
899
+ ```
900
+
901
+ ## ๐ŸŽฏ Patterns & Best Practices
902
+
903
+ ### Aggregate Design Principles
904
+
905
+ 1. **Keep Aggregates Small**
906
+
907
+ ```typescript
908
+ // Good: Small, focused aggregate
909
+ class OrderAggregate extends AggregateRoot {
910
+ private items: OrderItem[] = [];
911
+ private status: OrderStatus;
912
+
913
+ // Only essential order operations
914
+ addItem(item: OrderItem): Result<void, Error> {
915
+ /* ... */
916
+ }
917
+ removeItem(itemId: string): Result<void, Error> {
918
+ /* ... */
919
+ }
920
+ confirm(): Result<void, Error> {
921
+ /* ... */
922
+ }
923
+ }
924
+
925
+ // Avoid: Large, complex aggregates
926
+ class OrderAggregate extends AggregateRoot {
927
+ private items: OrderItem[] = [];
928
+ private customer: Customer; // Should be separate aggregate
929
+ private inventory: Inventory; // Should be separate aggregate
930
+ private payments: Payment[]; // Should be separate aggregate
931
+ }
932
+ ```
933
+
934
+ 2. **Use Eventual Consistency Between Aggregates**
935
+
936
+ ```typescript
937
+ // Good: Eventual consistency via domain events
938
+ class OrderAggregate extends AggregateRoot {
939
+ confirm(): Result<void, Error> {
940
+ this.status = OrderStatus.CONFIRMED;
941
+
942
+ // Notify other aggregates via events
943
+ this.addDomainEvent(
944
+ new OrderConfirmedEvent({
945
+ orderId: this.id.value,
946
+ customerId: this.customerId.value,
947
+ items: this.items.map(item => item.toJSON()),
948
+ })
949
+ );
950
+
951
+ return Result.ok();
952
+ }
953
+ }
954
+
955
+ // Handler in inventory context
956
+ @EventHandler(OrderConfirmedEvent)
957
+ class InventoryHandler {
958
+ async handle(event: OrderConfirmedEvent): Promise<void> {
959
+ await this.inventoryService.reserveItems(event.payload.items);
960
+ }
961
+ }
962
+ ```
963
+
964
+ 3. **Enforce Invariants at Aggregate Boundaries**
965
+
966
+ ```typescript
967
+ class OrderAggregate extends AggregateRoot {
968
+ private static readonly MAX_ITEMS = 100;
969
+ private static readonly MIN_TOTAL = 10.0;
970
+
971
+ addItem(item: OrderItem): Result<void, Error> {
972
+ // Validate aggregate invariants
973
+ if (this.items.length >= OrderAggregate.MAX_ITEMS) {
974
+ return Result.fail(
975
+ new Error(
976
+ `Order cannot have more than ${OrderAggregate.MAX_ITEMS} items`
977
+ )
978
+ );
979
+ }
980
+
981
+ const newTotal = this.calculateTotalWithItem(item);
982
+ if (
983
+ this.status === OrderStatus.PENDING &&
984
+ newTotal < OrderAggregate.MIN_TOTAL
985
+ ) {
986
+ return Result.fail(
987
+ new Error(`Order total must be at least ${OrderAggregate.MIN_TOTAL}`)
988
+ );
989
+ }
990
+
991
+ // Apply change
992
+ this.items.push(item);
993
+ this.recalculateTotal();
994
+
995
+ return Result.ok();
996
+ }
997
+ }
998
+ ```
999
+
1000
+ ### Event Design Guidelines
1001
+
1002
+ 1. **Use Past Tense for Domain Events**
1003
+
1004
+ ```typescript
1005
+ // Good: Past tense, describes what happened
1006
+ class OrderCreatedEvent extends DomainEvent<OrderCreatedData> {}
1007
+ class OrderConfirmedEvent extends DomainEvent<OrderConfirmedData> {}
1008
+ class OrderCancelledEvent extends DomainEvent<OrderCancelledData> {}
1009
+
1010
+ // Avoid: Present tense or imperative
1011
+ class CreateOrderEvent extends DomainEvent<OrderData> {}
1012
+ class ConfirmOrderEvent extends DomainEvent<OrderData> {}
1013
+ ```
1014
+
1015
+ 2. **Include Sufficient Data in Events**
1016
+
1017
+ ```typescript
1018
+ // Good: Rich event data
1019
+ class OrderCreatedEvent extends DomainEvent<{
1020
+ orderId: string;
1021
+ customerId: string;
1022
+ items: Array<{
1023
+ productId: string;
1024
+ quantity: number;
1025
+ price: number;
1026
+ }>;
1027
+ totalAmount: number;
1028
+ currency: string;
1029
+ createdAt: Date;
1030
+ metadata: {
1031
+ channel: string;
1032
+ promotionCode?: string;
1033
+ };
1034
+ }> {}
1035
+
1036
+ // Avoid: Minimal event data
1037
+ class OrderCreatedEvent extends DomainEvent<{
1038
+ orderId: string;
1039
+ }> {}
1040
+ ```
1041
+
1042
+ 3. **Version Events for Schema Evolution**
1043
+
1044
+ ```typescript
1045
+ // Event versioning
1046
+ class OrderCreatedEvent extends DomainEvent<OrderCreatedData> {
1047
+ static readonly VERSION = 2;
1048
+
1049
+ constructor(payload: OrderCreatedData) {
1050
+ super('OrderCreated', payload);
1051
+ this.version = OrderCreatedEvent.VERSION;
1052
+ }
1053
+ }
1054
+
1055
+ // Event upcasting for version compatibility
1056
+ class OrderCreatedEventUpcaster {
1057
+ upcast(event: IDomainEvent): IDomainEvent {
1058
+ if (event.version === 1) {
1059
+ return this.upcastV1ToV2(event);
1060
+ }
1061
+ return event;
1062
+ }
1063
+
1064
+ private upcastV1ToV2(event: IDomainEvent): IDomainEvent {
1065
+ // Convert v1 to v2 format
1066
+ return new OrderCreatedEvent({
1067
+ ...event.payload,
1068
+ currency: event.payload.currency || 'USD', // Default currency
1069
+ });
1070
+ }
1071
+ }
1072
+ ```
1073
+
1074
+ ### Testing Strategies
1075
+
1076
+ 1. **Unit Test Aggregate Behavior**
1077
+
1078
+ ```typescript
1079
+ describe('OrderAggregate', () => {
1080
+ it('should create order with items', () => {
1081
+ // Arrange
1082
+ const customerId = EntityId.create();
1083
+ const item = new OrderItem(EntityId.create(), 2, 29.99);
1084
+
1085
+ // Act
1086
+ const order = OrderAggregate.create(customerId);
1087
+ const result = order.addItem(item);
1088
+
1089
+ // Assert
1090
+ expect(result.isSuccess()).toBe(true);
1091
+ expect(order.getItems()).toHaveLength(1);
1092
+ expect(order.getTotalAmount()).toBe(59.98);
1093
+
1094
+ // Verify events
1095
+ const events = order.getUncommittedEvents();
1096
+ expect(events).toHaveLength(2);
1097
+ expect(events[0]).toBeInstanceOf(OrderCreatedEvent);
1098
+ expect(events[1]).toBeInstanceOf(OrderItemAddedEvent);
1099
+ });
1100
+
1101
+ it('should fail to add item with negative quantity', () => {
1102
+ // Arrange
1103
+ const order = OrderAggregate.create(EntityId.create());
1104
+ const item = new OrderItem(EntityId.create(), -1, 29.99);
1105
+
1106
+ // Act
1107
+ const result = order.addItem(item);
1108
+
1109
+ // Assert
1110
+ expect(result.isFailure()).toBe(true);
1111
+ expect(result.error?.message).toBe('Quantity must be positive');
1112
+ expect(order.getItems()).toHaveLength(0);
1113
+ });
1114
+ });
1115
+ ```
1116
+
1117
+ 2. **Test Event Sourcing**
1118
+
1119
+ ```typescript
1120
+ describe('BankAccountAggregate Event Sourcing', () => {
1121
+ it('should reconstitute from event stream', () => {
1122
+ // Arrange
1123
+ const events = [
1124
+ new AccountOpenedEvent({
1125
+ accountId: 'account-123',
1126
+ initialDeposit: 1000,
1127
+ overdraftLimit: 500,
1128
+ }),
1129
+ new MoneyDepositedEvent({
1130
+ accountId: 'account-123',
1131
+ amount: 250,
1132
+ balance: 1250,
1133
+ }),
1134
+ new MoneyWithdrawnEvent({
1135
+ accountId: 'account-123',
1136
+ amount: 100,
1137
+ balance: 1150,
1138
+ }),
1139
+ ];
1140
+
1141
+ // Act
1142
+ const account = BankAccountAggregate.fromEventStream(events);
1143
+
1144
+ // Assert
1145
+ expect(account.getBalance()).toBe(1150);
1146
+ expect(account.getOverdraftLimit()).toBe(500);
1147
+ expect(account.getStatus()).toBe(AccountStatus.ACTIVE);
1148
+ });
1149
+ });
1150
+ ```
1151
+
1152
+ 3. **Integration Testing with Repository**
1153
+
1154
+ ```typescript
1155
+ describe('OrderRepository Integration', () => {
1156
+ it('should save and retrieve aggregate', async () => {
1157
+ // Arrange
1158
+ const order = OrderAggregate.create(EntityId.create());
1159
+ order.addItem(new OrderItem(EntityId.create(), 1, 99.99));
1160
+
1161
+ // Act
1162
+ await orderRepository.save(order);
1163
+ const retrievedOrder = await orderRepository.findById(order.id);
1164
+
1165
+ // Assert
1166
+ expect(retrievedOrder).toBeDefined();
1167
+ expect(retrievedOrder?.getTotalAmount()).toBe(99.99);
1168
+ expect(retrievedOrder?.getItems()).toHaveLength(1);
1169
+ });
1170
+ });
1171
+ ```
1172
+
1173
+ ## โšก Performance
1174
+
1175
+ ### Optimization Strategies
1176
+
1177
+ 1. **Snapshot Strategy**
1178
+
1179
+ ```typescript
1180
+ class OrderRepository {
1181
+ private readonly snapshotThreshold = 20;
1182
+
1183
+ async save(order: OrderAggregate): Promise<void> {
1184
+ // Always save current state
1185
+ await this.saveSnapshot(order);
1186
+
1187
+ // Save events
1188
+ const events = order.getUncommittedEvents();
1189
+ await this.eventStore.append(order.id.value, events);
1190
+
1191
+ // Create snapshot if threshold reached
1192
+ const eventCount = await this.eventStore.getEventCount(order.id.value);
1193
+ if (eventCount >= this.snapshotThreshold) {
1194
+ await this.snapshotStore.createSnapshot(
1195
+ order.id.value,
1196
+ order.toSnapshot()
1197
+ );
1198
+
1199
+ // Optional: Archive old events
1200
+ await this.eventStore.archiveEvents(order.id.value, eventCount - 10);
1201
+ }
1202
+ }
1203
+ }
1204
+ ```
1205
+
1206
+ 2. **Lazy Loading**
1207
+
1208
+ ```typescript
1209
+ class OrderAggregate extends AggregateRoot {
1210
+ private _items: OrderItem[] | null = null;
1211
+
1212
+ getItems(): OrderItem[] {
1213
+ if (this._items === null) {
1214
+ this._items = this.loadItemsFromSnapshot();
1215
+ }
1216
+ return this._items;
1217
+ }
1218
+
1219
+ private loadItemsFromSnapshot(): OrderItem[] {
1220
+ // Load from snapshot or reconstruct from events
1221
+ return this.snapshotData.items.map(item => OrderItem.fromJSON(item));
1222
+ }
1223
+ }
1224
+ ```
1225
+
1226
+ 3. **Batching Operations**
1227
+
1228
+ ```typescript
1229
+ class OrderAggregate extends AggregateRoot {
1230
+ addItems(items: OrderItem[]): Result<void, Error> {
1231
+ // Validate all items first
1232
+ for (const item of items) {
1233
+ const validation = this.validateItem(item);
1234
+ if (validation.isFailure()) {
1235
+ return validation;
1236
+ }
1237
+ }
1238
+
1239
+ // Apply all items in batch
1240
+ for (const item of items) {
1241
+ this.items.push(item);
1242
+ this.totalAmount += item.quantity * item.price;
1243
+ }
1244
+
1245
+ // Single event for batch operation
1246
+ this.addDomainEvent(
1247
+ new OrderItemsBatchAddedEvent({
1248
+ orderId: this.id.value,
1249
+ items: items.map(item => item.toJSON()),
1250
+ newTotal: this.totalAmount,
1251
+ })
1252
+ );
1253
+
1254
+ return Result.ok();
1255
+ }
1256
+ }
1257
+ ```
1258
+
1259
+ ### Memory Management
1260
+
1261
+ ```typescript
1262
+ class AggregateRoot {
1263
+ private eventCache: Map<string, IDomainEvent> = new Map();
1264
+ private readonly maxCacheSize = 1000;
1265
+
1266
+ addDomainEvent(event: IDomainEvent): void {
1267
+ // Manage cache size
1268
+ if (this.eventCache.size >= this.maxCacheSize) {
1269
+ // Remove oldest events
1270
+ const oldestKey = this.eventCache.keys().next().value;
1271
+ this.eventCache.delete(oldestKey);
1272
+ }
1273
+
1274
+ this.eventCache.set(event.id, event);
1275
+ }
1276
+
1277
+ getUncommittedEvents(): IDomainEvent[] {
1278
+ return Array.from(this.eventCache.values());
1279
+ }
1280
+
1281
+ markEventsAsCommitted(): void {
1282
+ this.eventCache.clear();
1283
+ }
1284
+ }
1285
+ ```
1286
+
1287
+ ## ๐Ÿงช Testing
1288
+
1289
+ ### Aggregate Testing Utilities
1290
+
1291
+ ```typescript
1292
+ import { AggregateTestBuilder } from '@vytches/ddd-aggregates/testing';
1293
+
1294
+ // Aggregate builder for tests
1295
+ const orderBuilder = new AggregateTestBuilder(OrderAggregate)
1296
+ .withCustomer('customer-123')
1297
+ .withItems([
1298
+ { productId: 'product-1', quantity: 2, price: 29.99 },
1299
+ { productId: 'product-2', quantity: 1, price: 49.99 },
1300
+ ])
1301
+ .withStatus(OrderStatus.PENDING)
1302
+ .build();
1303
+
1304
+ // Event stream testing
1305
+ const account = new AggregateTestBuilder(BankAccountAggregate)
1306
+ .fromEventStream([
1307
+ new AccountOpenedEvent({ accountId: 'account-123', initialDeposit: 1000 }),
1308
+ new MoneyDepositedEvent({
1309
+ accountId: 'account-123',
1310
+ amount: 500,
1311
+ balance: 1500,
1312
+ }),
1313
+ ])
1314
+ .build();
1315
+
1316
+ // Snapshot testing
1317
+ const order = new AggregateTestBuilder(OrderAggregate)
1318
+ .fromSnapshot({
1319
+ id: 'order-123',
1320
+ version: 5,
1321
+ data: {
1322
+ /* snapshot data */
1323
+ },
1324
+ })
1325
+ .build();
1326
+ ```
1327
+
1328
+ ### Event Testing
1329
+
1330
+ ```typescript
1331
+ import { EventTestHarness } from '@vytches/ddd-aggregates/testing';
1332
+
1333
+ describe('Order Events', () => {
1334
+ let eventHarness: EventTestHarness;
1335
+
1336
+ beforeEach(() => {
1337
+ eventHarness = new EventTestHarness();
1338
+ });
1339
+
1340
+ it('should emit correct events when adding item', () => {
1341
+ // Arrange
1342
+ const order = OrderAggregate.create(EntityId.create());
1343
+ const item = new OrderItem(EntityId.create(), 1, 99.99);
1344
+
1345
+ // Act
1346
+ order.addItem(item);
1347
+
1348
+ // Assert
1349
+ eventHarness.assertEventEmitted(order, OrderItemAddedEvent, {
1350
+ orderId: order.id.value,
1351
+ productId: item.productId.value,
1352
+ quantity: 1,
1353
+ price: 99.99,
1354
+ });
1355
+ });
1356
+ });
1357
+ ```
1358
+
1359
+ ## ๐Ÿค Contributing
1360
+
1361
+ We welcome contributions! Please see our
1362
+ [Contributing Guide](../../CONTRIBUTING.md) for details.
1363
+
1364
+ ### Development Setup
1365
+
1366
+ ```bash
1367
+ # Clone repository
1368
+ git clone https://github.com/vytches/ddd.git
1369
+ cd ddd
1370
+
1371
+ # Install dependencies
1372
+ pnpm install
1373
+
1374
+ # Build aggregates package
1375
+ pnpm build --filter=@vytches/ddd-aggregates
1376
+
1377
+ # Run tests
1378
+ pnpm test --filter=@vytches/ddd-aggregates
1379
+
1380
+ # Run in development mode
1381
+ pnpm dev --filter=@vytches/ddd-aggregates
1382
+ ```
1383
+
1384
+ ## ๐Ÿ“„ License
1385
+
1386
+ This project is licensed under the MIT License - see the
1387
+ [LICENSE](../../LICENSE) file for details.
1388
+
1389
+ ---
1390
+
1391
+ **Part of the [@vytches/ddd-core](https://github.com/vytches/ddd) ecosystem**
1392
+
1393
+ For more information, visit the [main documentation](../../README.md).