@rineex/ddd 1.1.0 → 1.2.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 +1291 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
# @rineex/ddd
|
|
2
|
+
|
|
3
|
+
> Domain-Driven Design (DDD) utilities and abstractions for building
|
|
4
|
+
> maintainable, scalable, and testable Node.js applications with clear
|
|
5
|
+
> separation of concerns.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@rineex/ddd)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [Overview](#overview)
|
|
14
|
+
- [Philosophy](#philosophy)
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Quick Start](#quick-start)
|
|
17
|
+
- [Core Concepts](#core-concepts)
|
|
18
|
+
- [Value Objects](#value-objects)
|
|
19
|
+
- [Entities](#entities)
|
|
20
|
+
- [Aggregate Roots](#aggregate-roots)
|
|
21
|
+
- [Domain Events](#domain-events)
|
|
22
|
+
- [Application Services](#application-services)
|
|
23
|
+
- [API Reference](#api-reference)
|
|
24
|
+
- [Examples](#examples)
|
|
25
|
+
- [Best Practices](#best-practices)
|
|
26
|
+
- [Error Handling](#error-handling)
|
|
27
|
+
- [TypeScript Support](#typescript-support)
|
|
28
|
+
- [Contributing](#contributing)
|
|
29
|
+
- [License](#license)
|
|
30
|
+
|
|
31
|
+
## Overview
|
|
32
|
+
|
|
33
|
+
`@rineex/ddd` is a lightweight TypeScript library that provides production-grade
|
|
34
|
+
abstractions for implementing Domain-Driven Design patterns. It enforces
|
|
35
|
+
architectural constraints that prevent common pitfalls in large-scale
|
|
36
|
+
applications while maintaining flexibility for domain-specific requirements.
|
|
37
|
+
|
|
38
|
+
### Key Features
|
|
39
|
+
|
|
40
|
+
- **Type-Safe Abstractions**: Fully typed base classes for all DDD building
|
|
41
|
+
blocks
|
|
42
|
+
- **Immutability by Default**: Value objects and entities are frozen to prevent
|
|
43
|
+
accidental mutations
|
|
44
|
+
- **Domain Events Support**: First-class support for event sourcing and
|
|
45
|
+
event-driven architectures
|
|
46
|
+
- **Validation Framework**: Built-in validation for value objects and entities
|
|
47
|
+
- **Zero Dependencies**: Only peer dependencies, minimal bundle footprint
|
|
48
|
+
- **Production Ready**: Used in high-performance systems at scale
|
|
49
|
+
- **Comprehensive Error Types**: Specific error classes for domain-driven
|
|
50
|
+
validation failures
|
|
51
|
+
|
|
52
|
+
## Philosophy
|
|
53
|
+
|
|
54
|
+
This library is built on core principles that enable teams to:
|
|
55
|
+
|
|
56
|
+
1. **Express Domain Logic Explicitly**: Make business rules clear and testable
|
|
57
|
+
2. **Enforce Invariants**: Validate state transitions at the boundary
|
|
58
|
+
3. **Manage Complexity**: Use aggregates to create transaction boundaries
|
|
59
|
+
4. **Enable Event-Driven Architectures**: Capture and publish domain events
|
|
60
|
+
5. **Maintain Testability**: Pure domain logic with no hidden dependencies
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install @rineex/ddd
|
|
66
|
+
# or
|
|
67
|
+
pnpm add @rineex/ddd
|
|
68
|
+
# or
|
|
69
|
+
yarn add @rineex/ddd
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Requirements
|
|
73
|
+
|
|
74
|
+
- **Node.js**: 18.0 or higher
|
|
75
|
+
- **TypeScript**: 5.0 or higher (recommended: 5.9+)
|
|
76
|
+
- **ES2020+**: Target the module to ES2020 or higher for optimal compatibility
|
|
77
|
+
|
|
78
|
+
## Quick Start
|
|
79
|
+
|
|
80
|
+
Here's a minimal example to get started:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import {
|
|
84
|
+
Entity,
|
|
85
|
+
AggregateRoot,
|
|
86
|
+
ValueObject,
|
|
87
|
+
DomainEvent,
|
|
88
|
+
ApplicationServicePort,
|
|
89
|
+
AggregateId,
|
|
90
|
+
} from '@rineex/ddd';
|
|
91
|
+
|
|
92
|
+
// Define a Value Object
|
|
93
|
+
class Email extends ValueObject<string> {
|
|
94
|
+
public static create(value: string) {
|
|
95
|
+
return new Email(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
protected validate(props: string): void {
|
|
99
|
+
if (!props.includes('@')) {
|
|
100
|
+
throw new Error('Invalid email');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Define an Aggregate Root
|
|
106
|
+
interface UserProps {
|
|
107
|
+
email: Email;
|
|
108
|
+
isActive: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
class User extends AggregateRoot<UserProps> {
|
|
112
|
+
get email(): Email {
|
|
113
|
+
return this.props.email;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get isActive(): boolean {
|
|
117
|
+
return this.props.isActive;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected validate(): void {
|
|
121
|
+
if (!this.email) {
|
|
122
|
+
throw new Error('Email is required');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Create and use
|
|
128
|
+
const userId = AggregateId.create();
|
|
129
|
+
const user = new User({
|
|
130
|
+
id: userId,
|
|
131
|
+
createdAt: new Date(),
|
|
132
|
+
props: { email: Email.create('user@example.com'), isActive: true },
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
user.addEvent(
|
|
136
|
+
new UserCreatedEvent({
|
|
137
|
+
id: 'evt-1',
|
|
138
|
+
aggregateId: userId.uuid,
|
|
139
|
+
schemaVersion: 1,
|
|
140
|
+
occurredAt: Date.now(),
|
|
141
|
+
payload: { email: user.email.value },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const events = user.pullDomainEvents();
|
|
146
|
+
console.log(events); // [UserCreatedEvent]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Core Concepts
|
|
150
|
+
|
|
151
|
+
### Value Objects
|
|
152
|
+
|
|
153
|
+
Value Objects are immutable objects that are distinguished by their value rather
|
|
154
|
+
than their identity. They represent concepts within the domain that have no
|
|
155
|
+
lifecycle.
|
|
156
|
+
|
|
157
|
+
#### Characteristics
|
|
158
|
+
|
|
159
|
+
- **Immutable**: Cannot be changed after creation
|
|
160
|
+
- **Identity by Value**: Two value objects with the same properties are equal
|
|
161
|
+
- **Self-Validating**: Validation occurs during construction
|
|
162
|
+
- **No Side Effects**: Pure transformations only
|
|
163
|
+
|
|
164
|
+
#### Implementation
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { ValueObject } from '@rineex/ddd';
|
|
168
|
+
|
|
169
|
+
interface AddressProps {
|
|
170
|
+
street: string;
|
|
171
|
+
city: string;
|
|
172
|
+
postalCode: string;
|
|
173
|
+
country: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
class Address extends ValueObject<AddressProps> {
|
|
177
|
+
get street(): string {
|
|
178
|
+
return this.props.street;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get city(): string {
|
|
182
|
+
return this.props.city;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get postalCode(): string {
|
|
186
|
+
return this.props.postalCode;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get country(): string {
|
|
190
|
+
return this.props.country;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
public static create(props: AddressProps): Address {
|
|
194
|
+
return new Address(props);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
protected validate(props: AddressProps): void {
|
|
198
|
+
if (!props.street || props.street.trim().length === 0) {
|
|
199
|
+
throw new Error('Street is required');
|
|
200
|
+
}
|
|
201
|
+
if (!props.city || props.city.trim().length === 0) {
|
|
202
|
+
throw new Error('City is required');
|
|
203
|
+
}
|
|
204
|
+
if (props.postalCode.length < 3) {
|
|
205
|
+
throw new Error('Invalid postal code');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Usage
|
|
211
|
+
const address = Address.create({
|
|
212
|
+
street: '123 Main St',
|
|
213
|
+
city: 'New York',
|
|
214
|
+
postalCode: '10001',
|
|
215
|
+
country: 'USA',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Immutability guaranteed
|
|
219
|
+
// address.props.street = 'foo'; // Error: Cannot assign to read only property
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### Type Safety with `unwrapValueObject`
|
|
223
|
+
|
|
224
|
+
When working with collections of value objects, use the `unwrapValueObject`
|
|
225
|
+
utility:
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { unwrapValueObject, UnwrapValueObject } from '@rineex/ddd';
|
|
229
|
+
|
|
230
|
+
interface UserProps {
|
|
231
|
+
tags: Tag[]; // where Tag extends ValueObject<string>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const unwrapped: UnwrapValueObject<UserProps> = unwrapValueObject(userProps);
|
|
235
|
+
// { tags: ['admin', 'moderator'] }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Entities
|
|
239
|
+
|
|
240
|
+
Entities are objects with a unique identity that persists over time. Unlike
|
|
241
|
+
value objects, they can be mutable and have a lifecycle.
|
|
242
|
+
|
|
243
|
+
#### Characteristics
|
|
244
|
+
|
|
245
|
+
- **Unique Identity**: Distinguished by a unique identifier (not just value)
|
|
246
|
+
- **Lifecycle**: Can be created, modified, and deleted
|
|
247
|
+
- **Mutable**: State can change, but identity remains constant
|
|
248
|
+
- **Equality by Identity**: Two entities with different properties but the same
|
|
249
|
+
ID are equal
|
|
250
|
+
|
|
251
|
+
#### Implementation
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
import { Entity, AggregateId } from '@rineex/ddd';
|
|
255
|
+
import type { CreateEntityProps } from '@rineex/ddd';
|
|
256
|
+
|
|
257
|
+
interface OrderItemProps {
|
|
258
|
+
productId: string;
|
|
259
|
+
quantity: number;
|
|
260
|
+
unitPrice: number;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
class OrderItem extends Entity<OrderItemProps> {
|
|
264
|
+
get productId(): string {
|
|
265
|
+
return this.props.productId;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
get quantity(): number {
|
|
269
|
+
return this.props.quantity;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
get unitPrice(): number {
|
|
273
|
+
return this.props.unitPrice;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
get total(): number {
|
|
277
|
+
return this.quantity * this.unitPrice;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
protected validate(): void {
|
|
281
|
+
if (this.quantity <= 0) {
|
|
282
|
+
throw new Error('Quantity must be greater than zero');
|
|
283
|
+
}
|
|
284
|
+
if (this.unitPrice < 0) {
|
|
285
|
+
throw new Error('Unit price cannot be negative');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Creating an entity
|
|
291
|
+
const item = new OrderItem({
|
|
292
|
+
id: AggregateId.create(),
|
|
293
|
+
createdAt: new Date(),
|
|
294
|
+
props: {
|
|
295
|
+
productId: 'prod-123',
|
|
296
|
+
quantity: 2,
|
|
297
|
+
unitPrice: 29.99,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
console.log(item.total); // 59.98
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Aggregate Roots
|
|
305
|
+
|
|
306
|
+
Aggregate Roots are entities that serve as entry points to aggregates. They
|
|
307
|
+
enforce invariants, manage transactions, and raise domain events.
|
|
308
|
+
|
|
309
|
+
#### Characteristics
|
|
310
|
+
|
|
311
|
+
- **Boundary**: Define the scope of consistency within a transaction
|
|
312
|
+
- **Invariant Enforcement**: Validate rules that involve multiple entities or
|
|
313
|
+
value objects
|
|
314
|
+
- **Event Publisher**: Raise domain events to notify other parts of the system
|
|
315
|
+
- **Transaction Consistency**: All changes within an aggregate should be
|
|
316
|
+
persisted atomically
|
|
317
|
+
|
|
318
|
+
#### Implementation
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import {
|
|
322
|
+
AggregateRoot,
|
|
323
|
+
AggregateId,
|
|
324
|
+
DomainEvent,
|
|
325
|
+
type DomainEventPayload,
|
|
326
|
+
} from '@rineex/ddd';
|
|
327
|
+
|
|
328
|
+
// Define domain events
|
|
329
|
+
class UserCreatedEvent extends DomainEvent {
|
|
330
|
+
public readonly eventName = 'UserCreated';
|
|
331
|
+
|
|
332
|
+
constructor(
|
|
333
|
+
props: Parameters<DomainEvent['constructor']>[0] & {
|
|
334
|
+
payload: { email: string };
|
|
335
|
+
},
|
|
336
|
+
) {
|
|
337
|
+
super(props);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
class UserEmailChangedEvent extends DomainEvent {
|
|
342
|
+
public readonly eventName = 'UserEmailChanged';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Define the aggregate
|
|
346
|
+
interface UserProps {
|
|
347
|
+
email: string;
|
|
348
|
+
isActive: boolean;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
class User extends AggregateRoot<UserProps> {
|
|
352
|
+
get email(): string {
|
|
353
|
+
return this.props.email;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
get isActive(): boolean {
|
|
357
|
+
return this.props.isActive;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
public static create(email: string, id?: AggregateId): User {
|
|
361
|
+
const user = new User({
|
|
362
|
+
id: id || AggregateId.create(),
|
|
363
|
+
createdAt: new Date(),
|
|
364
|
+
props: { email, isActive: true },
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
user.addEvent(
|
|
368
|
+
new UserCreatedEvent({
|
|
369
|
+
id: crypto.randomUUID(),
|
|
370
|
+
aggregateId: user.id.uuid,
|
|
371
|
+
schemaVersion: 1,
|
|
372
|
+
occurredAt: Date.now(),
|
|
373
|
+
payload: { email },
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
return user;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public changeEmail(newEmail: string): void {
|
|
381
|
+
// Validate before changing
|
|
382
|
+
if (!newEmail.includes('@')) {
|
|
383
|
+
throw new Error('Invalid email format');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.props = { ...this.props, email: newEmail };
|
|
387
|
+
|
|
388
|
+
this.addEvent(
|
|
389
|
+
new UserEmailChangedEvent({
|
|
390
|
+
id: crypto.randomUUID(),
|
|
391
|
+
aggregateId: this.id.uuid,
|
|
392
|
+
schemaVersion: 1,
|
|
393
|
+
occurredAt: Date.now(),
|
|
394
|
+
payload: { oldEmail: this.props.email, newEmail },
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
protected validate(): void {
|
|
400
|
+
if (!this.props.email || !this.props.email.includes('@')) {
|
|
401
|
+
throw new Error('User must have a valid email');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Usage
|
|
407
|
+
const user = User.create('john@example.com');
|
|
408
|
+
user.changeEmail('jane@example.com');
|
|
409
|
+
|
|
410
|
+
const events = user.pullDomainEvents(); // Remove events for publishing
|
|
411
|
+
console.log(events); // [UserCreatedEvent, UserEmailChangedEvent]
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
#### Key Methods
|
|
415
|
+
|
|
416
|
+
- **`addEvent(event: DomainEvent): void`** - Adds a domain event after
|
|
417
|
+
validating invariants
|
|
418
|
+
- **`pullDomainEvents(): readonly DomainEvent[]`** - Retrieves and clears all
|
|
419
|
+
domain events
|
|
420
|
+
- **`validate(): void`** - Abstract method for enforcing aggregate invariants
|
|
421
|
+
|
|
422
|
+
### Domain Events
|
|
423
|
+
|
|
424
|
+
Domain Events represent significant things that happened in the domain. They are
|
|
425
|
+
immutable records of past events and enable event-driven architectures.
|
|
426
|
+
|
|
427
|
+
#### Characteristics
|
|
428
|
+
|
|
429
|
+
- **Immutable**: Represent facts that have already occurred
|
|
430
|
+
- **Self-Describing**: Include all necessary information in the payload
|
|
431
|
+
- **Serializable**: Can be persisted and transmitted
|
|
432
|
+
- **Versioned**: Schema version allows for evolution
|
|
433
|
+
- **Timestamped**: Record when the event occurred
|
|
434
|
+
|
|
435
|
+
#### Implementation
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
import { DomainEvent, type DomainEventPayload } from '@rineex/ddd';
|
|
439
|
+
|
|
440
|
+
// Define event payloads (only primitives allowed)
|
|
441
|
+
interface OrderPlacedPayload extends DomainEventPayload {
|
|
442
|
+
customerId: string;
|
|
443
|
+
orderId: string;
|
|
444
|
+
totalAmount: number;
|
|
445
|
+
itemCount: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Create event class
|
|
449
|
+
class OrderPlacedEvent extends DomainEvent<OrderPlacedPayload> {
|
|
450
|
+
public readonly eventName = 'OrderPlaced';
|
|
451
|
+
|
|
452
|
+
constructor(
|
|
453
|
+
props: Omit<
|
|
454
|
+
Parameters<DomainEvent<OrderPlacedPayload>['constructor']>[0],
|
|
455
|
+
'payload'
|
|
456
|
+
> & {
|
|
457
|
+
payload: OrderPlacedPayload;
|
|
458
|
+
},
|
|
459
|
+
) {
|
|
460
|
+
super(props);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Using events
|
|
465
|
+
const event = new OrderPlacedEvent({
|
|
466
|
+
id: crypto.randomUUID(),
|
|
467
|
+
aggregateId: 'order-123',
|
|
468
|
+
schemaVersion: 1,
|
|
469
|
+
occurredAt: Date.now(),
|
|
470
|
+
payload: {
|
|
471
|
+
customerId: 'cust-456',
|
|
472
|
+
orderId: 'order-123',
|
|
473
|
+
totalAmount: 99.99,
|
|
474
|
+
itemCount: 3,
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Events are serializable
|
|
479
|
+
const primitives = event.toPrimitives();
|
|
480
|
+
// {
|
|
481
|
+
// id: '...',
|
|
482
|
+
// aggregateId: 'order-123',
|
|
483
|
+
// schemaVersion: 1,
|
|
484
|
+
// occurredAt: 1234567890,
|
|
485
|
+
// eventName: 'OrderPlaced',
|
|
486
|
+
// payload: { customerId: '...', orderId: '...', ... }
|
|
487
|
+
// }
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Application Services
|
|
491
|
+
|
|
492
|
+
Application Services orchestrate the business logic of the domain. They are the
|
|
493
|
+
entry points for handling use cases and commands.
|
|
494
|
+
|
|
495
|
+
#### Characteristics
|
|
496
|
+
|
|
497
|
+
- **Use Case Implementation**: Each service handles a single, well-defined use
|
|
498
|
+
case
|
|
499
|
+
- **Port Interface**: Implement a standard interface for consistency
|
|
500
|
+
- **Orchestration**: Coordinate domain objects, repositories, and external
|
|
501
|
+
services
|
|
502
|
+
- **Transaction Management**: Define transaction boundaries
|
|
503
|
+
- **Error Handling**: Map domain errors to application-level responses
|
|
504
|
+
|
|
505
|
+
#### Implementation
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
import type { ApplicationServicePort } from '@rineex/ddd';
|
|
509
|
+
|
|
510
|
+
// Define input and output DTOs
|
|
511
|
+
interface CreateUserInput {
|
|
512
|
+
email: string;
|
|
513
|
+
name: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
interface CreateUserOutput {
|
|
517
|
+
id: string;
|
|
518
|
+
email: string;
|
|
519
|
+
name: string;
|
|
520
|
+
createdAt: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Implement the service
|
|
524
|
+
class CreateUserService implements ApplicationServicePort<
|
|
525
|
+
CreateUserInput,
|
|
526
|
+
CreateUserOutput
|
|
527
|
+
> {
|
|
528
|
+
constructor(
|
|
529
|
+
private readonly userRepository: UserRepository,
|
|
530
|
+
private readonly eventPublisher: EventPublisher,
|
|
531
|
+
) {}
|
|
532
|
+
|
|
533
|
+
async execute(args: CreateUserInput): Promise<CreateUserOutput> {
|
|
534
|
+
// Check for existing user
|
|
535
|
+
const existing = await this.userRepository.findByEmail(args.email);
|
|
536
|
+
if (existing) {
|
|
537
|
+
throw new Error(`User with email ${args.email} already exists`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Create aggregate
|
|
541
|
+
const user = User.create(args.email, args.name);
|
|
542
|
+
|
|
543
|
+
// Persist
|
|
544
|
+
await this.userRepository.save(user);
|
|
545
|
+
|
|
546
|
+
// Publish events
|
|
547
|
+
const events = user.pullDomainEvents();
|
|
548
|
+
await this.eventPublisher.publishAll(events);
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
id: user.id.uuid,
|
|
552
|
+
email: user.email,
|
|
553
|
+
name: user.name,
|
|
554
|
+
createdAt: user.createdAt.toISOString(),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Using the service
|
|
560
|
+
const createUserService = new CreateUserService(userRepository, eventPublisher);
|
|
561
|
+
const result = await createUserService.execute({
|
|
562
|
+
email: 'user@example.com',
|
|
563
|
+
name: 'John Doe',
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
## API Reference
|
|
568
|
+
|
|
569
|
+
### Value Objects
|
|
570
|
+
|
|
571
|
+
#### `ValueObject<T>`
|
|
572
|
+
|
|
573
|
+
Abstract base class for all value objects.
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
export abstract class ValueObject<T> {
|
|
577
|
+
get value(): T;
|
|
578
|
+
public static is(vo: unknown): vo is ValueObject<unknown>;
|
|
579
|
+
public equals(other?: ValueObject<T>): boolean;
|
|
580
|
+
protected abstract validate(props: T): void;
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
**Methods:**
|
|
585
|
+
|
|
586
|
+
- `value` - Returns the immutable properties
|
|
587
|
+
- `is(vo)` - Type guard for runtime checking
|
|
588
|
+
- `equals(other)` - Deep equality comparison
|
|
589
|
+
- `validate(props)` - Validation logic (must be implemented)
|
|
590
|
+
|
|
591
|
+
### Entities
|
|
592
|
+
|
|
593
|
+
#### `Entity<EntityProps>`
|
|
594
|
+
|
|
595
|
+
Abstract base class for domain entities.
|
|
596
|
+
|
|
597
|
+
```typescript
|
|
598
|
+
export abstract class Entity<EntityProps> {
|
|
599
|
+
readonly id: AggregateId;
|
|
600
|
+
readonly createdAt: Date;
|
|
601
|
+
readonly metadata: { createdAt: string; id: string };
|
|
602
|
+
abstract validate(): void;
|
|
603
|
+
equals(entity: unknown): boolean;
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**Constructor:**
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
new Entity({
|
|
611
|
+
id?: AggregateId; // Generated if not provided
|
|
612
|
+
createdAt: Date; // Required
|
|
613
|
+
props: EntityProps; // Domain-specific properties
|
|
614
|
+
})
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Aggregate Roots
|
|
618
|
+
|
|
619
|
+
#### `AggregateRoot<EntityProps>`
|
|
620
|
+
|
|
621
|
+
Extends `Entity` with domain event support.
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
|
|
625
|
+
readonly domainEvents: readonly DomainEvent[];
|
|
626
|
+
abstract validate(): void;
|
|
627
|
+
addEvent(event: DomainEvent): void;
|
|
628
|
+
pullDomainEvents(): readonly DomainEvent[];
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
**Methods:**
|
|
633
|
+
|
|
634
|
+
- `addEvent(event)` - Add an event after validating invariants
|
|
635
|
+
- `pullDomainEvents()` - Get and clear all recorded events
|
|
636
|
+
- `domainEvents` - Read-only view of current events
|
|
637
|
+
|
|
638
|
+
### Domain Events
|
|
639
|
+
|
|
640
|
+
#### `DomainEvent<T extends DomainEventPayload>`
|
|
641
|
+
|
|
642
|
+
Abstract base class for domain events.
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
export abstract class DomainEvent<T extends DomainEventPayload> {
|
|
646
|
+
abstract readonly eventName: string;
|
|
647
|
+
readonly id: string;
|
|
648
|
+
readonly aggregateId: string;
|
|
649
|
+
readonly schemaVersion: number;
|
|
650
|
+
readonly occurredAt: number;
|
|
651
|
+
readonly payload: Readonly<T>;
|
|
652
|
+
|
|
653
|
+
toPrimitives(): {
|
|
654
|
+
id: string;
|
|
655
|
+
aggregateId: string;
|
|
656
|
+
schemaVersion: number;
|
|
657
|
+
occurredAt: number;
|
|
658
|
+
eventName: string;
|
|
659
|
+
payload: T;
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
### Application Services
|
|
665
|
+
|
|
666
|
+
#### `ApplicationServicePort<I, O>`
|
|
667
|
+
|
|
668
|
+
Interface for application services.
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
export interface ApplicationServicePort<I, O> {
|
|
672
|
+
execute: (args: I) => Promise<O>;
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Value Objects (Pre-built)
|
|
677
|
+
|
|
678
|
+
#### `AggregateId`
|
|
679
|
+
|
|
680
|
+
Represents the unique identifier for an aggregate.
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
class AggregateId extends ValueObject<{ uuid: string }> {
|
|
684
|
+
get uuid(): string;
|
|
685
|
+
public static create(id?: string): AggregateId;
|
|
686
|
+
}
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
#### `IPAddress`
|
|
690
|
+
|
|
691
|
+
Validates IPv4 and IPv6 addresses.
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
class IPAddress extends ValueObject<string> {
|
|
695
|
+
public static create(value: string): IPAddress;
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
#### `URL`
|
|
700
|
+
|
|
701
|
+
Validates web URLs.
|
|
702
|
+
|
|
703
|
+
```typescript
|
|
704
|
+
class URL extends ValueObject<string> {
|
|
705
|
+
public static create(value: string): URL;
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
#### `UserAgent`
|
|
710
|
+
|
|
711
|
+
Parses and validates user agent strings.
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
class UserAgent extends ValueObject<string> {
|
|
715
|
+
public static create(value: string): UserAgent;
|
|
716
|
+
}
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### Error Types
|
|
720
|
+
|
|
721
|
+
#### `DomainError`
|
|
722
|
+
|
|
723
|
+
Base class for all domain errors.
|
|
724
|
+
|
|
725
|
+
```typescript
|
|
726
|
+
export class DomainError extends Error {
|
|
727
|
+
constructor(message: string);
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
#### `EntityValidationError`
|
|
732
|
+
|
|
733
|
+
Thrown when entity validation fails.
|
|
734
|
+
|
|
735
|
+
```typescript
|
|
736
|
+
export class EntityValidationError extends DomainError {}
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
#### `InvalidValueObjectError`
|
|
740
|
+
|
|
741
|
+
Thrown when value object validation fails.
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
export class InvalidValueObjectError extends DomainError {}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
#### `ApplicationError`
|
|
748
|
+
|
|
749
|
+
Thrown for application-level errors.
|
|
750
|
+
|
|
751
|
+
```typescript
|
|
752
|
+
export class ApplicationError extends DomainError {}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
## Examples
|
|
756
|
+
|
|
757
|
+
### Complete Order Management System
|
|
758
|
+
|
|
759
|
+
Here's a realistic example showing how to structure a domain with multiple
|
|
760
|
+
aggregates:
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
import {
|
|
764
|
+
AggregateRoot,
|
|
765
|
+
AggregateId,
|
|
766
|
+
ValueObject,
|
|
767
|
+
DomainEvent,
|
|
768
|
+
Entity,
|
|
769
|
+
ApplicationServicePort,
|
|
770
|
+
} from '@rineex/ddd';
|
|
771
|
+
|
|
772
|
+
// ============ Value Objects ============
|
|
773
|
+
|
|
774
|
+
interface MoneyProps {
|
|
775
|
+
amount: number;
|
|
776
|
+
currency: string;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
class Money extends ValueObject<MoneyProps> {
|
|
780
|
+
get amount(): number {
|
|
781
|
+
return this.props.amount;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
get currency(): string {
|
|
785
|
+
return this.props.currency;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
public static create(amount: number, currency = 'USD'): Money {
|
|
789
|
+
return new Money({ amount, currency });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
protected validate(props: MoneyProps): void {
|
|
793
|
+
if (props.amount < 0) {
|
|
794
|
+
throw new Error('Amount cannot be negative');
|
|
795
|
+
}
|
|
796
|
+
if (!props.currency || props.currency.length !== 3) {
|
|
797
|
+
throw new Error('Invalid currency code');
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ============ Entities ============
|
|
803
|
+
|
|
804
|
+
interface OrderLineProps {
|
|
805
|
+
productId: string;
|
|
806
|
+
quantity: number;
|
|
807
|
+
price: Money;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
class OrderLine extends Entity<OrderLineProps> {
|
|
811
|
+
get productId(): string {
|
|
812
|
+
return this.props.productId;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
get quantity(): number {
|
|
816
|
+
return this.props.quantity;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
get price(): Money {
|
|
820
|
+
return this.props.price;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
get subtotal(): Money {
|
|
824
|
+
return Money.create(this.price.amount * this.quantity, this.price.currency);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
protected validate(): void {
|
|
828
|
+
if (this.quantity <= 0) {
|
|
829
|
+
throw new Error('Quantity must be positive');
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ============ Domain Events ============
|
|
835
|
+
|
|
836
|
+
class OrderCreatedEvent extends DomainEvent {
|
|
837
|
+
public readonly eventName = 'OrderCreated';
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
class OrderLineAddedEvent extends DomainEvent {
|
|
841
|
+
public readonly eventName = 'OrderLineAdded';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
class OrderCompletedEvent extends DomainEvent {
|
|
845
|
+
public readonly eventName = 'OrderCompleted';
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ============ Aggregate Root ============
|
|
849
|
+
|
|
850
|
+
interface OrderProps {
|
|
851
|
+
customerId: string;
|
|
852
|
+
lines: OrderLine[];
|
|
853
|
+
status: 'pending' | 'completed' | 'cancelled';
|
|
854
|
+
total: Money;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
class Order extends AggregateRoot<OrderProps> {
|
|
858
|
+
get customerId(): string {
|
|
859
|
+
return this.props.customerId;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
get lines(): OrderLine[] {
|
|
863
|
+
return this.props.lines;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
get status(): string {
|
|
867
|
+
return this.props.status;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
get total(): Money {
|
|
871
|
+
return this.props.total;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
public static create(customerId: string, id?: AggregateId): Order {
|
|
875
|
+
const order = new Order({
|
|
876
|
+
id: id || AggregateId.create(),
|
|
877
|
+
createdAt: new Date(),
|
|
878
|
+
props: {
|
|
879
|
+
customerId,
|
|
880
|
+
lines: [],
|
|
881
|
+
status: 'pending',
|
|
882
|
+
total: Money.create(0),
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
order.addEvent(
|
|
887
|
+
new OrderCreatedEvent({
|
|
888
|
+
id: crypto.randomUUID(),
|
|
889
|
+
aggregateId: order.id.uuid,
|
|
890
|
+
schemaVersion: 1,
|
|
891
|
+
occurredAt: Date.now(),
|
|
892
|
+
payload: { customerId },
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
return order;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
public addLine(productId: string, quantity: number, price: Money): void {
|
|
900
|
+
const line = new OrderLine({
|
|
901
|
+
id: AggregateId.create(),
|
|
902
|
+
createdAt: new Date(),
|
|
903
|
+
props: { productId, quantity, price },
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
this.props.lines.push(line);
|
|
907
|
+
this.recalculateTotal();
|
|
908
|
+
|
|
909
|
+
this.addEvent(
|
|
910
|
+
new OrderLineAddedEvent({
|
|
911
|
+
id: crypto.randomUUID(),
|
|
912
|
+
aggregateId: this.id.uuid,
|
|
913
|
+
schemaVersion: 1,
|
|
914
|
+
occurredAt: Date.now(),
|
|
915
|
+
payload: { productId, quantity },
|
|
916
|
+
}),
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
public complete(): void {
|
|
921
|
+
if (this.status !== 'pending') {
|
|
922
|
+
throw new Error('Only pending orders can be completed');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
this.props.status = 'completed';
|
|
926
|
+
|
|
927
|
+
this.addEvent(
|
|
928
|
+
new OrderCompletedEvent({
|
|
929
|
+
id: crypto.randomUUID(),
|
|
930
|
+
aggregateId: this.id.uuid,
|
|
931
|
+
schemaVersion: 1,
|
|
932
|
+
occurredAt: Date.now(),
|
|
933
|
+
payload: { total: this.total.amount },
|
|
934
|
+
}),
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private recalculateTotal(): void {
|
|
939
|
+
const sum = this.lines.reduce((acc, line) => acc + line.subtotal.amount, 0);
|
|
940
|
+
this.props.total = Money.create(sum, 'USD');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
protected validate(): void {
|
|
944
|
+
if (!this.customerId) {
|
|
945
|
+
throw new Error('Customer ID is required');
|
|
946
|
+
}
|
|
947
|
+
if (this.lines.length === 0) {
|
|
948
|
+
throw new Error('Order must have at least one line');
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ============ Application Service ============
|
|
954
|
+
|
|
955
|
+
interface CreateOrderInput {
|
|
956
|
+
customerId: string;
|
|
957
|
+
lines: { productId: string; quantity: number; price: number }[];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
interface CreateOrderOutput {
|
|
961
|
+
id: string;
|
|
962
|
+
customerId: string;
|
|
963
|
+
total: number;
|
|
964
|
+
lineCount: number;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
class CreateOrderService implements ApplicationServicePort<
|
|
968
|
+
CreateOrderInput,
|
|
969
|
+
CreateOrderOutput
|
|
970
|
+
> {
|
|
971
|
+
constructor(private readonly orderRepository: OrderRepository) {}
|
|
972
|
+
|
|
973
|
+
async execute(args: CreateOrderInput): Promise<CreateOrderOutput> {
|
|
974
|
+
const order = Order.create(args.customerId);
|
|
975
|
+
|
|
976
|
+
for (const line of args.lines) {
|
|
977
|
+
order.addLine(line.productId, line.quantity, Money.create(line.price));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
order.complete();
|
|
981
|
+
await this.orderRepository.save(order);
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
id: order.id.uuid,
|
|
985
|
+
customerId: order.customerId,
|
|
986
|
+
total: order.total.amount,
|
|
987
|
+
lineCount: order.lines.length,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
## Best Practices
|
|
994
|
+
|
|
995
|
+
### 1. **Make Invalid States Impossible**
|
|
996
|
+
|
|
997
|
+
Use type system and validation to make invalid states impossible to construct:
|
|
998
|
+
|
|
999
|
+
```typescript
|
|
1000
|
+
// ❌ BAD: Can create invalid state
|
|
1001
|
+
class User {
|
|
1002
|
+
email: string;
|
|
1003
|
+
isVerified: boolean;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ✅ GOOD: Invalid state impossible
|
|
1007
|
+
class UnverifiedUser extends ValueObject<{ email: string }> {}
|
|
1008
|
+
class VerifiedUser extends ValueObject<{ email: string; verifiedAt: Date }> {}
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
### 2. **Keep Aggregates Small**
|
|
1012
|
+
|
|
1013
|
+
Prefer small aggregates with clear boundaries over large aggregates with many
|
|
1014
|
+
entities:
|
|
1015
|
+
|
|
1016
|
+
```typescript
|
|
1017
|
+
// ❌ BAD: Too many entities in one aggregate
|
|
1018
|
+
class Store extends AggregateRoot {
|
|
1019
|
+
employees: Employee[];
|
|
1020
|
+
inventory: InventoryItem[];
|
|
1021
|
+
orders: Order[];
|
|
1022
|
+
// ... many more
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ✅ GOOD: Separate aggregates with references
|
|
1026
|
+
class Store extends AggregateRoot {
|
|
1027
|
+
name: string;
|
|
1028
|
+
// Reference to other aggregates by ID only
|
|
1029
|
+
employeeIds: AggregateId[];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
class Inventory extends AggregateRoot {
|
|
1033
|
+
storeId: AggregateId;
|
|
1034
|
+
items: InventoryItem[];
|
|
1035
|
+
}
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
### 3. **Use Value Objects for Primitive Types**
|
|
1039
|
+
|
|
1040
|
+
Wrap primitives that have domain meaning:
|
|
1041
|
+
|
|
1042
|
+
```typescript
|
|
1043
|
+
// ❌ BAD: Raw primitive types
|
|
1044
|
+
interface User {
|
|
1045
|
+
email: string;
|
|
1046
|
+
phone: string;
|
|
1047
|
+
age: number;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ✅ GOOD: Domain-meaningful value objects
|
|
1051
|
+
interface User {
|
|
1052
|
+
email: Email;
|
|
1053
|
+
phone: PhoneNumber;
|
|
1054
|
+
age: Age;
|
|
1055
|
+
}
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### 4. **Validate at Boundaries**
|
|
1059
|
+
|
|
1060
|
+
Perform all validation when creating aggregates, not repeatedly:
|
|
1061
|
+
|
|
1062
|
+
```typescript
|
|
1063
|
+
// ❌ BAD: Repeated validation
|
|
1064
|
+
function updateEmail(email: string) {
|
|
1065
|
+
if (!isValidEmail(email)) throw Error();
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function sendEmail(email: string) {
|
|
1069
|
+
if (!isValidEmail(email)) throw Error();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ✅ GOOD: Single validation point
|
|
1073
|
+
const email = Email.create(value); // Throws if invalid
|
|
1074
|
+
updateEmail(email);
|
|
1075
|
+
sendEmail(email);
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
### 5. **Event-Driven State Changes**
|
|
1079
|
+
|
|
1080
|
+
All changes should be reflected in domain events:
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
// ✅ GOOD: Changes recorded as events
|
|
1084
|
+
class User extends AggregateRoot {
|
|
1085
|
+
changeEmail(newEmail: Email): void {
|
|
1086
|
+
const oldEmail = this.email;
|
|
1087
|
+
this.props.email = newEmail;
|
|
1088
|
+
|
|
1089
|
+
this.addEvent(
|
|
1090
|
+
new EmailChangedEvent({
|
|
1091
|
+
id: crypto.randomUUID(),
|
|
1092
|
+
aggregateId: this.id.uuid,
|
|
1093
|
+
schemaVersion: 1,
|
|
1094
|
+
occurredAt: Date.now(),
|
|
1095
|
+
payload: { oldEmail: oldEmail.value, newEmail: newEmail.value },
|
|
1096
|
+
}),
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
### 6. **Publish Events After Persistence**
|
|
1103
|
+
|
|
1104
|
+
Always publish events after persisting the aggregate:
|
|
1105
|
+
|
|
1106
|
+
```typescript
|
|
1107
|
+
async function handle(command: CreateUserCommand): Promise<void> {
|
|
1108
|
+
// Create aggregate
|
|
1109
|
+
const user = User.create(command.email);
|
|
1110
|
+
|
|
1111
|
+
// Persist first
|
|
1112
|
+
await userRepository.save(user);
|
|
1113
|
+
|
|
1114
|
+
// Then publish
|
|
1115
|
+
const events = user.pullDomainEvents();
|
|
1116
|
+
await eventPublisher.publishAll(events);
|
|
1117
|
+
}
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
### 7. **Immutability by Convention**
|
|
1121
|
+
|
|
1122
|
+
Even though TypeScript doesn't enforce it, treat all domain objects as
|
|
1123
|
+
immutable:
|
|
1124
|
+
|
|
1125
|
+
```typescript
|
|
1126
|
+
// ✅ GOOD: Replace entire aggregate when state changes
|
|
1127
|
+
class User extends AggregateRoot {
|
|
1128
|
+
changeName(newName: string): void {
|
|
1129
|
+
// Don't mutate: this.props.name = newName;
|
|
1130
|
+
|
|
1131
|
+
// Instead, create new object:
|
|
1132
|
+
this.props = { ...this.props, name: newName };
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
## Error Handling
|
|
1138
|
+
|
|
1139
|
+
Handle different error scenarios appropriately:
|
|
1140
|
+
|
|
1141
|
+
```typescript
|
|
1142
|
+
import {
|
|
1143
|
+
DomainError,
|
|
1144
|
+
EntityValidationError,
|
|
1145
|
+
InvalidValueObjectError,
|
|
1146
|
+
ApplicationError,
|
|
1147
|
+
} from '@rineex/ddd';
|
|
1148
|
+
|
|
1149
|
+
try {
|
|
1150
|
+
const email = Email.create('invalid-email');
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
if (error instanceof InvalidValueObjectError) {
|
|
1153
|
+
// Handle value object validation errors
|
|
1154
|
+
console.error('Invalid email format:', error.message);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
try {
|
|
1159
|
+
const user = new User({
|
|
1160
|
+
id: AggregateId.create(),
|
|
1161
|
+
createdAt: new Date(),
|
|
1162
|
+
props: { email: Email.create('user@example.com') },
|
|
1163
|
+
});
|
|
1164
|
+
user.validate();
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error instanceof EntityValidationError) {
|
|
1167
|
+
// Handle entity validation errors
|
|
1168
|
+
console.error('User invariant violated:', error.message);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
try {
|
|
1173
|
+
await userService.execute(input);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (error instanceof ApplicationError) {
|
|
1176
|
+
// Handle application-level errors
|
|
1177
|
+
console.error('Service failed:', error.message);
|
|
1178
|
+
} else if (error instanceof DomainError) {
|
|
1179
|
+
// Catch-all for domain errors
|
|
1180
|
+
console.error('Domain error:', error.message);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
## TypeScript Support
|
|
1186
|
+
|
|
1187
|
+
This library is built with TypeScript 5.9+ and provides comprehensive type
|
|
1188
|
+
safety:
|
|
1189
|
+
|
|
1190
|
+
```typescript
|
|
1191
|
+
// Full type inference
|
|
1192
|
+
const user = User.create('user@example.com');
|
|
1193
|
+
const id: AggregateId = user.id; // Correctly typed
|
|
1194
|
+
|
|
1195
|
+
// Type-safe event handling
|
|
1196
|
+
const events = user.pullDomainEvents();
|
|
1197
|
+
events.forEach(event => {
|
|
1198
|
+
if (event instanceof UserCreatedEvent) {
|
|
1199
|
+
// Type guard works correctly
|
|
1200
|
+
const payload = event.payload; // Correctly inferred type
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// Proper generic constraints
|
|
1205
|
+
class MyAggregate extends AggregateRoot<MyProps> {
|
|
1206
|
+
// Full type safety with MyProps
|
|
1207
|
+
}
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
### Recommended TypeScript Configuration
|
|
1211
|
+
|
|
1212
|
+
```json
|
|
1213
|
+
{
|
|
1214
|
+
"compilerOptions": {
|
|
1215
|
+
"target": "ES2020",
|
|
1216
|
+
"module": "ESNext",
|
|
1217
|
+
"lib": ["ES2020"],
|
|
1218
|
+
"strict": true,
|
|
1219
|
+
"skipLibCheck": true,
|
|
1220
|
+
"forceConsistentCasingInFileNames": true,
|
|
1221
|
+
"resolveJsonModule": true,
|
|
1222
|
+
"esModuleInterop": true,
|
|
1223
|
+
"declaration": true,
|
|
1224
|
+
"declarationMap": true,
|
|
1225
|
+
"sourceMap": true
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
## Contributing
|
|
1231
|
+
|
|
1232
|
+
Contributions are welcome! Please follow these guidelines:
|
|
1233
|
+
|
|
1234
|
+
1. **Fork** the repository
|
|
1235
|
+
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
|
1236
|
+
3. **Write** tests for new functionality
|
|
1237
|
+
4. **Ensure** all tests pass (`pnpm test`)
|
|
1238
|
+
5. **Follow** the code style (`pnpm lint`)
|
|
1239
|
+
6. **Commit** with clear messages
|
|
1240
|
+
7. **Push** to the branch and create a Pull Request
|
|
1241
|
+
|
|
1242
|
+
### Development Setup
|
|
1243
|
+
|
|
1244
|
+
```bash
|
|
1245
|
+
# Install dependencies
|
|
1246
|
+
pnpm install
|
|
1247
|
+
|
|
1248
|
+
# Run tests
|
|
1249
|
+
pnpm test
|
|
1250
|
+
|
|
1251
|
+
# Run linter
|
|
1252
|
+
pnpm lint
|
|
1253
|
+
|
|
1254
|
+
# Check types
|
|
1255
|
+
pnpm check-types
|
|
1256
|
+
|
|
1257
|
+
# Build the package
|
|
1258
|
+
pnpm build
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
### Code Style
|
|
1262
|
+
|
|
1263
|
+
- Follow the existing code style
|
|
1264
|
+
- Use TypeScript strict mode
|
|
1265
|
+
- Write descriptive variable and function names
|
|
1266
|
+
- Add JSDoc comments for public APIs
|
|
1267
|
+
- Keep functions small and focused
|
|
1268
|
+
|
|
1269
|
+
## License
|
|
1270
|
+
|
|
1271
|
+
This project is licensed under the Apache License 2.0 - see the
|
|
1272
|
+
[LICENSE](LICENSE) file for details.
|
|
1273
|
+
|
|
1274
|
+
## Related Resources
|
|
1275
|
+
|
|
1276
|
+
- [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.domainlanguage.com/ddd/)
|
|
1277
|
+
by Eric Evans
|
|
1278
|
+
- [Implementing Domain-Driven Design](https://vaughnvernon.com/books/) by Vaughn
|
|
1279
|
+
Vernon
|
|
1280
|
+
- [Architecture Patterns with Python](https://www.cosmicpython.com/) by Harry J.
|
|
1281
|
+
W. Percival and Bob Gregory
|
|
1282
|
+
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
|
1283
|
+
|
|
1284
|
+
## Support
|
|
1285
|
+
|
|
1286
|
+
For issues, questions, or suggestions, please open an issue on
|
|
1287
|
+
[GitHub](https://github.com/rineex/core/issues).
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
**Made with ❤️ by the Rineex Team**
|