@rineex/ddd 3.0.0 → 3.0.1
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 +487 -1498
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# @rineex/ddd
|
|
2
2
|
|
|
3
|
-
> Domain-Driven Design (DDD)
|
|
4
|
-
>
|
|
5
|
-
> separation of concerns.
|
|
3
|
+
> Domain-Driven Design (DDD) primitives for building maintainable, scalable
|
|
4
|
+
> TypeScript applications.
|
|
6
5
|
|
|
7
6
|
[](https://www.npmjs.com/package/@rineex/ddd)
|
|
8
7
|
[](LICENSE)
|
|
@@ -11,1751 +10,741 @@
|
|
|
11
10
|
## Table of Contents
|
|
12
11
|
|
|
13
12
|
- [Overview](#overview)
|
|
14
|
-
- [Philosophy](#philosophy)
|
|
15
13
|
- [Installation](#installation)
|
|
16
|
-
- [
|
|
17
|
-
- [Core Concepts](#core-concepts)
|
|
14
|
+
- [Package Exports](#package-exports)
|
|
18
15
|
- [Value Objects](#value-objects)
|
|
16
|
+
- [Primitive Value Objects](#primitive-value-objects)
|
|
19
17
|
- [Entities](#entities)
|
|
20
18
|
- [Aggregate Roots](#aggregate-roots)
|
|
21
19
|
- [Domain Events](#domain-events)
|
|
20
|
+
- [Domain Errors](#domain-errors)
|
|
21
|
+
- [Result Type](#result-type)
|
|
22
22
|
- [Application Services](#application-services)
|
|
23
|
+
- [Ports & Utilities](#ports--utilities)
|
|
24
|
+
- [Integration Guide](#integration-guide)
|
|
23
25
|
- [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
26
|
- [License](#license)
|
|
30
27
|
|
|
31
|
-
|
|
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
|
|
28
|
+
---
|
|
39
29
|
|
|
40
|
-
|
|
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
|
|
30
|
+
## Overview
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
`@rineex/ddd` provides type-safe building blocks for implementing Domain-Driven
|
|
33
|
+
Design patterns. Used by `@rineex/authentication` and other Rineex packages.
|
|
53
34
|
|
|
54
|
-
|
|
35
|
+
**Features:** Value Objects, Entities, Aggregate Roots, Domain Events, Domain
|
|
36
|
+
Errors (extensible namespaces), Result type, Application Service port, Clock
|
|
37
|
+
port, HTTP status constants.
|
|
55
38
|
|
|
56
|
-
|
|
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
|
|
39
|
+
---
|
|
61
40
|
|
|
62
41
|
## Installation
|
|
63
42
|
|
|
64
43
|
```bash
|
|
65
|
-
npm install @rineex/ddd
|
|
66
|
-
# or
|
|
67
44
|
pnpm add @rineex/ddd
|
|
68
|
-
# or
|
|
69
|
-
yarn add @rineex/ddd
|
|
70
45
|
```
|
|
71
46
|
|
|
72
|
-
|
|
47
|
+
**Requirements:** Node.js 18+, TypeScript 5.0+, ES2020+ target
|
|
73
48
|
|
|
74
|
-
|
|
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
|
|
49
|
+
---
|
|
79
50
|
|
|
80
|
-
|
|
51
|
+
## Package Exports
|
|
81
52
|
|
|
82
53
|
```typescript
|
|
83
54
|
import {
|
|
55
|
+
ValueObject,
|
|
56
|
+
PrimitiveValueObject,
|
|
84
57
|
Entity,
|
|
85
58
|
AggregateRoot,
|
|
86
|
-
ValueObject,
|
|
87
59
|
DomainEvent,
|
|
88
|
-
ApplicationServicePort,
|
|
89
60
|
AggregateId,
|
|
90
|
-
|
|
61
|
+
DomainID,
|
|
62
|
+
Email,
|
|
63
|
+
DomainError,
|
|
64
|
+
InvalidValueObjectError,
|
|
65
|
+
EntityValidationError,
|
|
66
|
+
InvalidValueError,
|
|
67
|
+
InvalidStateError,
|
|
68
|
+
InternalError,
|
|
69
|
+
TimeoutError,
|
|
70
|
+
ApplicationError,
|
|
71
|
+
ApplicationServicePort,
|
|
72
|
+
Result,
|
|
73
|
+
ClockPort,
|
|
74
|
+
EntityId,
|
|
75
|
+
EntityProps,
|
|
76
|
+
DomainEventPayload,
|
|
77
|
+
CreateEventProps,
|
|
78
|
+
UnixTimestampMillis,
|
|
79
|
+
HttpStatus,
|
|
80
|
+
HttpStatusMessage,
|
|
81
|
+
deepFreeze,
|
|
91
82
|
} from '@rineex/ddd';
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Value Objects
|
|
88
|
+
|
|
89
|
+
Value objects are immutable and defined by attributes. Use `ValueObject<T>` for
|
|
90
|
+
composite structures. Props are deep-frozen in the constructor.
|
|
91
|
+
|
|
92
|
+
### Example (from `vo.spec.ts`)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { ValueObject, InvalidValueObjectError } from '@rineex/ddd';
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return new Email(value);
|
|
97
|
+
class TestValueObject extends ValueObject<{ name: string; age: number }> {
|
|
98
|
+
constructor(props: { name: string; age: number }) {
|
|
99
|
+
super(props);
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
protected validate(props: string): void {
|
|
100
|
-
if (!props.
|
|
101
|
-
throw
|
|
102
|
+
protected validate(props: { name: string; age: number }): void {
|
|
103
|
+
if (!props.name?.trim()) {
|
|
104
|
+
throw InvalidValueObjectError.create('Name is required');
|
|
105
|
+
}
|
|
106
|
+
if (props.age < 0 || props.age > 150) {
|
|
107
|
+
throw InvalidValueObjectError.create('Age must be between 0 and 150');
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
// Usage
|
|
113
|
+
const vo = new TestValueObject({ name: 'John', age: 30 });
|
|
114
|
+
vo.value; // { name: 'John', age: 30 }
|
|
115
|
+
vo.equals(other); // deep equality
|
|
116
|
+
vo.toJSON(); // returns props
|
|
117
|
+
vo.toString(); // JSON.stringify(props)
|
|
118
|
+
ValueObject.is(vo); // type guard
|
|
119
|
+
```
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
get email(): Email {
|
|
114
|
-
return this.props.email;
|
|
115
|
-
}
|
|
121
|
+
### Simple Value Object (wraps a single value)
|
|
116
122
|
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
```typescript
|
|
124
|
+
class SimpleValueObject extends ValueObject<string> {
|
|
125
|
+
constructor(value: string) {
|
|
126
|
+
super(value);
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
protected validate(): void {
|
|
122
|
-
if (!
|
|
123
|
-
throw
|
|
129
|
+
protected validate(value: string): void {
|
|
130
|
+
if (!value?.length) {
|
|
131
|
+
throw InvalidValueObjectError.create('Value cannot be empty');
|
|
124
132
|
}
|
|
125
133
|
}
|
|
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';
|
|
140
134
|
}
|
|
141
|
-
|
|
142
|
-
// Create and use
|
|
143
|
-
const userId = AggregateId.generate();
|
|
144
|
-
const user = new User({
|
|
145
|
-
id: userId,
|
|
146
|
-
createdAt: new Date(),
|
|
147
|
-
props: { email: Email.create('user@example.com'), isActive: true },
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
user.addEvent(
|
|
151
|
-
new UserCreatedEvent({
|
|
152
|
-
aggregateId: userId,
|
|
153
|
-
schemaVersion: 1,
|
|
154
|
-
occurredAt: Date.now(),
|
|
155
|
-
payload: { email: user.email.value },
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
const events = user.pullDomainEvents();
|
|
160
|
-
console.log(events); // [UserCreatedEvent]
|
|
161
135
|
```
|
|
162
136
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
### Value Objects
|
|
166
|
-
|
|
167
|
-
Value Objects are immutable objects that are distinguished by their value rather
|
|
168
|
-
than their identity. They represent concepts within the domain that have no
|
|
169
|
-
lifecycle.
|
|
137
|
+
---
|
|
170
138
|
|
|
171
|
-
|
|
139
|
+
## Primitive Value Objects
|
|
172
140
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
- **Self-Validating**: Validation occurs during construction
|
|
176
|
-
- **No Side Effects**: Pure transformations only
|
|
141
|
+
For single primitives (string, number, boolean), extend
|
|
142
|
+
`PrimitiveValueObject<T>`. Equality is by reference (`===`).
|
|
177
143
|
|
|
178
|
-
|
|
144
|
+
### Example (from `primitive-vo.spec.ts`)
|
|
179
145
|
|
|
180
146
|
```typescript
|
|
181
|
-
import {
|
|
182
|
-
|
|
183
|
-
interface AddressProps {
|
|
184
|
-
street: string;
|
|
185
|
-
city: string;
|
|
186
|
-
postalCode: string;
|
|
187
|
-
country: string;
|
|
188
|
-
}
|
|
147
|
+
import { PrimitiveValueObject, InvalidValueObjectError } from '@rineex/ddd';
|
|
189
148
|
|
|
190
|
-
class
|
|
191
|
-
|
|
192
|
-
|
|
149
|
+
class StringVO extends PrimitiveValueObject<string> {
|
|
150
|
+
constructor(value: string) {
|
|
151
|
+
super(value);
|
|
193
152
|
}
|
|
194
153
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
get postalCode(): string {
|
|
200
|
-
return this.props.postalCode;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
get country(): string {
|
|
204
|
-
return this.props.country;
|
|
154
|
+
protected validate(value: string): void {
|
|
155
|
+
if (!value?.length) {
|
|
156
|
+
throw InvalidValueObjectError.create('String cannot be empty');
|
|
157
|
+
}
|
|
205
158
|
}
|
|
159
|
+
}
|
|
206
160
|
|
|
207
|
-
|
|
208
|
-
|
|
161
|
+
class NumberVO extends PrimitiveValueObject<number> {
|
|
162
|
+
constructor(value: number) {
|
|
163
|
+
super(value);
|
|
209
164
|
}
|
|
210
165
|
|
|
211
|
-
protected validate(
|
|
212
|
-
if (
|
|
213
|
-
throw
|
|
214
|
-
}
|
|
215
|
-
if (!props.city || props.city.trim().length === 0) {
|
|
216
|
-
throw new Error('City is required');
|
|
217
|
-
}
|
|
218
|
-
if (props.postalCode.length < 3) {
|
|
219
|
-
throw new Error('Invalid postal code');
|
|
166
|
+
protected validate(value: number): void {
|
|
167
|
+
if (value < 0) {
|
|
168
|
+
throw InvalidValueObjectError.create('Number must be non-negative');
|
|
220
169
|
}
|
|
221
170
|
}
|
|
222
171
|
}
|
|
223
172
|
|
|
224
173
|
// Usage
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// Immutability guaranteed
|
|
233
|
-
// address.props.street = 'foo'; // Error: Cannot assign to read only property
|
|
174
|
+
const s = new StringVO('test');
|
|
175
|
+
s.value; // 'test'
|
|
176
|
+
s.getValue(); // deprecated, use .value
|
|
177
|
+
s.toString(); // 'test'
|
|
178
|
+
s.equals(new StringVO('test')); // true
|
|
234
179
|
```
|
|
235
180
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
For value objects that wrap a single primitive (string, number, or boolean), use
|
|
239
|
-
`PrimitiveValueObject` for better performance:
|
|
181
|
+
### Pre-built: Email
|
|
240
182
|
|
|
241
183
|
```typescript
|
|
242
|
-
import {
|
|
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
|
-
}
|
|
184
|
+
import { Email } from '@rineex/ddd';
|
|
255
185
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
186
|
+
const email = Email.fromString('user@example.com');
|
|
187
|
+
// or: new Email('user@example.com')
|
|
188
|
+
email.value; // 'user@example.com'
|
|
189
|
+
email.toString();
|
|
259
190
|
```
|
|
260
191
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
When working with collections of value objects, use the `unwrapValueObject`
|
|
264
|
-
utility:
|
|
192
|
+
### Pre-built: AggregateId & DomainID
|
|
265
193
|
|
|
266
194
|
```typescript
|
|
267
|
-
import {
|
|
195
|
+
import { AggregateId, DomainID } from '@rineex/ddd';
|
|
268
196
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
197
|
+
// AggregateId
|
|
198
|
+
const id = AggregateId.generate();
|
|
199
|
+
const fromStr = AggregateId.fromString('550e8400-e29b-41d4-a716-446655440000');
|
|
272
200
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
```
|
|
201
|
+
// DomainID – extend for custom IDs
|
|
202
|
+
class AuthAttemptId extends DomainID {}
|
|
276
203
|
|
|
277
|
-
|
|
204
|
+
const attemptId = AuthAttemptId.generate();
|
|
205
|
+
const parsed = AuthAttemptId.fromString('550e8400-e29b-41d4-a716-446655440000');
|
|
206
|
+
```
|
|
278
207
|
|
|
279
|
-
|
|
280
|
-
value objects, they can be mutable and have a lifecycle.
|
|
208
|
+
---
|
|
281
209
|
|
|
282
|
-
|
|
210
|
+
## Entities
|
|
283
211
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
- **Equality by Identity**: Two entities with different properties but the same
|
|
288
|
-
ID are equal
|
|
212
|
+
Entities have stable identity. Equality is by `id`, not attributes. Use
|
|
213
|
+
`mutate(updater)` for state changes; it re-freezes and re-validates. Use
|
|
214
|
+
`AggregateId` or extend `DomainID` for custom identity types.
|
|
289
215
|
|
|
290
|
-
|
|
216
|
+
### Example (from `@rineex/authentication` OAuthAuthorization)
|
|
291
217
|
|
|
292
218
|
```typescript
|
|
293
|
-
import { Entity,
|
|
294
|
-
|
|
295
|
-
interface OrderItemProps {
|
|
296
|
-
productId: string;
|
|
297
|
-
quantity: number;
|
|
298
|
-
unitPrice: number;
|
|
299
|
-
}
|
|
219
|
+
import { Entity, EntityProps, DomainID } from '@rineex/ddd';
|
|
300
220
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return this.props.productId;
|
|
304
|
-
}
|
|
221
|
+
// Custom ID – extend DomainID for domain-specific identifiers
|
|
222
|
+
class OAuthAuthorizationId extends DomainID {}
|
|
305
223
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
224
|
+
export interface OAuthAuthorizationProps {
|
|
225
|
+
provider: string;
|
|
226
|
+
redirectUri: string;
|
|
227
|
+
scope: readonly string[];
|
|
228
|
+
}
|
|
309
229
|
|
|
310
|
-
|
|
311
|
-
|
|
230
|
+
export class OAuthAuthorization extends Entity<
|
|
231
|
+
OAuthAuthorizationId,
|
|
232
|
+
OAuthAuthorizationProps
|
|
233
|
+
> {
|
|
234
|
+
constructor(
|
|
235
|
+
props: EntityProps<OAuthAuthorizationId, OAuthAuthorizationProps>,
|
|
236
|
+
) {
|
|
237
|
+
super({ ...props });
|
|
312
238
|
}
|
|
313
239
|
|
|
314
|
-
|
|
315
|
-
return
|
|
240
|
+
toObject(): Record<string, unknown> {
|
|
241
|
+
return {
|
|
242
|
+
id: this.id.value,
|
|
243
|
+
provider: this.props.provider,
|
|
244
|
+
redirectUri: this.props.redirectUri,
|
|
245
|
+
scope: this.props.scope,
|
|
246
|
+
};
|
|
316
247
|
}
|
|
317
248
|
|
|
318
|
-
|
|
319
|
-
if (this.
|
|
320
|
-
throw new Error('
|
|
321
|
-
}
|
|
322
|
-
if (this.unitPrice < 0) {
|
|
323
|
-
throw new Error('Unit price cannot be negative');
|
|
249
|
+
validate(): void {
|
|
250
|
+
if (!this.props.redirectUri.startsWith('https://')) {
|
|
251
|
+
throw new Error('Redirect URI must use HTTPS');
|
|
324
252
|
}
|
|
325
253
|
}
|
|
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
|
-
}
|
|
337
254
|
}
|
|
338
255
|
|
|
339
|
-
//
|
|
340
|
-
const
|
|
341
|
-
id:
|
|
342
|
-
createdAt: new Date(),
|
|
256
|
+
// Usage
|
|
257
|
+
const auth = new OAuthAuthorization({
|
|
258
|
+
id: OAuthAuthorizationId.generate(),
|
|
343
259
|
props: {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
260
|
+
provider: 'google',
|
|
261
|
+
redirectUri: 'https://app.example.com/callback',
|
|
262
|
+
scope: ['openid', 'email'],
|
|
347
263
|
},
|
|
348
264
|
});
|
|
349
|
-
|
|
350
|
-
console.log(item.total); // 59.98
|
|
265
|
+
auth.equals(other); // true iff same id
|
|
351
266
|
```
|
|
352
267
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
Aggregate Roots are entities that serve as entry points to aggregates. They
|
|
356
|
-
enforce invariants, manage transactions, and raise domain events.
|
|
268
|
+
---
|
|
357
269
|
|
|
358
|
-
|
|
270
|
+
## Aggregate Roots
|
|
359
271
|
|
|
360
|
-
|
|
361
|
-
- **Invariant Enforcement**: Validate rules that involve multiple entities or
|
|
362
|
-
value objects
|
|
363
|
-
- **Event Publisher**: Raise domain events to notify other parts of the system
|
|
364
|
-
- **Transaction Consistency**: All changes within an aggregate should be
|
|
365
|
-
persisted atomically
|
|
272
|
+
Aggregate roots extend `Entity` and add domain event support.
|
|
366
273
|
|
|
367
|
-
|
|
274
|
+
### Example (from `aggregate-root.spec.ts`)
|
|
368
275
|
|
|
369
276
|
```typescript
|
|
370
277
|
import {
|
|
371
278
|
AggregateRoot,
|
|
372
|
-
AggregateId,
|
|
373
279
|
DomainEvent,
|
|
374
|
-
|
|
280
|
+
AggregateId,
|
|
281
|
+
EntityValidationError,
|
|
375
282
|
} from '@rineex/ddd';
|
|
376
283
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
class UserCreatedEvent extends DomainEvent<AggregateId, UserCreatedPayload> {
|
|
383
|
-
public readonly eventName = 'UserCreated';
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
interface UserEmailChangedPayload extends DomainEventPayload {
|
|
387
|
-
oldEmail: string;
|
|
388
|
-
newEmail: string;
|
|
284
|
+
interface OrderProps {
|
|
285
|
+
customerId: string;
|
|
286
|
+
total: number;
|
|
389
287
|
}
|
|
390
288
|
|
|
391
|
-
class
|
|
289
|
+
class OrderCreatedEvent extends DomainEvent<
|
|
392
290
|
AggregateId,
|
|
393
|
-
|
|
291
|
+
{ customerId: string }
|
|
394
292
|
> {
|
|
395
|
-
|
|
396
|
-
}
|
|
293
|
+
readonly eventName = 'OrderCreated';
|
|
397
294
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
295
|
+
static create(props: {
|
|
296
|
+
id?: string;
|
|
297
|
+
aggregateId: AggregateId;
|
|
298
|
+
schemaVersion: number;
|
|
299
|
+
occurredAt: number;
|
|
300
|
+
payload: { customerId: string };
|
|
301
|
+
}) {
|
|
302
|
+
return new OrderCreatedEvent(props);
|
|
303
|
+
}
|
|
402
304
|
}
|
|
403
305
|
|
|
404
|
-
class
|
|
405
|
-
|
|
406
|
-
return this.props.email;
|
|
407
|
-
}
|
|
306
|
+
class OrderCompletedEvent extends DomainEvent<AggregateId, { total: number }> {
|
|
307
|
+
readonly eventName = 'OrderCompleted';
|
|
408
308
|
|
|
409
|
-
|
|
410
|
-
|
|
309
|
+
static create(props: {
|
|
310
|
+
id?: string;
|
|
311
|
+
aggregateId: AggregateId;
|
|
312
|
+
schemaVersion: number;
|
|
313
|
+
occurredAt: number;
|
|
314
|
+
payload: { total: number };
|
|
315
|
+
}) {
|
|
316
|
+
return new OrderCompletedEvent(props);
|
|
411
317
|
}
|
|
318
|
+
}
|
|
412
319
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
320
|
+
class Order extends AggregateRoot<AggregateId, OrderProps> {
|
|
321
|
+
constructor(params: {
|
|
322
|
+
id: AggregateId;
|
|
323
|
+
createdAt?: Date;
|
|
324
|
+
props: OrderProps;
|
|
325
|
+
}) {
|
|
326
|
+
super(params);
|
|
327
|
+
}
|
|
420
328
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
329
|
+
create(): void {
|
|
330
|
+
this.addEvent(
|
|
331
|
+
OrderCreatedEvent.create({
|
|
332
|
+
aggregateId: this.id,
|
|
424
333
|
schemaVersion: 1,
|
|
425
334
|
occurredAt: Date.now(),
|
|
426
|
-
payload: {
|
|
335
|
+
payload: { customerId: this.props.customerId },
|
|
427
336
|
}),
|
|
428
337
|
);
|
|
429
|
-
|
|
430
|
-
return user;
|
|
431
338
|
}
|
|
432
339
|
|
|
433
|
-
|
|
434
|
-
// Validate before changing
|
|
435
|
-
if (!newEmail.includes('@')) {
|
|
436
|
-
throw new Error('Invalid email format');
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const oldEmail = this.props.email;
|
|
440
|
-
this.props = { ...this.props, email: newEmail };
|
|
441
|
-
|
|
340
|
+
complete(): void {
|
|
442
341
|
this.addEvent(
|
|
443
|
-
|
|
342
|
+
OrderCompletedEvent.create({
|
|
444
343
|
aggregateId: this.id,
|
|
445
344
|
schemaVersion: 1,
|
|
446
345
|
occurredAt: Date.now(),
|
|
447
|
-
payload: {
|
|
346
|
+
payload: { total: this.props.total },
|
|
448
347
|
}),
|
|
449
348
|
);
|
|
450
349
|
}
|
|
451
350
|
|
|
452
|
-
|
|
453
|
-
if (!this.props.
|
|
454
|
-
throw
|
|
351
|
+
validate(): void {
|
|
352
|
+
if (!this.props.customerId?.trim()) {
|
|
353
|
+
throw EntityValidationError.create('Customer ID is required', {});
|
|
354
|
+
}
|
|
355
|
+
if (this.props.total < 0) {
|
|
356
|
+
throw EntityValidationError.create('Total must be non-negative', {});
|
|
455
357
|
}
|
|
456
358
|
}
|
|
457
359
|
|
|
458
|
-
|
|
360
|
+
toObject() {
|
|
459
361
|
return {
|
|
460
362
|
id: this.id.toString(),
|
|
461
363
|
createdAt: this.createdAt.toISOString(),
|
|
462
|
-
|
|
463
|
-
|
|
364
|
+
customerId: this.props.customerId,
|
|
365
|
+
total: this.props.total,
|
|
464
366
|
};
|
|
465
367
|
}
|
|
466
368
|
}
|
|
467
369
|
|
|
468
370
|
// Usage
|
|
469
|
-
const
|
|
470
|
-
|
|
371
|
+
const order = new Order({
|
|
372
|
+
id: AggregateId.generate(),
|
|
373
|
+
props: { customerId: 'customer-1', total: 100 },
|
|
374
|
+
});
|
|
375
|
+
order.create();
|
|
376
|
+
order.complete();
|
|
471
377
|
|
|
472
|
-
|
|
473
|
-
|
|
378
|
+
order.domainEvents; // readonly copy
|
|
379
|
+
const events = order.pullDomainEvents(); // returns and clears
|
|
474
380
|
```
|
|
475
381
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
- **`addEvent(event: DomainEvent): void`** - Adds a domain event after
|
|
479
|
-
validating invariants
|
|
480
|
-
- **`pullDomainEvents(): readonly DomainEvent[]`** - Retrieves and clears all
|
|
481
|
-
domain events
|
|
482
|
-
- **`validate(): void`** - Abstract method for enforcing aggregate invariants
|
|
483
|
-
|
|
484
|
-
### Domain Events
|
|
485
|
-
|
|
486
|
-
Domain Events represent significant things that happened in the domain. They are
|
|
487
|
-
immutable records of past events and enable event-driven architectures.
|
|
382
|
+
---
|
|
488
383
|
|
|
489
|
-
|
|
384
|
+
## Domain Events
|
|
490
385
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
- **Serializable**: Can be persisted and transmitted
|
|
494
|
-
- **Versioned**: Schema version allows for evolution
|
|
495
|
-
- **Timestamped**: Record when the event occurred
|
|
386
|
+
Events are immutable. Payload must be `Serializable` (primitives, arrays, plain
|
|
387
|
+
objects). `id` is auto-generated if omitted.
|
|
496
388
|
|
|
497
|
-
|
|
389
|
+
### Example (from `domain.event.spec.ts`)
|
|
498
390
|
|
|
499
391
|
```typescript
|
|
500
|
-
import { DomainEvent,
|
|
392
|
+
import { DomainEvent, DomainEventPayload, AggregateId } from '@rineex/ddd';
|
|
501
393
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
orderId: string;
|
|
506
|
-
totalAmount: number;
|
|
507
|
-
itemCount: number;
|
|
394
|
+
interface TestPayload extends DomainEventPayload {
|
|
395
|
+
userId: string;
|
|
396
|
+
action: string;
|
|
508
397
|
}
|
|
509
398
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
399
|
+
class TestDomainEvent extends DomainEvent<AggregateId, TestPayload> {
|
|
400
|
+
readonly eventName = 'TestEvent';
|
|
401
|
+
|
|
402
|
+
static create(props: {
|
|
403
|
+
id?: string;
|
|
404
|
+
aggregateId: AggregateId;
|
|
405
|
+
schemaVersion: number;
|
|
406
|
+
occurredAt: number;
|
|
407
|
+
payload: TestPayload;
|
|
408
|
+
}) {
|
|
409
|
+
return new TestDomainEvent(props);
|
|
410
|
+
}
|
|
513
411
|
}
|
|
514
412
|
|
|
515
|
-
//
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
aggregateId: orderId,
|
|
413
|
+
// Usage
|
|
414
|
+
const event = TestDomainEvent.create({
|
|
415
|
+
aggregateId: AggregateId.generate(),
|
|
519
416
|
schemaVersion: 1,
|
|
520
417
|
occurredAt: Date.now(),
|
|
521
|
-
payload: {
|
|
522
|
-
customerId: 'cust-456',
|
|
523
|
-
orderId: orderId.toString(),
|
|
524
|
-
totalAmount: 99.99,
|
|
525
|
-
itemCount: 3,
|
|
526
|
-
},
|
|
418
|
+
payload: { userId: 'user-1', action: 'login' },
|
|
527
419
|
});
|
|
528
420
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
// occurredAt: 1234567890,
|
|
536
|
-
// eventName: 'OrderPlaced',
|
|
537
|
-
// payload: { customerId: '...', orderId: '...', ... }
|
|
538
|
-
// }
|
|
539
|
-
```
|
|
421
|
+
event.id;
|
|
422
|
+
event.eventName;
|
|
423
|
+
event.aggregateId;
|
|
424
|
+
event.schemaVersion;
|
|
425
|
+
event.occurredAt;
|
|
426
|
+
event.payload;
|
|
540
427
|
|
|
541
|
-
|
|
428
|
+
event.toPrimitives();
|
|
429
|
+
// { id, eventName, aggregateId, schemaVersion, occurredAt, payload }
|
|
430
|
+
```
|
|
542
431
|
|
|
543
|
-
|
|
544
|
-
entry points for handling use cases and commands.
|
|
432
|
+
---
|
|
545
433
|
|
|
546
|
-
|
|
434
|
+
## Domain Errors
|
|
547
435
|
|
|
548
|
-
|
|
549
|
-
case
|
|
550
|
-
- **Port Interface**: Implement a standard interface for consistency
|
|
551
|
-
- **Orchestration**: Coordinate domain objects, repositories, and external
|
|
552
|
-
services
|
|
553
|
-
- **Transaction Management**: Define transaction boundaries
|
|
554
|
-
- **Error Handling**: Map domain errors to application-level responses
|
|
436
|
+
### Base DomainError
|
|
555
437
|
|
|
556
|
-
|
|
438
|
+
Extend `DomainError<Meta, Code>` with `code`, `type`, and constructor
|
|
439
|
+
`super(message, metadata)`.
|
|
557
440
|
|
|
558
441
|
```typescript
|
|
559
|
-
import
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
interface CreateUserOutput {
|
|
568
|
-
id: string;
|
|
569
|
-
email: string;
|
|
570
|
-
name: string;
|
|
571
|
-
createdAt: string;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Implement the service
|
|
575
|
-
class CreateUserService implements ApplicationServicePort<
|
|
576
|
-
CreateUserInput,
|
|
577
|
-
CreateUserOutput
|
|
578
|
-
> {
|
|
579
|
-
constructor(
|
|
580
|
-
private readonly userRepository: UserRepository,
|
|
581
|
-
private readonly eventPublisher: EventPublisher,
|
|
582
|
-
) {}
|
|
583
|
-
|
|
584
|
-
async execute(args: CreateUserInput): Promise<CreateUserOutput> {
|
|
585
|
-
// Check for existing user
|
|
586
|
-
const existing = await this.userRepository.findByEmail(args.email);
|
|
587
|
-
if (existing) {
|
|
588
|
-
throw new Error(`User with email ${args.email} already exists`);
|
|
589
|
-
}
|
|
442
|
+
import {
|
|
443
|
+
DomainError,
|
|
444
|
+
DomainErrorCode,
|
|
445
|
+
DomainErrorType,
|
|
446
|
+
Metadata,
|
|
447
|
+
} from '@rineex/ddd';
|
|
590
448
|
|
|
591
|
-
|
|
592
|
-
const user = User.create(args.email, args.name);
|
|
449
|
+
type Props = Metadata<{ identityId: string }>;
|
|
593
450
|
|
|
594
|
-
|
|
595
|
-
|
|
451
|
+
class IdentityDisabledError extends DomainError<Props> {
|
|
452
|
+
readonly code: DomainErrorCode = 'AUTH_CORE_IDENTITY.DISABLED_ERROR';
|
|
453
|
+
readonly type: DomainErrorType = 'DOMAIN.INVALID_STATE';
|
|
596
454
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
455
|
+
private constructor(message: string, props: Props) {
|
|
456
|
+
super(message, props);
|
|
457
|
+
}
|
|
600
458
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
email: user.email,
|
|
604
|
-
name: user.name,
|
|
605
|
-
createdAt: user.createdAt.toISOString(),
|
|
606
|
-
};
|
|
459
|
+
static create(message: string, props: Props) {
|
|
460
|
+
return new IdentityDisabledError(message, props);
|
|
607
461
|
}
|
|
608
462
|
}
|
|
609
|
-
|
|
610
|
-
// Using the service
|
|
611
|
-
const createUserService = new CreateUserService(userRepository, eventPublisher);
|
|
612
|
-
const result = await createUserService.execute({
|
|
613
|
-
email: 'user@example.com',
|
|
614
|
-
name: 'John Doe',
|
|
615
|
-
});
|
|
616
463
|
```
|
|
617
464
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
### Value Objects
|
|
621
|
-
|
|
622
|
-
#### `ValueObject<T>`
|
|
465
|
+
### Extending Error Namespaces
|
|
623
466
|
|
|
624
|
-
|
|
467
|
+
Declare namespaces via module augmentation for type-safe codes:
|
|
625
468
|
|
|
626
469
|
```typescript
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
public static is(vo: unknown): vo is ValueObject<unknown>;
|
|
630
|
-
public equals(other?: ValueObject<T>): boolean;
|
|
631
|
-
protected abstract validate(props: T): void;
|
|
632
|
-
}
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
**Methods:**
|
|
636
|
-
|
|
637
|
-
- `value` - Returns the immutable properties
|
|
638
|
-
- `is(vo)` - Type guard for runtime checking
|
|
639
|
-
- `equals(other)` - Deep equality comparison
|
|
640
|
-
- `validate(props)` - Validation logic (must be implemented)
|
|
470
|
+
// your-module.d.ts
|
|
471
|
+
import '@rineex/ddd';
|
|
641
472
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
```typescript
|
|
649
|
-
export abstract class Entity<ID extends EntityId, Props> {
|
|
650
|
-
readonly id: ID;
|
|
651
|
-
readonly createdAt: Date;
|
|
652
|
-
abstract validate(): void;
|
|
653
|
-
abstract toObject(): Record<string, unknown>;
|
|
654
|
-
equals(other?: Entity<ID, Props>): boolean;
|
|
473
|
+
declare module '@rineex/ddd' {
|
|
474
|
+
interface DomainErrorNamespaces {
|
|
475
|
+
USER: ['NOT_FOUND', 'INVALID_EMAIL'];
|
|
476
|
+
ORDER: ['NOT_FOUND', 'INVALID_STATUS'];
|
|
477
|
+
}
|
|
655
478
|
}
|
|
656
479
|
```
|
|
657
480
|
|
|
658
|
-
|
|
481
|
+
### Built-in Errors
|
|
659
482
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
483
|
+
| Error | Code | Use case |
|
|
484
|
+
| ------------------------- | ------------------------ | ------------------------------------ |
|
|
485
|
+
| `InvalidValueObjectError` | `DOMAIN.INVALID_VALUE` | Value object validation failure |
|
|
486
|
+
| `EntityValidationError` | `CORE.VALIDATION_FAILED` | Entity/aggregate invariant violation |
|
|
487
|
+
| `InvalidValueError` | `DOMAIN.INVALID_VALUE` | Value constraint violation |
|
|
488
|
+
| `InvalidStateError` | `DOMAIN.INVALID_STATE` | Invalid state for operation |
|
|
489
|
+
| `InternalError` | `CORE.INTERNAL_ERROR` | Unexpected/programming errors |
|
|
490
|
+
| `TimeoutError` | `SYSTEM.TIMEOUT` | Operation timeout |
|
|
491
|
+
| `ApplicationError` | (extends `Error`) | Application/HTTP layer errors |
|
|
667
492
|
|
|
668
|
-
|
|
493
|
+
```typescript
|
|
494
|
+
// InvalidValueError – optional metadata
|
|
495
|
+
throw new InvalidValueError('Age cannot be negative');
|
|
496
|
+
throw new InvalidValueError('Validation failed', {
|
|
497
|
+
field: 'age',
|
|
498
|
+
min: 18,
|
|
499
|
+
max: 100,
|
|
500
|
+
});
|
|
669
501
|
|
|
670
|
-
|
|
502
|
+
// InvalidStateError – no metadata
|
|
503
|
+
throw new InvalidStateError('Cannot cancel completed order');
|
|
671
504
|
|
|
672
|
-
|
|
505
|
+
// EntityValidationError – props required
|
|
506
|
+
throw EntityValidationError.create('Name is required', {});
|
|
673
507
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
508
|
+
// ApplicationError – structured params
|
|
509
|
+
class UserNotFoundError extends ApplicationError {
|
|
510
|
+
constructor(userId: string) {
|
|
511
|
+
super({
|
|
512
|
+
message: `User ${userId} not found`,
|
|
513
|
+
code: 'USER_NOT_FOUND',
|
|
514
|
+
isOperational: true,
|
|
515
|
+
metadata: { userId },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
684
518
|
}
|
|
685
519
|
```
|
|
686
520
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
- `addEvent(event)` - Add an event after validating invariants
|
|
690
|
-
- `pullDomainEvents()` - Get and clear all recorded events
|
|
691
|
-
- `domainEvents` - Read-only view of current events
|
|
521
|
+
---
|
|
692
522
|
|
|
693
|
-
|
|
523
|
+
## Result Type
|
|
694
524
|
|
|
695
|
-
|
|
525
|
+
`Result<T, E>` for explicit success/failure without throwing. Default error type
|
|
526
|
+
is `DomainError`.
|
|
696
527
|
|
|
697
|
-
|
|
528
|
+
### Example (from `result.spec.ts`)
|
|
698
529
|
|
|
699
530
|
```typescript
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
readonly aggregateId: AggregateId;
|
|
707
|
-
readonly schemaVersion: number;
|
|
708
|
-
readonly occurredAt: number;
|
|
709
|
-
readonly payload: Readonly<T>;
|
|
710
|
-
|
|
711
|
-
toPrimitives(): {
|
|
712
|
-
id: string;
|
|
713
|
-
aggregateId: string;
|
|
714
|
-
schemaVersion: number;
|
|
715
|
-
occurredAt: number;
|
|
716
|
-
eventName: string;
|
|
717
|
-
payload: T;
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
```
|
|
531
|
+
import {
|
|
532
|
+
Result,
|
|
533
|
+
InvalidValueError,
|
|
534
|
+
InvalidStateError,
|
|
535
|
+
DomainError,
|
|
536
|
+
} from '@rineex/ddd';
|
|
721
537
|
|
|
722
|
-
|
|
538
|
+
// Creation
|
|
539
|
+
const ok = Result.ok(42);
|
|
540
|
+
const fail = Result.fail(new InvalidValueError('Invalid'));
|
|
723
541
|
|
|
724
|
-
|
|
542
|
+
// Checks
|
|
543
|
+
ok.isSuccess; // true
|
|
544
|
+
fail.isFailure; // true
|
|
725
545
|
|
|
726
|
-
|
|
546
|
+
// Extraction
|
|
547
|
+
ok.getValue(); // 42
|
|
548
|
+
fail.getError(); // InvalidValueError
|
|
727
549
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
550
|
+
// Type guards
|
|
551
|
+
if (result.isSuccessResult()) {
|
|
552
|
+
const v = result.getValue(); // T
|
|
553
|
+
}
|
|
554
|
+
if (result.isFailureResult()) {
|
|
555
|
+
const e = result.getError(); // E
|
|
731
556
|
}
|
|
732
557
|
```
|
|
733
558
|
|
|
734
|
-
###
|
|
735
|
-
|
|
736
|
-
#### `AggregateId`
|
|
737
|
-
|
|
738
|
-
Represents the unique identifier for an aggregate. Extends `UUID` which extends
|
|
739
|
-
`PrimitiveValueObject<string>`.
|
|
559
|
+
### Validation pattern
|
|
740
560
|
|
|
741
561
|
```typescript
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
562
|
+
function validateAge(age: number): Result<number, DomainError> {
|
|
563
|
+
if (age < 0) {
|
|
564
|
+
return Result.fail(new InvalidValueError('Age cannot be negative'));
|
|
565
|
+
}
|
|
566
|
+
if (age > 150) {
|
|
567
|
+
return Result.fail(new InvalidValueError('Age seems unrealistic'));
|
|
568
|
+
}
|
|
569
|
+
return Result.ok(age);
|
|
747
570
|
}
|
|
748
571
|
```
|
|
749
572
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
Base class for UUID-based value objects.
|
|
573
|
+
### Chaining
|
|
753
574
|
|
|
754
575
|
```typescript
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
576
|
+
function validateEmail(email: string): Result<string, DomainError> {
|
|
577
|
+
if (!email.includes('@')) {
|
|
578
|
+
return Result.fail(new InvalidValueError('Invalid email format'));
|
|
579
|
+
}
|
|
580
|
+
return Result.ok(email);
|
|
760
581
|
}
|
|
761
|
-
```
|
|
762
582
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
583
|
+
function createAccount(email: string): Result<{ email: string }, DomainError> {
|
|
584
|
+
const emailResult = validateEmail(email);
|
|
585
|
+
if (emailResult.isFailureResult()) return emailResult;
|
|
766
586
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
public static create(value: string): IPAddress;
|
|
770
|
-
public get value(): string;
|
|
587
|
+
const validated = emailResult.getValue()!;
|
|
588
|
+
return Result.ok({ email: validated });
|
|
771
589
|
}
|
|
772
590
|
```
|
|
773
591
|
|
|
774
|
-
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Application Services
|
|
775
595
|
|
|
776
|
-
|
|
596
|
+
Use `ApplicationServicePort<I, O>` for use-case orchestration.
|
|
777
597
|
|
|
778
598
|
```typescript
|
|
779
|
-
|
|
780
|
-
public static create(value: string): Url;
|
|
781
|
-
public get value(): string;
|
|
782
|
-
public get href(): string;
|
|
783
|
-
}
|
|
784
|
-
```
|
|
599
|
+
import { ApplicationServicePort, Result } from '@rineex/ddd';
|
|
785
600
|
|
|
786
|
-
|
|
601
|
+
interface CreateUserInput {
|
|
602
|
+
name: string;
|
|
603
|
+
email: string;
|
|
604
|
+
}
|
|
787
605
|
|
|
788
|
-
|
|
606
|
+
interface CreateUserOutput {
|
|
607
|
+
id: string;
|
|
608
|
+
name: string;
|
|
609
|
+
}
|
|
789
610
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
611
|
+
class CreateUserService implements ApplicationServicePort<
|
|
612
|
+
CreateUserInput,
|
|
613
|
+
CreateUserOutput
|
|
614
|
+
> {
|
|
615
|
+
async execute(args: CreateUserInput): Promise<CreateUserOutput> {
|
|
616
|
+
// validate, create entity, persist, publish events
|
|
617
|
+
return { id: '...', name: args.name };
|
|
618
|
+
}
|
|
797
619
|
}
|
|
798
620
|
```
|
|
799
621
|
|
|
800
|
-
|
|
622
|
+
---
|
|
801
623
|
|
|
802
|
-
|
|
624
|
+
## Ports & Utilities
|
|
803
625
|
|
|
804
|
-
|
|
626
|
+
### ClockPort
|
|
805
627
|
|
|
806
628
|
```typescript
|
|
807
|
-
|
|
808
|
-
constructor(message: string);
|
|
809
|
-
}
|
|
810
|
-
```
|
|
629
|
+
import type { ClockPort } from '@rineex/ddd';
|
|
811
630
|
|
|
812
|
-
|
|
631
|
+
const clock: ClockPort = {
|
|
632
|
+
now: () => new Date(),
|
|
633
|
+
};
|
|
634
|
+
```
|
|
813
635
|
|
|
814
|
-
|
|
636
|
+
### HttpStatus & HttpStatusMessage
|
|
815
637
|
|
|
816
638
|
```typescript
|
|
817
|
-
|
|
818
|
-
```
|
|
639
|
+
import { HttpStatus, HttpStatusMessage } from '@rineex/ddd';
|
|
819
640
|
|
|
820
|
-
|
|
641
|
+
HttpStatus.OK; // 200
|
|
642
|
+
HttpStatus.NOT_FOUND; // 404
|
|
643
|
+
HttpStatusMessage[404]; // 'Not Found'
|
|
644
|
+
```
|
|
821
645
|
|
|
822
|
-
|
|
646
|
+
### deepFreeze
|
|
823
647
|
|
|
824
648
|
```typescript
|
|
825
|
-
|
|
649
|
+
import { deepFreeze } from '@rineex/ddd';
|
|
650
|
+
|
|
651
|
+
const frozen = deepFreeze({ a: 1, nested: { b: 2 } });
|
|
826
652
|
```
|
|
827
653
|
|
|
828
|
-
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## Integration Guide
|
|
829
657
|
|
|
830
|
-
|
|
658
|
+
1. **Add dependency:** `pnpm add @rineex/ddd`
|
|
659
|
+
|
|
660
|
+
2. **Extend `DomainErrorNamespaces`** in a `.d.ts` file:
|
|
831
661
|
|
|
832
662
|
```typescript
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
readonly metadata?: Record<string, unknown>;
|
|
838
|
-
readonly cause?: Error;
|
|
663
|
+
declare module '@rineex/ddd' {
|
|
664
|
+
interface DomainErrorNamespaces {
|
|
665
|
+
MY_MODULE: ['NOT_FOUND', 'INVALID_INPUT'];
|
|
666
|
+
}
|
|
839
667
|
}
|
|
840
668
|
```
|
|
841
669
|
|
|
842
|
-
|
|
670
|
+
3. **Custom IDs:** Extend `DomainID` and use `generate()` / `fromString()`.
|
|
843
671
|
|
|
844
|
-
|
|
672
|
+
4. **Use `mutate()`** for entity/aggregate state changes.
|
|
845
673
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
that makes error handling explicit in the type system and is commonly used in
|
|
849
|
-
Domain-Driven Design to represent domain operation outcomes.
|
|
674
|
+
5. **Persist then publish:** Save aggregate, then call `pullDomainEvents()` and
|
|
675
|
+
publish.
|
|
850
676
|
|
|
851
|
-
|
|
677
|
+
---
|
|
852
678
|
|
|
853
|
-
|
|
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
|
|
679
|
+
## API Reference
|
|
857
680
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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:**
|
|
972
|
-
|
|
973
|
-
```typescript
|
|
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>;
|
|
1125
|
-
```
|
|
1126
|
-
|
|
1127
|
-
## Examples
|
|
1128
|
-
|
|
1129
|
-
### Complete Order Management System
|
|
1130
|
-
|
|
1131
|
-
Here's a realistic example showing how to structure a domain with multiple
|
|
1132
|
-
aggregates:
|
|
1133
|
-
|
|
1134
|
-
```typescript
|
|
1135
|
-
import {
|
|
1136
|
-
AggregateRoot,
|
|
1137
|
-
AggregateId,
|
|
1138
|
-
ValueObject,
|
|
1139
|
-
DomainEvent,
|
|
1140
|
-
Entity,
|
|
1141
|
-
ApplicationServicePort,
|
|
1142
|
-
type DomainEventPayload,
|
|
1143
|
-
} from '@rineex/ddd';
|
|
1144
|
-
|
|
1145
|
-
// ============ Value Objects ============
|
|
1146
|
-
|
|
1147
|
-
interface MoneyProps {
|
|
1148
|
-
amount: number;
|
|
1149
|
-
currency: string;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
class Money extends ValueObject<MoneyProps> {
|
|
1153
|
-
get amount(): number {
|
|
1154
|
-
return this.props.amount;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
get currency(): string {
|
|
1158
|
-
return this.props.currency;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
public static create(amount: number, currency = 'USD'): Money {
|
|
1162
|
-
return new Money({ amount, currency });
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
protected validate(props: MoneyProps): void {
|
|
1166
|
-
if (props.amount < 0) {
|
|
1167
|
-
throw new Error('Amount cannot be negative');
|
|
1168
|
-
}
|
|
1169
|
-
if (!props.currency || props.currency.length !== 3) {
|
|
1170
|
-
throw new Error('Invalid currency code');
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// ============ Entities ============
|
|
1176
|
-
|
|
1177
|
-
interface OrderLineProps {
|
|
1178
|
-
productId: string;
|
|
1179
|
-
quantity: number;
|
|
1180
|
-
price: Money;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
class OrderLine extends Entity<AggregateId, OrderLineProps> {
|
|
1184
|
-
get productId(): string {
|
|
1185
|
-
return this.props.productId;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
get quantity(): number {
|
|
1189
|
-
return this.props.quantity;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
get price(): Money {
|
|
1193
|
-
return this.props.price;
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
get subtotal(): Money {
|
|
1197
|
-
return Money.create(this.price.amount * this.quantity, this.price.currency);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
protected validate(): void {
|
|
1201
|
-
if (this.quantity <= 0) {
|
|
1202
|
-
throw new Error('Quantity must be positive');
|
|
1203
|
-
}
|
|
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
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// ============ Domain Events ============
|
|
1218
|
-
|
|
1219
|
-
interface OrderCreatedPayload extends DomainEventPayload {
|
|
1220
|
-
customerId: string;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
class OrderCreatedEvent extends DomainEvent<AggregateId, OrderCreatedPayload> {
|
|
1224
|
-
public readonly eventName = 'OrderCreated';
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
interface OrderLineAddedPayload extends DomainEventPayload {
|
|
1228
|
-
productId: string;
|
|
1229
|
-
quantity: number;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
class OrderLineAddedEvent extends DomainEvent<
|
|
1233
|
-
AggregateId,
|
|
1234
|
-
OrderLineAddedPayload
|
|
1235
|
-
> {
|
|
1236
|
-
public readonly eventName = 'OrderLineAdded';
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
interface OrderCompletedPayload extends DomainEventPayload {
|
|
1240
|
-
total: number;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
class OrderCompletedEvent extends DomainEvent<
|
|
1244
|
-
AggregateId,
|
|
1245
|
-
OrderCompletedPayload
|
|
1246
|
-
> {
|
|
1247
|
-
public readonly eventName = 'OrderCompleted';
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// ============ Aggregate Root ============
|
|
1251
|
-
|
|
1252
|
-
interface OrderProps {
|
|
1253
|
-
customerId: string;
|
|
1254
|
-
lines: OrderLine[];
|
|
1255
|
-
status: 'pending' | 'completed' | 'cancelled';
|
|
1256
|
-
total: Money;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
class Order extends AggregateRoot<AggregateId, OrderProps> {
|
|
1260
|
-
get customerId(): string {
|
|
1261
|
-
return this.props.customerId;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
get lines(): OrderLine[] {
|
|
1265
|
-
return this.props.lines;
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
get status(): string {
|
|
1269
|
-
return this.props.status;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
get total(): Money {
|
|
1273
|
-
return this.props.total;
|
|
1274
|
-
}
|
|
681
|
+
### ValueObject\<T\>
|
|
682
|
+
|
|
683
|
+
| Member | Description |
|
|
684
|
+
| -------------------- | ------------------------ |
|
|
685
|
+
| `value` | Read-only props |
|
|
686
|
+
| `equals(other)` | Deep equality |
|
|
687
|
+
| `toJSON()` | Returns props |
|
|
688
|
+
| `toString()` | `JSON.stringify(props)` |
|
|
689
|
+
| `ValueObject.is(vo)` | Type guard |
|
|
690
|
+
| `validate(props)` | Abstract, must implement |
|
|
691
|
+
|
|
692
|
+
### PrimitiveValueObject\<T\>
|
|
693
|
+
|
|
694
|
+
| Member | Description |
|
|
695
|
+
| ----------------- | --------------------- |
|
|
696
|
+
| `value` | Primitive value |
|
|
697
|
+
| `getValue()` | Same (deprecated) |
|
|
698
|
+
| `equals(other)` | Reference equality |
|
|
699
|
+
| `toString()` | String representation |
|
|
700
|
+
| `validate(value)` | Abstract |
|
|
701
|
+
|
|
702
|
+
### Entity\<ID, Props\>
|
|
703
|
+
|
|
704
|
+
| Member | Description |
|
|
705
|
+
| ----------------- | ------------------------------ |
|
|
706
|
+
| `id` | Identity |
|
|
707
|
+
| `createdAt` | Creation date |
|
|
708
|
+
| `props` | Read-only (protected) |
|
|
709
|
+
| `equals(other)` | By `id` |
|
|
710
|
+
| `mutate(updater)` | Safe state change + revalidate |
|
|
711
|
+
| `validate()` | Abstract |
|
|
712
|
+
| `toObject()` | Abstract |
|
|
713
|
+
|
|
714
|
+
### AggregateRoot\<ID, Props\>
|
|
715
|
+
|
|
716
|
+
Extends `Entity`. Adds:
|
|
717
|
+
|
|
718
|
+
| Member | Description |
|
|
719
|
+
| -------------------- | ------------------- |
|
|
720
|
+
| `addEvent(event)` | Append domain event |
|
|
721
|
+
| `domainEvents` | Read-only copy |
|
|
722
|
+
| `pullDomainEvents()` | Return and clear |
|
|
723
|
+
|
|
724
|
+
### DomainEvent\<AggregateId, Payload\>
|
|
725
|
+
|
|
726
|
+
| Member | Description |
|
|
727
|
+
| ---------------- | ------------------- |
|
|
728
|
+
| `id` | Event ID |
|
|
729
|
+
| `aggregateId` | Aggregate reference |
|
|
730
|
+
| `schemaVersion` | Version |
|
|
731
|
+
| `occurredAt` | Unix ms |
|
|
732
|
+
| `payload` | Serializable data |
|
|
733
|
+
| `eventName` | Abstract |
|
|
734
|
+
| `toPrimitives()` | Plain object |
|
|
735
|
+
|
|
736
|
+
### Result\<T, E\>
|
|
737
|
+
|
|
738
|
+
| Member | Description |
|
|
739
|
+
| ---------------------------------------- | -------------- |
|
|
740
|
+
| `Result.ok(value)` | Success |
|
|
741
|
+
| `Result.fail(err)` | Failure |
|
|
742
|
+
| `isSuccess`, `isFailure` | Booleans |
|
|
743
|
+
| `getValue()`, `getError()` | Value or error |
|
|
744
|
+
| `isSuccessResult()`, `isFailureResult()` | Type guards |
|
|
1275
745
|
|
|
1276
|
-
|
|
1277
|
-
const orderId = id || AggregateId.generate();
|
|
1278
|
-
const order = new Order({
|
|
1279
|
-
id: orderId,
|
|
1280
|
-
createdAt: new Date(),
|
|
1281
|
-
props: {
|
|
1282
|
-
customerId,
|
|
1283
|
-
lines: [],
|
|
1284
|
-
status: 'pending',
|
|
1285
|
-
total: Money.create(0),
|
|
1286
|
-
},
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
order.addEvent(
|
|
1290
|
-
new OrderCreatedEvent({
|
|
1291
|
-
aggregateId: orderId,
|
|
1292
|
-
schemaVersion: 1,
|
|
1293
|
-
occurredAt: Date.now(),
|
|
1294
|
-
payload: { customerId },
|
|
1295
|
-
}),
|
|
1296
|
-
);
|
|
1297
|
-
|
|
1298
|
-
return order;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
public addLine(productId: string, quantity: number, price: Money): void {
|
|
1302
|
-
const line = new OrderLine({
|
|
1303
|
-
id: AggregateId.generate(),
|
|
1304
|
-
createdAt: new Date(),
|
|
1305
|
-
props: { productId, quantity, price },
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
this.props.lines.push(line);
|
|
1309
|
-
this.recalculateTotal();
|
|
1310
|
-
|
|
1311
|
-
this.addEvent(
|
|
1312
|
-
new OrderLineAddedEvent({
|
|
1313
|
-
aggregateId: this.id,
|
|
1314
|
-
schemaVersion: 1,
|
|
1315
|
-
occurredAt: Date.now(),
|
|
1316
|
-
payload: { productId, quantity },
|
|
1317
|
-
}),
|
|
1318
|
-
);
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
public complete(): void {
|
|
1322
|
-
if (this.status !== 'pending') {
|
|
1323
|
-
throw new Error('Only pending orders can be completed');
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
this.props.status = 'completed';
|
|
1327
|
-
|
|
1328
|
-
this.addEvent(
|
|
1329
|
-
new OrderCompletedEvent({
|
|
1330
|
-
aggregateId: this.id,
|
|
1331
|
-
schemaVersion: 1,
|
|
1332
|
-
occurredAt: Date.now(),
|
|
1333
|
-
payload: { total: this.total.amount },
|
|
1334
|
-
}),
|
|
1335
|
-
);
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
private recalculateTotal(): void {
|
|
1339
|
-
const sum = this.lines.reduce((acc, line) => acc + line.subtotal.amount, 0);
|
|
1340
|
-
this.props.total = Money.create(sum, 'USD');
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
protected validate(): void {
|
|
1344
|
-
if (!this.customerId) {
|
|
1345
|
-
throw new Error('Customer ID is required');
|
|
1346
|
-
}
|
|
1347
|
-
if (this.lines.length === 0) {
|
|
1348
|
-
throw new Error('Order must have at least one line');
|
|
1349
|
-
}
|
|
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
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
// ============ Application Service ============
|
|
1365
|
-
|
|
1366
|
-
interface CreateOrderInput {
|
|
1367
|
-
customerId: string;
|
|
1368
|
-
lines: { productId: string; quantity: number; price: number }[];
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
interface CreateOrderOutput {
|
|
1372
|
-
id: string;
|
|
1373
|
-
customerId: string;
|
|
1374
|
-
total: number;
|
|
1375
|
-
lineCount: number;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
class CreateOrderService implements ApplicationServicePort<
|
|
1379
|
-
CreateOrderInput,
|
|
1380
|
-
CreateOrderOutput
|
|
1381
|
-
> {
|
|
1382
|
-
constructor(private readonly orderRepository: OrderRepository) {}
|
|
1383
|
-
|
|
1384
|
-
async execute(args: CreateOrderInput): Promise<CreateOrderOutput> {
|
|
1385
|
-
const order = Order.create(args.customerId);
|
|
1386
|
-
|
|
1387
|
-
for (const line of args.lines) {
|
|
1388
|
-
order.addLine(line.productId, line.quantity, Money.create(line.price));
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
order.complete();
|
|
1392
|
-
await this.orderRepository.save(order);
|
|
1393
|
-
|
|
1394
|
-
return {
|
|
1395
|
-
id: order.id.toString(),
|
|
1396
|
-
customerId: order.customerId,
|
|
1397
|
-
total: order.total.amount,
|
|
1398
|
-
lineCount: order.lines.length,
|
|
1399
|
-
};
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
```
|
|
1403
|
-
|
|
1404
|
-
## Best Practices
|
|
1405
|
-
|
|
1406
|
-
### 1. **Make Invalid States Impossible**
|
|
1407
|
-
|
|
1408
|
-
Use type system and validation to make invalid states impossible to construct:
|
|
1409
|
-
|
|
1410
|
-
```typescript
|
|
1411
|
-
// ❌ BAD: Can create invalid state
|
|
1412
|
-
class User {
|
|
1413
|
-
email: string;
|
|
1414
|
-
isVerified: boolean;
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
// ✅ GOOD: Invalid state impossible
|
|
1418
|
-
class UnverifiedUser extends ValueObject<{ email: string }> {}
|
|
1419
|
-
class VerifiedUser extends ValueObject<{ email: string; verifiedAt: Date }> {}
|
|
1420
|
-
```
|
|
1421
|
-
|
|
1422
|
-
### 2. **Keep Aggregates Small**
|
|
1423
|
-
|
|
1424
|
-
Prefer small aggregates with clear boundaries over large aggregates with many
|
|
1425
|
-
entities:
|
|
1426
|
-
|
|
1427
|
-
```typescript
|
|
1428
|
-
// ❌ BAD: Too many entities in one aggregate
|
|
1429
|
-
class Store extends AggregateRoot {
|
|
1430
|
-
employees: Employee[];
|
|
1431
|
-
inventory: InventoryItem[];
|
|
1432
|
-
orders: Order[];
|
|
1433
|
-
// ... many more
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
// ✅ GOOD: Separate aggregates with references
|
|
1437
|
-
class Store extends AggregateRoot {
|
|
1438
|
-
name: string;
|
|
1439
|
-
// Reference to other aggregates by ID only
|
|
1440
|
-
employeeIds: AggregateId[];
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
class Inventory extends AggregateRoot {
|
|
1444
|
-
storeId: AggregateId;
|
|
1445
|
-
items: InventoryItem[];
|
|
1446
|
-
}
|
|
1447
|
-
```
|
|
1448
|
-
|
|
1449
|
-
### 3. **Use Value Objects for Primitive Types**
|
|
1450
|
-
|
|
1451
|
-
Wrap primitives that have domain meaning:
|
|
1452
|
-
|
|
1453
|
-
```typescript
|
|
1454
|
-
// ❌ BAD: Raw primitive types
|
|
1455
|
-
interface User {
|
|
1456
|
-
email: string;
|
|
1457
|
-
phone: string;
|
|
1458
|
-
age: number;
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
// ✅ GOOD: Domain-meaningful value objects
|
|
1462
|
-
interface User {
|
|
1463
|
-
email: Email;
|
|
1464
|
-
phone: PhoneNumber;
|
|
1465
|
-
age: Age;
|
|
1466
|
-
}
|
|
1467
|
-
```
|
|
1468
|
-
|
|
1469
|
-
### 4. **Validate at Boundaries**
|
|
1470
|
-
|
|
1471
|
-
Perform all validation when creating aggregates, not repeatedly:
|
|
1472
|
-
|
|
1473
|
-
```typescript
|
|
1474
|
-
// ❌ BAD: Repeated validation
|
|
1475
|
-
function updateEmail(email: string) {
|
|
1476
|
-
if (!isValidEmail(email)) throw Error();
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
function sendEmail(email: string) {
|
|
1480
|
-
if (!isValidEmail(email)) throw Error();
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
// ✅ GOOD: Single validation point
|
|
1484
|
-
const email = Email.create(value); // Throws if invalid
|
|
1485
|
-
updateEmail(email);
|
|
1486
|
-
sendEmail(email);
|
|
1487
|
-
```
|
|
1488
|
-
|
|
1489
|
-
### 5. **Event-Driven State Changes**
|
|
1490
|
-
|
|
1491
|
-
All changes should be reflected in domain events:
|
|
1492
|
-
|
|
1493
|
-
```typescript
|
|
1494
|
-
// ✅ GOOD: Changes recorded as events
|
|
1495
|
-
class User extends AggregateRoot {
|
|
1496
|
-
changeEmail(newEmail: Email): void {
|
|
1497
|
-
const oldEmail = this.email;
|
|
1498
|
-
this.props.email = newEmail;
|
|
1499
|
-
|
|
1500
|
-
this.addEvent(
|
|
1501
|
-
new EmailChangedEvent({
|
|
1502
|
-
id: crypto.randomUUID(),
|
|
1503
|
-
aggregateId: this.id.uuid,
|
|
1504
|
-
schemaVersion: 1,
|
|
1505
|
-
occurredAt: Date.now(),
|
|
1506
|
-
payload: { oldEmail: oldEmail.value, newEmail: newEmail.value },
|
|
1507
|
-
}),
|
|
1508
|
-
);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
```
|
|
1512
|
-
|
|
1513
|
-
### 6. **Publish Events After Persistence**
|
|
1514
|
-
|
|
1515
|
-
Always publish events after persisting the aggregate:
|
|
1516
|
-
|
|
1517
|
-
```typescript
|
|
1518
|
-
async function handle(command: CreateUserCommand): Promise<void> {
|
|
1519
|
-
// Create aggregate
|
|
1520
|
-
const user = User.create(command.email);
|
|
1521
|
-
|
|
1522
|
-
// Persist first
|
|
1523
|
-
await userRepository.save(user);
|
|
1524
|
-
|
|
1525
|
-
// Then publish
|
|
1526
|
-
const events = user.pullDomainEvents();
|
|
1527
|
-
await eventPublisher.publishAll(events);
|
|
1528
|
-
}
|
|
1529
|
-
```
|
|
1530
|
-
|
|
1531
|
-
### 7. **Immutability by Convention**
|
|
1532
|
-
|
|
1533
|
-
Even though TypeScript doesn't enforce it, treat all domain objects as
|
|
1534
|
-
immutable:
|
|
1535
|
-
|
|
1536
|
-
```typescript
|
|
1537
|
-
// ✅ GOOD: Replace entire aggregate when state changes
|
|
1538
|
-
class User extends AggregateRoot {
|
|
1539
|
-
changeName(newName: string): void {
|
|
1540
|
-
// Don't mutate: this.props.name = newName;
|
|
1541
|
-
|
|
1542
|
-
// Instead, create new object:
|
|
1543
|
-
this.props = { ...this.props, name: newName };
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
```
|
|
1547
|
-
|
|
1548
|
-
## Error Handling
|
|
1549
|
-
|
|
1550
|
-
Handle different error scenarios appropriately:
|
|
1551
|
-
|
|
1552
|
-
```typescript
|
|
1553
|
-
import {
|
|
1554
|
-
DomainError,
|
|
1555
|
-
EntityValidationError,
|
|
1556
|
-
InvalidValueObjectError,
|
|
1557
|
-
ApplicationError,
|
|
1558
|
-
Result,
|
|
1559
|
-
} from '@rineex/ddd';
|
|
1560
|
-
|
|
1561
|
-
// Using exceptions (traditional approach)
|
|
1562
|
-
try {
|
|
1563
|
-
const email = Email.create('invalid-email');
|
|
1564
|
-
} catch (error) {
|
|
1565
|
-
if (error instanceof InvalidValueObjectError) {
|
|
1566
|
-
// Handle value object validation errors
|
|
1567
|
-
console.error('Invalid email format:', error.message);
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
try {
|
|
1572
|
-
const user = new User({
|
|
1573
|
-
id: AggregateId.generate(),
|
|
1574
|
-
createdAt: new Date(),
|
|
1575
|
-
props: { email: Email.create('user@example.com') },
|
|
1576
|
-
});
|
|
1577
|
-
user.validate();
|
|
1578
|
-
} catch (error) {
|
|
1579
|
-
if (error instanceof EntityValidationError) {
|
|
1580
|
-
// Handle entity validation errors
|
|
1581
|
-
console.error('User invariant violated:', error.message);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
try {
|
|
1586
|
-
await userService.execute(input);
|
|
1587
|
-
} catch (error) {
|
|
1588
|
-
if (error instanceof ApplicationError) {
|
|
1589
|
-
// Handle application-level errors
|
|
1590
|
-
console.error('Service failed:', error.message);
|
|
1591
|
-
console.error('HTTP Status:', error.status);
|
|
1592
|
-
console.error('Error Code:', error.code);
|
|
1593
|
-
} else if (error instanceof DomainError) {
|
|
1594
|
-
// Catch-all for domain errors
|
|
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);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
```
|
|
1654
|
-
|
|
1655
|
-
## TypeScript Support
|
|
1656
|
-
|
|
1657
|
-
This library is built with TypeScript 5.9+ and provides comprehensive type
|
|
1658
|
-
safety:
|
|
1659
|
-
|
|
1660
|
-
```typescript
|
|
1661
|
-
// Full type inference
|
|
1662
|
-
const user = User.create('user@example.com');
|
|
1663
|
-
const id: AggregateId = user.id; // Correctly typed
|
|
1664
|
-
|
|
1665
|
-
// Type-safe event handling
|
|
1666
|
-
const events = user.pullDomainEvents();
|
|
1667
|
-
events.forEach(event => {
|
|
1668
|
-
if (event instanceof UserCreatedEvent) {
|
|
1669
|
-
// Type guard works correctly
|
|
1670
|
-
const payload = event.payload; // Correctly inferred type
|
|
1671
|
-
}
|
|
1672
|
-
});
|
|
1673
|
-
|
|
1674
|
-
// Proper generic constraints
|
|
1675
|
-
class MyAggregate extends AggregateRoot<MyProps> {
|
|
1676
|
-
// Full type safety with MyProps
|
|
1677
|
-
}
|
|
1678
|
-
```
|
|
1679
|
-
|
|
1680
|
-
### Recommended TypeScript Configuration
|
|
1681
|
-
|
|
1682
|
-
```json
|
|
1683
|
-
{
|
|
1684
|
-
"compilerOptions": {
|
|
1685
|
-
"target": "ES2020",
|
|
1686
|
-
"module": "ESNext",
|
|
1687
|
-
"lib": ["ES2020"],
|
|
1688
|
-
"strict": true,
|
|
1689
|
-
"skipLibCheck": true,
|
|
1690
|
-
"forceConsistentCasingInFileNames": true,
|
|
1691
|
-
"resolveJsonModule": true,
|
|
1692
|
-
"esModuleInterop": true,
|
|
1693
|
-
"declaration": true,
|
|
1694
|
-
"declarationMap": true,
|
|
1695
|
-
"sourceMap": true
|
|
1696
|
-
}
|
|
1697
|
-
}
|
|
1698
|
-
```
|
|
1699
|
-
|
|
1700
|
-
## Contributing
|
|
1701
|
-
|
|
1702
|
-
Contributions are welcome! Please follow these guidelines:
|
|
1703
|
-
|
|
1704
|
-
1. **Fork** the repository
|
|
1705
|
-
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
|
1706
|
-
3. **Write** tests for new functionality
|
|
1707
|
-
4. **Ensure** all tests pass (`pnpm test`)
|
|
1708
|
-
5. **Follow** the code style (`pnpm lint`)
|
|
1709
|
-
6. **Commit** with clear messages
|
|
1710
|
-
7. **Push** to the branch and create a Pull Request
|
|
1711
|
-
|
|
1712
|
-
### Development Setup
|
|
1713
|
-
|
|
1714
|
-
```bash
|
|
1715
|
-
# Install dependencies
|
|
1716
|
-
pnpm install
|
|
1717
|
-
|
|
1718
|
-
# Run tests
|
|
1719
|
-
pnpm test
|
|
1720
|
-
|
|
1721
|
-
# Run linter
|
|
1722
|
-
pnpm lint
|
|
1723
|
-
|
|
1724
|
-
# Check types
|
|
1725
|
-
pnpm check-types
|
|
1726
|
-
|
|
1727
|
-
# Build the package
|
|
1728
|
-
pnpm build
|
|
1729
|
-
```
|
|
1730
|
-
|
|
1731
|
-
### Code Style
|
|
1732
|
-
|
|
1733
|
-
- Follow the existing code style
|
|
1734
|
-
- Use TypeScript strict mode
|
|
1735
|
-
- Write descriptive variable and function names
|
|
1736
|
-
- Add JSDoc comments for public APIs
|
|
1737
|
-
- Keep functions small and focused
|
|
746
|
+
---
|
|
1738
747
|
|
|
1739
748
|
## License
|
|
1740
749
|
|
|
1741
|
-
|
|
1742
|
-
[LICENSE](LICENSE) file for details.
|
|
1743
|
-
|
|
1744
|
-
## Related Resources
|
|
1745
|
-
|
|
1746
|
-
- [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.domainlanguage.com/ddd/)
|
|
1747
|
-
by Eric Evans
|
|
1748
|
-
- [Implementing Domain-Driven Design](https://vaughnvernon.com/books/) by Vaughn
|
|
1749
|
-
Vernon
|
|
1750
|
-
- [Architecture Patterns with Python](https://www.cosmicpython.com/) by Harry J.
|
|
1751
|
-
W. Percival and Bob Gregory
|
|
1752
|
-
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
|
1753
|
-
|
|
1754
|
-
## Support
|
|
1755
|
-
|
|
1756
|
-
For issues, questions, or suggestions, please open an issue on
|
|
1757
|
-
[GitHub](https://github.com/rineex/core/issues).
|
|
1758
|
-
|
|
1759
|
-
---
|
|
1760
|
-
|
|
1761
|
-
**Made with ❤️ by the Rineex Team**
|
|
750
|
+
Apache-2.0 – see [LICENSE](../../LICENSE).
|