@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/LICENSE +21 -0
- package/LLMGUIDE.md +185 -0
- package/README.md +1393 -0
- package/dist/aggregate-errors.d.ts +58 -0
- package/dist/aggregate-interfaces.d.ts +398 -0
- package/dist/capabilities/audit-capability.d.ts +95 -0
- package/dist/capabilities/event-sourcing-capability.d.ts +79 -0
- package/dist/capabilities/index.d.ts +4 -0
- package/dist/capabilities/snapshot-capability.d.ts +113 -0
- package/dist/capabilities/versioning-capability.d.ts +93 -0
- package/dist/core/aggregate-root.builder.d.ts +110 -0
- package/dist/core/aggregate-root.d.ts +201 -0
- package/dist/core/aggregate-utilities.d.ts +233 -0
- package/dist/core/entity.d.ts +76 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1283 -0
- package/package.json +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
# @vytches/ddd-aggregates
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/js/%40vytches%2Fddd-aggregates)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](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).
|