@rineex/ddd 1.6.0 → 2.0.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 +549 -79
- package/dist/index.d.mts +28 -163
- package/dist/index.d.ts +28 -163
- 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 +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
DomainEvent,
|
|
88
88
|
ApplicationServicePort,
|
|
89
89
|
AggregateId,
|
|
90
|
+
type EntityProps,
|
|
90
91
|
} from '@rineex/ddd';
|
|
91
92
|
|
|
92
93
|
// Define a Value Object
|
|
@@ -108,7 +109,7 @@ interface UserProps {
|
|
|
108
109
|
isActive: boolean;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
class User extends AggregateRoot<UserProps> {
|
|
112
|
+
class User extends AggregateRoot<AggregateId, UserProps> {
|
|
112
113
|
get email(): Email {
|
|
113
114
|
return this.props.email;
|
|
114
115
|
}
|
|
@@ -122,10 +123,24 @@ class User extends AggregateRoot<UserProps> {
|
|
|
122
123
|
throw new Error('Email is required');
|
|
123
124
|
}
|
|
124
125
|
}
|
|
126
|
+
|
|
127
|
+
public toObject(): Record<string, unknown> {
|
|
128
|
+
return {
|
|
129
|
+
id: this.id.toString(),
|
|
130
|
+
createdAt: this.createdAt.toISOString(),
|
|
131
|
+
email: this.email.value,
|
|
132
|
+
isActive: this.isActive,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Define a Domain Event
|
|
138
|
+
class UserCreatedEvent extends DomainEvent<AggregateId> {
|
|
139
|
+
public readonly eventName = 'UserCreated';
|
|
125
140
|
}
|
|
126
141
|
|
|
127
142
|
// Create and use
|
|
128
|
-
const userId = AggregateId.
|
|
143
|
+
const userId = AggregateId.generate();
|
|
129
144
|
const user = new User({
|
|
130
145
|
id: userId,
|
|
131
146
|
createdAt: new Date(),
|
|
@@ -134,8 +149,7 @@ const user = new User({
|
|
|
134
149
|
|
|
135
150
|
user.addEvent(
|
|
136
151
|
new UserCreatedEvent({
|
|
137
|
-
|
|
138
|
-
aggregateId: userId.uuid,
|
|
152
|
+
aggregateId: userId,
|
|
139
153
|
schemaVersion: 1,
|
|
140
154
|
occurredAt: Date.now(),
|
|
141
155
|
payload: { email: user.email.value },
|
|
@@ -219,13 +233,38 @@ const address = Address.create({
|
|
|
219
233
|
// address.props.street = 'foo'; // Error: Cannot assign to read only property
|
|
220
234
|
```
|
|
221
235
|
|
|
236
|
+
#### Primitive Value Objects
|
|
237
|
+
|
|
238
|
+
For value objects that wrap a single primitive (string, number, or boolean), use
|
|
239
|
+
`PrimitiveValueObject` for better performance:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
import { PrimitiveValueObject } from '@rineex/ddd';
|
|
243
|
+
|
|
244
|
+
class Email extends PrimitiveValueObject<string> {
|
|
245
|
+
public static create(value: string): Email {
|
|
246
|
+
return new Email(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
protected validate(value: string): void {
|
|
250
|
+
if (!value.includes('@')) {
|
|
251
|
+
throw new Error('Invalid email');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Usage
|
|
257
|
+
const email = Email.create('user@example.com');
|
|
258
|
+
console.log(email.getValue()); // 'user@example.com'
|
|
259
|
+
```
|
|
260
|
+
|
|
222
261
|
#### Type Safety with `unwrapValueObject`
|
|
223
262
|
|
|
224
263
|
When working with collections of value objects, use the `unwrapValueObject`
|
|
225
264
|
utility:
|
|
226
265
|
|
|
227
266
|
```typescript
|
|
228
|
-
import { unwrapValueObject, UnwrapValueObject } from '@rineex/ddd';
|
|
267
|
+
import { unwrapValueObject, type UnwrapValueObject } from '@rineex/ddd';
|
|
229
268
|
|
|
230
269
|
interface UserProps {
|
|
231
270
|
tags: Tag[]; // where Tag extends ValueObject<string>
|
|
@@ -251,8 +290,7 @@ value objects, they can be mutable and have a lifecycle.
|
|
|
251
290
|
#### Implementation
|
|
252
291
|
|
|
253
292
|
```typescript
|
|
254
|
-
import { Entity, AggregateId } from '@rineex/ddd';
|
|
255
|
-
import type { CreateEntityProps } from '@rineex/ddd';
|
|
293
|
+
import { Entity, AggregateId, type EntityProps } from '@rineex/ddd';
|
|
256
294
|
|
|
257
295
|
interface OrderItemProps {
|
|
258
296
|
productId: string;
|
|
@@ -260,7 +298,7 @@ interface OrderItemProps {
|
|
|
260
298
|
unitPrice: number;
|
|
261
299
|
}
|
|
262
300
|
|
|
263
|
-
class OrderItem extends Entity<OrderItemProps> {
|
|
301
|
+
class OrderItem extends Entity<AggregateId, OrderItemProps> {
|
|
264
302
|
get productId(): string {
|
|
265
303
|
return this.props.productId;
|
|
266
304
|
}
|
|
@@ -285,11 +323,22 @@ class OrderItem extends Entity<OrderItemProps> {
|
|
|
285
323
|
throw new Error('Unit price cannot be negative');
|
|
286
324
|
}
|
|
287
325
|
}
|
|
326
|
+
|
|
327
|
+
public toObject(): Record<string, unknown> {
|
|
328
|
+
return {
|
|
329
|
+
id: this.id.toString(),
|
|
330
|
+
createdAt: this.createdAt.toISOString(),
|
|
331
|
+
productId: this.productId,
|
|
332
|
+
quantity: this.quantity,
|
|
333
|
+
unitPrice: this.unitPrice,
|
|
334
|
+
total: this.total,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
288
337
|
}
|
|
289
338
|
|
|
290
339
|
// Creating an entity
|
|
291
340
|
const item = new OrderItem({
|
|
292
|
-
id: AggregateId.
|
|
341
|
+
id: AggregateId.generate(),
|
|
293
342
|
createdAt: new Date(),
|
|
294
343
|
props: {
|
|
295
344
|
productId: 'prod-123',
|
|
@@ -326,19 +375,23 @@ import {
|
|
|
326
375
|
} from '@rineex/ddd';
|
|
327
376
|
|
|
328
377
|
// Define domain events
|
|
329
|
-
|
|
378
|
+
interface UserCreatedPayload extends DomainEventPayload {
|
|
379
|
+
email: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
class UserCreatedEvent extends DomainEvent<AggregateId, UserCreatedPayload> {
|
|
330
383
|
public readonly eventName = 'UserCreated';
|
|
384
|
+
}
|
|
331
385
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
},
|
|
336
|
-
) {
|
|
337
|
-
super(props);
|
|
338
|
-
}
|
|
386
|
+
interface UserEmailChangedPayload extends DomainEventPayload {
|
|
387
|
+
oldEmail: string;
|
|
388
|
+
newEmail: string;
|
|
339
389
|
}
|
|
340
390
|
|
|
341
|
-
class UserEmailChangedEvent extends DomainEvent
|
|
391
|
+
class UserEmailChangedEvent extends DomainEvent<
|
|
392
|
+
AggregateId,
|
|
393
|
+
UserEmailChangedPayload
|
|
394
|
+
> {
|
|
342
395
|
public readonly eventName = 'UserEmailChanged';
|
|
343
396
|
}
|
|
344
397
|
|
|
@@ -348,7 +401,7 @@ interface UserProps {
|
|
|
348
401
|
isActive: boolean;
|
|
349
402
|
}
|
|
350
403
|
|
|
351
|
-
class User extends AggregateRoot<UserProps> {
|
|
404
|
+
class User extends AggregateRoot<AggregateId, UserProps> {
|
|
352
405
|
get email(): string {
|
|
353
406
|
return this.props.email;
|
|
354
407
|
}
|
|
@@ -358,16 +411,16 @@ class User extends AggregateRoot<UserProps> {
|
|
|
358
411
|
}
|
|
359
412
|
|
|
360
413
|
public static create(email: string, id?: AggregateId): User {
|
|
414
|
+
const userId = id || AggregateId.generate();
|
|
361
415
|
const user = new User({
|
|
362
|
-
id:
|
|
416
|
+
id: userId,
|
|
363
417
|
createdAt: new Date(),
|
|
364
418
|
props: { email, isActive: true },
|
|
365
419
|
});
|
|
366
420
|
|
|
367
421
|
user.addEvent(
|
|
368
422
|
new UserCreatedEvent({
|
|
369
|
-
|
|
370
|
-
aggregateId: user.id.uuid,
|
|
423
|
+
aggregateId: userId,
|
|
371
424
|
schemaVersion: 1,
|
|
372
425
|
occurredAt: Date.now(),
|
|
373
426
|
payload: { email },
|
|
@@ -383,15 +436,15 @@ class User extends AggregateRoot<UserProps> {
|
|
|
383
436
|
throw new Error('Invalid email format');
|
|
384
437
|
}
|
|
385
438
|
|
|
439
|
+
const oldEmail = this.props.email;
|
|
386
440
|
this.props = { ...this.props, email: newEmail };
|
|
387
441
|
|
|
388
442
|
this.addEvent(
|
|
389
443
|
new UserEmailChangedEvent({
|
|
390
|
-
|
|
391
|
-
aggregateId: this.id.uuid,
|
|
444
|
+
aggregateId: this.id,
|
|
392
445
|
schemaVersion: 1,
|
|
393
446
|
occurredAt: Date.now(),
|
|
394
|
-
payload: { oldEmail
|
|
447
|
+
payload: { oldEmail, newEmail },
|
|
395
448
|
}),
|
|
396
449
|
);
|
|
397
450
|
}
|
|
@@ -401,6 +454,15 @@ class User extends AggregateRoot<UserProps> {
|
|
|
401
454
|
throw new Error('User must have a valid email');
|
|
402
455
|
}
|
|
403
456
|
}
|
|
457
|
+
|
|
458
|
+
public toObject(): Record<string, unknown> {
|
|
459
|
+
return {
|
|
460
|
+
id: this.id.toString(),
|
|
461
|
+
createdAt: this.createdAt.toISOString(),
|
|
462
|
+
email: this.email,
|
|
463
|
+
isActive: this.isActive,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
404
466
|
}
|
|
405
467
|
|
|
406
468
|
// Usage
|
|
@@ -435,7 +497,7 @@ immutable records of past events and enable event-driven architectures.
|
|
|
435
497
|
#### Implementation
|
|
436
498
|
|
|
437
499
|
```typescript
|
|
438
|
-
import { DomainEvent, type DomainEventPayload } from '@rineex/ddd';
|
|
500
|
+
import { DomainEvent, type DomainEventPayload, AggregateId } from '@rineex/ddd';
|
|
439
501
|
|
|
440
502
|
// Define event payloads (only primitives allowed)
|
|
441
503
|
interface OrderPlacedPayload extends DomainEventPayload {
|
|
@@ -446,30 +508,19 @@ interface OrderPlacedPayload extends DomainEventPayload {
|
|
|
446
508
|
}
|
|
447
509
|
|
|
448
510
|
// Create event class
|
|
449
|
-
class OrderPlacedEvent extends DomainEvent<OrderPlacedPayload> {
|
|
511
|
+
class OrderPlacedEvent extends DomainEvent<AggregateId, OrderPlacedPayload> {
|
|
450
512
|
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
513
|
}
|
|
463
514
|
|
|
464
515
|
// Using events
|
|
516
|
+
const orderId = AggregateId.generate();
|
|
465
517
|
const event = new OrderPlacedEvent({
|
|
466
|
-
|
|
467
|
-
aggregateId: 'order-123',
|
|
518
|
+
aggregateId: orderId,
|
|
468
519
|
schemaVersion: 1,
|
|
469
520
|
occurredAt: Date.now(),
|
|
470
521
|
payload: {
|
|
471
522
|
customerId: 'cust-456',
|
|
472
|
-
orderId:
|
|
523
|
+
orderId: orderId.toString(),
|
|
473
524
|
totalAmount: 99.99,
|
|
474
525
|
itemCount: 3,
|
|
475
526
|
},
|
|
@@ -479,7 +530,7 @@ const event = new OrderPlacedEvent({
|
|
|
479
530
|
const primitives = event.toPrimitives();
|
|
480
531
|
// {
|
|
481
532
|
// id: '...',
|
|
482
|
-
// aggregateId: '
|
|
533
|
+
// aggregateId: '...',
|
|
483
534
|
// schemaVersion: 1,
|
|
484
535
|
// occurredAt: 1234567890,
|
|
485
536
|
// eventName: 'OrderPlaced',
|
|
@@ -590,17 +641,17 @@ export abstract class ValueObject<T> {
|
|
|
590
641
|
|
|
591
642
|
### Entities
|
|
592
643
|
|
|
593
|
-
#### `Entity<
|
|
644
|
+
#### `Entity<ID extends EntityId, Props>`
|
|
594
645
|
|
|
595
646
|
Abstract base class for domain entities.
|
|
596
647
|
|
|
597
648
|
```typescript
|
|
598
|
-
export abstract class Entity<
|
|
599
|
-
readonly id:
|
|
649
|
+
export abstract class Entity<ID extends EntityId, Props> {
|
|
650
|
+
readonly id: ID;
|
|
600
651
|
readonly createdAt: Date;
|
|
601
|
-
readonly metadata: { createdAt: string; id: string };
|
|
602
652
|
abstract validate(): void;
|
|
603
|
-
|
|
653
|
+
abstract toObject(): Record<string, unknown>;
|
|
654
|
+
equals(other?: Entity<ID, Props>): boolean;
|
|
604
655
|
}
|
|
605
656
|
```
|
|
606
657
|
|
|
@@ -608,22 +659,26 @@ export abstract class Entity<EntityProps> {
|
|
|
608
659
|
|
|
609
660
|
```typescript
|
|
610
661
|
new Entity({
|
|
611
|
-
id
|
|
612
|
-
createdAt
|
|
613
|
-
props:
|
|
662
|
+
id: ID; // Required - must be an EntityId type
|
|
663
|
+
createdAt?: Date; // Optional - defaults to new Date()
|
|
664
|
+
props: Props; // Domain-specific properties
|
|
614
665
|
})
|
|
615
666
|
```
|
|
616
667
|
|
|
617
668
|
### Aggregate Roots
|
|
618
669
|
|
|
619
|
-
#### `AggregateRoot<
|
|
670
|
+
#### `AggregateRoot<ID extends EntityId, P>`
|
|
620
671
|
|
|
621
672
|
Extends `Entity` with domain event support.
|
|
622
673
|
|
|
623
674
|
```typescript
|
|
624
|
-
export abstract class AggregateRoot<
|
|
675
|
+
export abstract class AggregateRoot<ID extends EntityId, P> extends Entity<
|
|
676
|
+
ID,
|
|
677
|
+
P
|
|
678
|
+
> {
|
|
625
679
|
readonly domainEvents: readonly DomainEvent[];
|
|
626
680
|
abstract validate(): void;
|
|
681
|
+
abstract toObject(): Record<string, unknown>;
|
|
627
682
|
addEvent(event: DomainEvent): void;
|
|
628
683
|
pullDomainEvents(): readonly DomainEvent[];
|
|
629
684
|
}
|
|
@@ -637,15 +692,18 @@ export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
|
|
|
637
692
|
|
|
638
693
|
### Domain Events
|
|
639
694
|
|
|
640
|
-
#### `DomainEvent<T extends DomainEventPayload>`
|
|
695
|
+
#### `DomainEvent<AggregateId extends EntityId, T extends DomainEventPayload>`
|
|
641
696
|
|
|
642
697
|
Abstract base class for domain events.
|
|
643
698
|
|
|
644
699
|
```typescript
|
|
645
|
-
export abstract class DomainEvent<
|
|
700
|
+
export abstract class DomainEvent<
|
|
701
|
+
AggregateId extends EntityId = EntityId,
|
|
702
|
+
T extends DomainEventPayload = DomainEventPayload,
|
|
703
|
+
> {
|
|
646
704
|
abstract readonly eventName: string;
|
|
647
705
|
readonly id: string;
|
|
648
|
-
readonly aggregateId:
|
|
706
|
+
readonly aggregateId: AggregateId;
|
|
649
707
|
readonly schemaVersion: number;
|
|
650
708
|
readonly occurredAt: number;
|
|
651
709
|
readonly payload: Readonly<T>;
|
|
@@ -677,12 +735,28 @@ export interface ApplicationServicePort<I, O> {
|
|
|
677
735
|
|
|
678
736
|
#### `AggregateId`
|
|
679
737
|
|
|
680
|
-
Represents the unique identifier for an aggregate.
|
|
738
|
+
Represents the unique identifier for an aggregate. Extends `UUID` which extends
|
|
739
|
+
`PrimitiveValueObject<string>`.
|
|
681
740
|
|
|
682
741
|
```typescript
|
|
683
|
-
class AggregateId extends
|
|
684
|
-
|
|
685
|
-
public static
|
|
742
|
+
class AggregateId extends UUID {
|
|
743
|
+
public static generate(): AggregateId;
|
|
744
|
+
public static fromString(value: string): AggregateId;
|
|
745
|
+
public getValue(): string;
|
|
746
|
+
public toString(): string;
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
#### `UUID`
|
|
751
|
+
|
|
752
|
+
Base class for UUID-based value objects.
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
class UUID extends PrimitiveValueObject<string> {
|
|
756
|
+
public static generate<T extends typeof UUID>(this: T): InstanceType<T>;
|
|
757
|
+
public static fromString(value: string): UUID;
|
|
758
|
+
public getValue(): string;
|
|
759
|
+
public toString(): string;
|
|
686
760
|
}
|
|
687
761
|
```
|
|
688
762
|
|
|
@@ -693,16 +767,19 @@ Validates IPv4 and IPv6 addresses.
|
|
|
693
767
|
```typescript
|
|
694
768
|
class IPAddress extends ValueObject<string> {
|
|
695
769
|
public static create(value: string): IPAddress;
|
|
770
|
+
public get value(): string;
|
|
696
771
|
}
|
|
697
772
|
```
|
|
698
773
|
|
|
699
|
-
#### `
|
|
774
|
+
#### `Url`
|
|
700
775
|
|
|
701
776
|
Validates web URLs.
|
|
702
777
|
|
|
703
778
|
```typescript
|
|
704
|
-
class
|
|
705
|
-
public static create(value: string):
|
|
779
|
+
class Url extends ValueObject<string> {
|
|
780
|
+
public static create(value: string): Url;
|
|
781
|
+
public get value(): string;
|
|
782
|
+
public get href(): string;
|
|
706
783
|
}
|
|
707
784
|
```
|
|
708
785
|
|
|
@@ -713,6 +790,10 @@ Parses and validates user agent strings.
|
|
|
713
790
|
```typescript
|
|
714
791
|
class UserAgent extends ValueObject<string> {
|
|
715
792
|
public static create(value: string): UserAgent;
|
|
793
|
+
public get value(): string;
|
|
794
|
+
public get isMobile(): boolean;
|
|
795
|
+
public get isBot(): boolean;
|
|
796
|
+
public getProps(): string;
|
|
716
797
|
}
|
|
717
798
|
```
|
|
718
799
|
|
|
@@ -746,10 +827,301 @@ export class InvalidValueObjectError extends DomainError {}
|
|
|
746
827
|
|
|
747
828
|
#### `ApplicationError`
|
|
748
829
|
|
|
749
|
-
Thrown for application-level errors.
|
|
830
|
+
Thrown for application-level errors with HTTP status codes.
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
export abstract class ApplicationError extends Error {
|
|
834
|
+
readonly code: HttpStatusMessage;
|
|
835
|
+
readonly status: HttpStatusCode;
|
|
836
|
+
readonly isOperational: boolean;
|
|
837
|
+
readonly metadata?: Record<string, unknown>;
|
|
838
|
+
readonly cause?: Error;
|
|
839
|
+
}
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
### Result Type
|
|
843
|
+
|
|
844
|
+
#### `Result<T, E>`
|
|
845
|
+
|
|
846
|
+
Represents the outcome of an operation that can either succeed or fail, without
|
|
847
|
+
relying on exceptions for control flow. This is a functional programming pattern
|
|
848
|
+
that makes error handling explicit in the type system and is commonly used in
|
|
849
|
+
Domain-Driven Design to represent domain operation outcomes.
|
|
850
|
+
|
|
851
|
+
**Key Features:**
|
|
852
|
+
|
|
853
|
+
- **Immutable**: Result instances are frozen to prevent accidental mutations
|
|
854
|
+
- **Type-Safe**: Full TypeScript support with generic types
|
|
855
|
+
- **Explicit Error Handling**: No hidden exceptions, all errors are explicit
|
|
856
|
+
- **DomainError Integration**: Works seamlessly with `DomainError` by default
|
|
857
|
+
|
|
858
|
+
```typescript
|
|
859
|
+
export class Result<T, E = DomainError> {
|
|
860
|
+
readonly isSuccess: boolean;
|
|
861
|
+
readonly isFailure: boolean;
|
|
862
|
+
|
|
863
|
+
public static ok<T, E = never>(value: T): Result<T, E>;
|
|
864
|
+
public static fail<T = never, E = DomainError>(error: E): Result<T, E>;
|
|
865
|
+
public getValue(): T | undefined;
|
|
866
|
+
public getError(): E | undefined;
|
|
867
|
+
public isSuccessResult(): this is Result<T, never>;
|
|
868
|
+
public isFailureResult(): this is Result<never, E>;
|
|
869
|
+
}
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
**Basic Example:**
|
|
873
|
+
|
|
874
|
+
```typescript
|
|
875
|
+
import { Result, DomainError } from '@rineex/ddd';
|
|
876
|
+
|
|
877
|
+
class InvalidValueError extends DomainError {
|
|
878
|
+
public get code() {
|
|
879
|
+
return 'DOMAIN.INVALID_VALUE' as const;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
constructor(message: string) {
|
|
883
|
+
super({ message });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function parseNumber(input: string): Result<number, DomainError> {
|
|
888
|
+
const value = Number(input);
|
|
889
|
+
if (Number.isNaN(value)) {
|
|
890
|
+
return Result.fail(new InvalidValueError('Invalid number'));
|
|
891
|
+
}
|
|
892
|
+
return Result.ok(value);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const result = parseNumber('42');
|
|
896
|
+
if (result.isSuccess) {
|
|
897
|
+
const value = result.getValue(); // number | undefined
|
|
898
|
+
console.log(value); // 42
|
|
899
|
+
} else {
|
|
900
|
+
const error = result.getError(); // DomainError | undefined
|
|
901
|
+
console.error(error?.message);
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**Validation Pattern:**
|
|
906
|
+
|
|
907
|
+
```typescript
|
|
908
|
+
function validateAge(age: number): Result<number, DomainError> {
|
|
909
|
+
if (age < 0) {
|
|
910
|
+
return Result.fail(new InvalidValueError('Age cannot be negative'));
|
|
911
|
+
}
|
|
912
|
+
if (age > 150) {
|
|
913
|
+
return Result.fail(new InvalidValueError('Age seems unrealistic'));
|
|
914
|
+
}
|
|
915
|
+
return Result.ok(age);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const result = validateAge(25);
|
|
919
|
+
if (result.isSuccess) {
|
|
920
|
+
console.log('Valid age:', result.getValue());
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
**Chaining Pattern:**
|
|
925
|
+
|
|
926
|
+
```typescript
|
|
927
|
+
function validateEmail(email: string): Result<string, DomainError> {
|
|
928
|
+
if (!email.includes('@')) {
|
|
929
|
+
return Result.fail(new InvalidValueError('Invalid email format'));
|
|
930
|
+
}
|
|
931
|
+
return Result.ok(email);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function createAccount(email: string): Result<{ email: string }, DomainError> {
|
|
935
|
+
const emailResult = validateEmail(email);
|
|
936
|
+
if (emailResult.isFailureResult()) {
|
|
937
|
+
return emailResult; // Forward the error
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const validatedEmail = emailResult.getValue()!;
|
|
941
|
+
return Result.ok({ email: validatedEmail });
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
**Using Type Guards:**
|
|
946
|
+
|
|
947
|
+
The `isSuccessResult()` and `isFailureResult()` methods provide type-safe
|
|
948
|
+
narrowing:
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
function processResult<T>(result: Result<T, DomainError>): void {
|
|
952
|
+
if (result.isSuccessResult()) {
|
|
953
|
+
// TypeScript narrows result to Result<T, never>
|
|
954
|
+
const value = result.getValue();
|
|
955
|
+
if (value) {
|
|
956
|
+
// Process the value with full type safety
|
|
957
|
+
console.log('Success:', value);
|
|
958
|
+
}
|
|
959
|
+
} else if (result.isFailureResult()) {
|
|
960
|
+
// TypeScript narrows result to Result<never, DomainError>
|
|
961
|
+
const error = result.getError();
|
|
962
|
+
if (error) {
|
|
963
|
+
// Access error properties with full type safety
|
|
964
|
+
console.error('Error code:', error.code);
|
|
965
|
+
console.error('Error message:', error.message);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
**Working with Domain Errors:**
|
|
750
972
|
|
|
751
973
|
```typescript
|
|
752
|
-
|
|
974
|
+
class InvalidStateError extends DomainError {
|
|
975
|
+
public get code() {
|
|
976
|
+
return 'DOMAIN.INVALID_STATE' as const;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
constructor(
|
|
980
|
+
message: string,
|
|
981
|
+
metadata?: Record<string, boolean | number | string>,
|
|
982
|
+
) {
|
|
983
|
+
super({ message, metadata });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function processOrder(orderId: string): Result<Order, DomainError> {
|
|
988
|
+
const order = orderRepository.findById(orderId);
|
|
989
|
+
if (!order) {
|
|
990
|
+
return Result.fail(new InvalidValueError('Order not found', { orderId }));
|
|
991
|
+
}
|
|
992
|
+
if (order.status !== 'PENDING') {
|
|
993
|
+
return Result.fail(
|
|
994
|
+
new InvalidStateError('Order cannot be processed', {
|
|
995
|
+
orderId,
|
|
996
|
+
currentStatus: order.status,
|
|
997
|
+
}),
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
return Result.ok(order);
|
|
1001
|
+
}
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
**Void Operations:**
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
function deleteUser(id: number): Result<void, DomainError> {
|
|
1008
|
+
if (id <= 0) {
|
|
1009
|
+
return Result.fail(new InvalidValueError('Invalid user ID'));
|
|
1010
|
+
}
|
|
1011
|
+
// ... deletion logic ...
|
|
1012
|
+
return Result.ok(undefined);
|
|
1013
|
+
}
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
**Best Practices:**
|
|
1017
|
+
|
|
1018
|
+
1. Always check `isSuccess` or `isFailure` before calling `getValue()` or
|
|
1019
|
+
`getError()`
|
|
1020
|
+
2. Use `isSuccessResult()` and `isFailureResult()` type guards for better type
|
|
1021
|
+
narrowing when you need TypeScript to narrow the result type
|
|
1022
|
+
3. Use `DomainError` for domain-specific errors to maintain consistency
|
|
1023
|
+
4. Forward errors in chaining operations rather than creating new ones
|
|
1024
|
+
5. Leverage TypeScript's type narrowing for safe value extraction
|
|
1025
|
+
|
|
1026
|
+
### Domain Violations
|
|
1027
|
+
|
|
1028
|
+
#### `DomainViolation`
|
|
1029
|
+
|
|
1030
|
+
Base class for domain violations. Purposely does NOT extend native Error to
|
|
1031
|
+
avoid stack trace overhead in the domain.
|
|
1032
|
+
|
|
1033
|
+
```typescript
|
|
1034
|
+
export abstract class DomainViolation {
|
|
1035
|
+
abstract readonly code: string;
|
|
1036
|
+
abstract readonly message: string;
|
|
1037
|
+
readonly metadata: Readonly<Record<string, unknown>>;
|
|
1038
|
+
}
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
### HTTP Status Codes
|
|
1042
|
+
|
|
1043
|
+
#### `HttpStatus` and `HttpStatusMessage`
|
|
1044
|
+
|
|
1045
|
+
Typed HTTP status code constants and messages.
|
|
1046
|
+
|
|
1047
|
+
```typescript
|
|
1048
|
+
export const HttpStatus = {
|
|
1049
|
+
OK: 200,
|
|
1050
|
+
CREATED: 201,
|
|
1051
|
+
BAD_REQUEST: 400,
|
|
1052
|
+
UNAUTHORIZED: 401,
|
|
1053
|
+
FORBIDDEN: 403,
|
|
1054
|
+
NOT_FOUND: 404,
|
|
1055
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
1056
|
+
// ... and many more
|
|
1057
|
+
} as const;
|
|
1058
|
+
|
|
1059
|
+
export const HttpStatusMessage = {
|
|
1060
|
+
200: 'OK',
|
|
1061
|
+
201: 'Created',
|
|
1062
|
+
400: 'Bad Request',
|
|
1063
|
+
// ... and many more
|
|
1064
|
+
} as const;
|
|
1065
|
+
|
|
1066
|
+
export type HttpStatusCode = keyof typeof HttpStatusMessage;
|
|
1067
|
+
export type HttpStatusMessage =
|
|
1068
|
+
(typeof HttpStatusMessage)[keyof typeof HttpStatusMessage];
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
### Utilities
|
|
1072
|
+
|
|
1073
|
+
#### `unwrapValueObject<T>`
|
|
1074
|
+
|
|
1075
|
+
Recursively unwraps value objects from nested structures.
|
|
1076
|
+
|
|
1077
|
+
```typescript
|
|
1078
|
+
export function unwrapValueObject<T>(
|
|
1079
|
+
input: T,
|
|
1080
|
+
seen?: WeakSet<object>
|
|
1081
|
+
): UnwrapValueObject<T>;
|
|
1082
|
+
|
|
1083
|
+
export type UnwrapValueObject<T> = /* recursive type utility */;
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
#### `deepFreeze<T>`
|
|
1087
|
+
|
|
1088
|
+
Deeply freezes objects to ensure immutability.
|
|
1089
|
+
|
|
1090
|
+
```typescript
|
|
1091
|
+
export function deepFreeze<T>(obj: T, seen?: WeakSet<object>): Readonly<T>;
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
### Types
|
|
1095
|
+
|
|
1096
|
+
#### `EntityId<T>`
|
|
1097
|
+
|
|
1098
|
+
Interface for identity value objects.
|
|
1099
|
+
|
|
1100
|
+
```typescript
|
|
1101
|
+
export interface EntityId<T = string> {
|
|
1102
|
+
equals(other?: EntityId<T> | null | undefined): boolean;
|
|
1103
|
+
toString(): string;
|
|
1104
|
+
}
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
#### `EntityProps<ID extends EntityId, Props>`
|
|
1108
|
+
|
|
1109
|
+
Configuration for the base Entity constructor.
|
|
1110
|
+
|
|
1111
|
+
```typescript
|
|
1112
|
+
export interface EntityProps<ID extends EntityId, Props> {
|
|
1113
|
+
readonly id: ID;
|
|
1114
|
+
readonly createdAt?: Date;
|
|
1115
|
+
readonly props: Props;
|
|
1116
|
+
}
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
#### `DomainEventPayload`
|
|
1120
|
+
|
|
1121
|
+
Type for domain event payloads (only primitives and serializable structures).
|
|
1122
|
+
|
|
1123
|
+
```typescript
|
|
1124
|
+
export type DomainEventPayload = Record<string, Serializable>;
|
|
753
1125
|
```
|
|
754
1126
|
|
|
755
1127
|
## Examples
|
|
@@ -767,6 +1139,7 @@ import {
|
|
|
767
1139
|
DomainEvent,
|
|
768
1140
|
Entity,
|
|
769
1141
|
ApplicationServicePort,
|
|
1142
|
+
type DomainEventPayload,
|
|
770
1143
|
} from '@rineex/ddd';
|
|
771
1144
|
|
|
772
1145
|
// ============ Value Objects ============
|
|
@@ -807,7 +1180,7 @@ interface OrderLineProps {
|
|
|
807
1180
|
price: Money;
|
|
808
1181
|
}
|
|
809
1182
|
|
|
810
|
-
class OrderLine extends Entity<OrderLineProps> {
|
|
1183
|
+
class OrderLine extends Entity<AggregateId, OrderLineProps> {
|
|
811
1184
|
get productId(): string {
|
|
812
1185
|
return this.props.productId;
|
|
813
1186
|
}
|
|
@@ -829,19 +1202,48 @@ class OrderLine extends Entity<OrderLineProps> {
|
|
|
829
1202
|
throw new Error('Quantity must be positive');
|
|
830
1203
|
}
|
|
831
1204
|
}
|
|
1205
|
+
|
|
1206
|
+
public toObject(): Record<string, unknown> {
|
|
1207
|
+
return {
|
|
1208
|
+
id: this.id.toString(),
|
|
1209
|
+
createdAt: this.createdAt.toISOString(),
|
|
1210
|
+
productId: this.productId,
|
|
1211
|
+
quantity: this.quantity,
|
|
1212
|
+
price: this.price.value,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
832
1215
|
}
|
|
833
1216
|
|
|
834
1217
|
// ============ Domain Events ============
|
|
835
1218
|
|
|
836
|
-
|
|
1219
|
+
interface OrderCreatedPayload extends DomainEventPayload {
|
|
1220
|
+
customerId: string;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
class OrderCreatedEvent extends DomainEvent<AggregateId, OrderCreatedPayload> {
|
|
837
1224
|
public readonly eventName = 'OrderCreated';
|
|
838
1225
|
}
|
|
839
1226
|
|
|
840
|
-
|
|
1227
|
+
interface OrderLineAddedPayload extends DomainEventPayload {
|
|
1228
|
+
productId: string;
|
|
1229
|
+
quantity: number;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
class OrderLineAddedEvent extends DomainEvent<
|
|
1233
|
+
AggregateId,
|
|
1234
|
+
OrderLineAddedPayload
|
|
1235
|
+
> {
|
|
841
1236
|
public readonly eventName = 'OrderLineAdded';
|
|
842
1237
|
}
|
|
843
1238
|
|
|
844
|
-
|
|
1239
|
+
interface OrderCompletedPayload extends DomainEventPayload {
|
|
1240
|
+
total: number;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
class OrderCompletedEvent extends DomainEvent<
|
|
1244
|
+
AggregateId,
|
|
1245
|
+
OrderCompletedPayload
|
|
1246
|
+
> {
|
|
845
1247
|
public readonly eventName = 'OrderCompleted';
|
|
846
1248
|
}
|
|
847
1249
|
|
|
@@ -854,7 +1256,7 @@ interface OrderProps {
|
|
|
854
1256
|
total: Money;
|
|
855
1257
|
}
|
|
856
1258
|
|
|
857
|
-
class Order extends AggregateRoot<OrderProps> {
|
|
1259
|
+
class Order extends AggregateRoot<AggregateId, OrderProps> {
|
|
858
1260
|
get customerId(): string {
|
|
859
1261
|
return this.props.customerId;
|
|
860
1262
|
}
|
|
@@ -872,8 +1274,9 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
872
1274
|
}
|
|
873
1275
|
|
|
874
1276
|
public static create(customerId: string, id?: AggregateId): Order {
|
|
1277
|
+
const orderId = id || AggregateId.generate();
|
|
875
1278
|
const order = new Order({
|
|
876
|
-
id:
|
|
1279
|
+
id: orderId,
|
|
877
1280
|
createdAt: new Date(),
|
|
878
1281
|
props: {
|
|
879
1282
|
customerId,
|
|
@@ -885,8 +1288,7 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
885
1288
|
|
|
886
1289
|
order.addEvent(
|
|
887
1290
|
new OrderCreatedEvent({
|
|
888
|
-
|
|
889
|
-
aggregateId: order.id.uuid,
|
|
1291
|
+
aggregateId: orderId,
|
|
890
1292
|
schemaVersion: 1,
|
|
891
1293
|
occurredAt: Date.now(),
|
|
892
1294
|
payload: { customerId },
|
|
@@ -898,7 +1300,7 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
898
1300
|
|
|
899
1301
|
public addLine(productId: string, quantity: number, price: Money): void {
|
|
900
1302
|
const line = new OrderLine({
|
|
901
|
-
id: AggregateId.
|
|
1303
|
+
id: AggregateId.generate(),
|
|
902
1304
|
createdAt: new Date(),
|
|
903
1305
|
props: { productId, quantity, price },
|
|
904
1306
|
});
|
|
@@ -908,8 +1310,7 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
908
1310
|
|
|
909
1311
|
this.addEvent(
|
|
910
1312
|
new OrderLineAddedEvent({
|
|
911
|
-
|
|
912
|
-
aggregateId: this.id.uuid,
|
|
1313
|
+
aggregateId: this.id,
|
|
913
1314
|
schemaVersion: 1,
|
|
914
1315
|
occurredAt: Date.now(),
|
|
915
1316
|
payload: { productId, quantity },
|
|
@@ -926,8 +1327,7 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
926
1327
|
|
|
927
1328
|
this.addEvent(
|
|
928
1329
|
new OrderCompletedEvent({
|
|
929
|
-
|
|
930
|
-
aggregateId: this.id.uuid,
|
|
1330
|
+
aggregateId: this.id,
|
|
931
1331
|
schemaVersion: 1,
|
|
932
1332
|
occurredAt: Date.now(),
|
|
933
1333
|
payload: { total: this.total.amount },
|
|
@@ -948,6 +1348,17 @@ class Order extends AggregateRoot<OrderProps> {
|
|
|
948
1348
|
throw new Error('Order must have at least one line');
|
|
949
1349
|
}
|
|
950
1350
|
}
|
|
1351
|
+
|
|
1352
|
+
public toObject(): Record<string, unknown> {
|
|
1353
|
+
return {
|
|
1354
|
+
id: this.id.toString(),
|
|
1355
|
+
createdAt: this.createdAt.toISOString(),
|
|
1356
|
+
customerId: this.customerId,
|
|
1357
|
+
lines: this.lines.map(line => line.toObject()),
|
|
1358
|
+
status: this.status,
|
|
1359
|
+
total: this.total.value,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
951
1362
|
}
|
|
952
1363
|
|
|
953
1364
|
// ============ Application Service ============
|
|
@@ -981,7 +1392,7 @@ class CreateOrderService implements ApplicationServicePort<
|
|
|
981
1392
|
await this.orderRepository.save(order);
|
|
982
1393
|
|
|
983
1394
|
return {
|
|
984
|
-
id: order.id.
|
|
1395
|
+
id: order.id.toString(),
|
|
985
1396
|
customerId: order.customerId,
|
|
986
1397
|
total: order.total.amount,
|
|
987
1398
|
lineCount: order.lines.length,
|
|
@@ -1144,8 +1555,10 @@ import {
|
|
|
1144
1555
|
EntityValidationError,
|
|
1145
1556
|
InvalidValueObjectError,
|
|
1146
1557
|
ApplicationError,
|
|
1558
|
+
Result,
|
|
1147
1559
|
} from '@rineex/ddd';
|
|
1148
1560
|
|
|
1561
|
+
// Using exceptions (traditional approach)
|
|
1149
1562
|
try {
|
|
1150
1563
|
const email = Email.create('invalid-email');
|
|
1151
1564
|
} catch (error) {
|
|
@@ -1157,7 +1570,7 @@ try {
|
|
|
1157
1570
|
|
|
1158
1571
|
try {
|
|
1159
1572
|
const user = new User({
|
|
1160
|
-
id: AggregateId.
|
|
1573
|
+
id: AggregateId.generate(),
|
|
1161
1574
|
createdAt: new Date(),
|
|
1162
1575
|
props: { email: Email.create('user@example.com') },
|
|
1163
1576
|
});
|
|
@@ -1175,9 +1588,66 @@ try {
|
|
|
1175
1588
|
if (error instanceof ApplicationError) {
|
|
1176
1589
|
// Handle application-level errors
|
|
1177
1590
|
console.error('Service failed:', error.message);
|
|
1591
|
+
console.error('HTTP Status:', error.status);
|
|
1592
|
+
console.error('Error Code:', error.code);
|
|
1178
1593
|
} else if (error instanceof DomainError) {
|
|
1179
1594
|
// Catch-all for domain errors
|
|
1180
1595
|
console.error('Domain error:', error.message);
|
|
1596
|
+
console.error('Error Code:', error.code);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Using Result type (functional approach with DomainError)
|
|
1601
|
+
class InvalidEmailError extends DomainError {
|
|
1602
|
+
public get code() {
|
|
1603
|
+
return 'DOMAIN.INVALID_VALUE' as const;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
constructor(message: string) {
|
|
1607
|
+
super({ message });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function createUser(email: string): Result<User, DomainError> {
|
|
1612
|
+
// Validate email format
|
|
1613
|
+
if (!email.includes('@')) {
|
|
1614
|
+
return Result.fail(new InvalidEmailError('Invalid email format'));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
try {
|
|
1618
|
+
const emailVO = Email.create(email);
|
|
1619
|
+
const user = new User({
|
|
1620
|
+
id: AggregateId.generate(),
|
|
1621
|
+
createdAt: new Date(),
|
|
1622
|
+
props: { email: emailVO, isActive: true },
|
|
1623
|
+
});
|
|
1624
|
+
return Result.ok(user);
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
if (error instanceof InvalidValueObjectError) {
|
|
1627
|
+
return Result.fail(new InvalidEmailError(error.message));
|
|
1628
|
+
}
|
|
1629
|
+
return Result.fail(
|
|
1630
|
+
new InvalidEmailError(
|
|
1631
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
1632
|
+
),
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const result = createUser('user@example.com');
|
|
1638
|
+
if (result.isSuccessResult()) {
|
|
1639
|
+
const user = result.getValue();
|
|
1640
|
+
if (user) {
|
|
1641
|
+
// Use user safely with full type safety
|
|
1642
|
+
console.log('User created:', user.id.toString());
|
|
1643
|
+
}
|
|
1644
|
+
} else if (result.isFailureResult()) {
|
|
1645
|
+
const error = result.getError();
|
|
1646
|
+
if (error) {
|
|
1647
|
+
// Handle error with full context and type safety
|
|
1648
|
+
console.error('Error code:', error.code);
|
|
1649
|
+
console.error('Error message:', error.message);
|
|
1650
|
+
console.error('Metadata:', error.metadata);
|
|
1181
1651
|
}
|
|
1182
1652
|
}
|
|
1183
1653
|
```
|