From 5783ecc54a5bc99ae37c60a7f08809718f4f69e8 Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sat, 8 Aug 2026 19:44:15 +0330 Subject: [PATCH 1/5] docs(ddd): document hardened domain primitives --- .../specs/2026-08-08-ddd-primitives-design.md | 55 +++++++++++ packages/ddd/README.md | 4 + packages/ddd/docs/DDD-GUIDE.md | 92 +++++++++++++++++++ packages/ddd/docs/MIGRATION.md | 40 ++++++++ 4 files changed, 191 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-ddd-primitives-design.md create mode 100644 packages/ddd/docs/DDD-GUIDE.md create mode 100644 packages/ddd/docs/MIGRATION.md diff --git a/docs/superpowers/specs/2026-08-08-ddd-primitives-design.md b/docs/superpowers/specs/2026-08-08-ddd-primitives-design.md new file mode 100644 index 0000000..21cdb2e --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-ddd-primitives-design.md @@ -0,0 +1,55 @@ +# DDD Primitive Hardening Design + +## Goal + +Strengthen `packages/ddd` around entity invariants, aggregate boundaries, +domain-event correctness, and explicit mapper-based rehydration. This is an +intentional breaking change. + +## Entity contract + +`Entity` stores identity and state but does not invoke overridable validation +from its base constructor. Concrete types validate after `super(...)` through +their creation/rehydration factory. Validation accepts candidate props so +`mutate` can validate before committing the replacement. A failed mutation +leaves the previous valid state intact. + +Entity metadata and state must not expose mutable internal `Date` instances. +Serialization remains explicit: `toObject()` is the domain-specific object +projection; `toJSON()` is the JSON-safe primitive projection. + +## Aggregate-root contract + +An aggregate root is the only public entry point for changes to its consistency +boundary. Child entities are not exposed for independent persistence. Event +recording is protected and verifies that the event aggregate ID equals the root +ID. Events are returned in insertion order and pulled atomically after +successful application work. + +## Domain-event contract + +Events are immutable facts. Event ID, stable event name, positive schema +version, aggregate ID, occurrence timestamp, and JSON-safe payload are required +at construction. The base class does not depend on Node-specific random-ID +generation. Event payload serialization returns a detached primitive structure. + +## Rehydration + +Mappers translate persistence data into value objects and invoke an explicit +rehydration factory. Rehydration restores state without emitting creation +events. Creation factories may emit creation events only after the new aggregate +is valid. + +## Documentation + +Update the package README with a quick start and links to +`packages/ddd/docs/DDD-GUIDE.md` and `packages/ddd/docs/MIGRATION.md`. The guide +explains where each primitive belongs, when to use it, and when not to use it. +Migration notes list the removed/changed APIs and before/after examples. + +## Testing + +Add tests for constructor validation safety, atomic failed mutations, immutable +metadata and payloads, aggregate/event identity matching, event metadata +validation, event ordering, and mapper rehydration without creation events. Run +package type tests, unit tests, lint, and build after migration. diff --git a/packages/ddd/README.md b/packages/ddd/README.md index f10a7e4..8f51b0f 100644 --- a/packages/ddd/README.md +++ b/packages/ddd/README.md @@ -36,6 +36,10 @@ Design patterns. Used by `@rineex/auth-core` and other Rineex packages. Errors (extensible namespaces), Result type, Application Service port, Clock port, HTTP status constants. +For placement rules, aggregate design, event recording, mapper-based +rehydration, and testing guidance, see [the DDD guide](./docs/DDD-GUIDE.md). For +the breaking API migration, see [MIGRATION.md](./docs/MIGRATION.md). + --- ## Installation diff --git a/packages/ddd/docs/DDD-GUIDE.md b/packages/ddd/docs/DDD-GUIDE.md new file mode 100644 index 0000000..06d1fd8 --- /dev/null +++ b/packages/ddd/docs/DDD-GUIDE.md @@ -0,0 +1,92 @@ +# DDD Guide for `@rineex/ddd` + +This package supplies tactical DDD building blocks. It does not decide your +bounded contexts, aggregate boundaries, or business language. Those decisions +belong to the application domain. + +## Where code belongs + +| Concern | Location | +| ----------------------------------------------------------------------- | ----------------- | +| Business rules, entities, value objects, aggregate roots, domain events | `domain/` | +| Use-case orchestration and transaction ports | `application/` | +| Database, ORM, HTTP, broker, and mapper implementations | `infrastructure/` | + +Dependencies point inward: infrastructure depends on application and domain; +domain depends on neither. + +## Entity + +Use `Entity` when an object has identity and behavior but is not the aggregate's +transaction boundary. Keep state protected and expose methods that express +business intent. + +```ts +class Account extends Entity { + private constructor(params: EntityProps) { + super(params); + this.validate(); + } + + static create(props: AccountProps): Account { + return new Account({ id: AccountId.generate(), props }); + } + + rename(name: string): void { + this.mutate(current => ({ ...current, name })); + } +} +``` + +Do not use an entity as a DTO, persistence row, or place for infrastructure +calls. + +## Aggregate root + +Use `AggregateRoot` when all changes to a consistency boundary must pass through +one object. Application services load and save the root; they do not update +child entities directly. + +An aggregate command should validate its business preconditions, mutate state +atomically, and then record a past-tense event. Cross-aggregate coordination +belongs in the application layer and is normally eventually consistent. + +## Value objects + +Use value objects for concepts defined by their values: email addresses, IDs, +URLs, money, status, and similar concepts. Prefer them over primitive strings or +numbers when validation or domain meaning matters. + +## Domain events + +Events describe facts that already happened. Use stable past-tense names such as +`AccountOpened`. Include a versioned JSON-safe payload. The aggregate records +the event; an application handler or outbox adapter publishes it. + +Do not put database writes, HTTP calls, event-bus calls, or command behavior in +an event class. + +## Mappers and rehydration + +Mappers are infrastructure adapters. They convert persistence primitives into +value objects and call the aggregate's explicit rehydration path. Rehydration +must restore state without emitting creation events. + +```ts +toDomain(row: AccountRow): Account { + return Account.rehydrate({ + id: AccountId.fromString(row.id), + props: { name: row.name, status: AccountStatus.fromString(row.status) }, + createdAt: row.createdAt, + }); +} +``` + +## Testing checklist + +- valid creation and invalid construction; +- every public command's successful transition; +- failed transitions leave state unchanged; +- aggregate event type, ID, payload, and order; +- rehydration emits no creation event; +- serialization contains primitives and no infrastructure objects. diff --git a/packages/ddd/docs/MIGRATION.md b/packages/ddd/docs/MIGRATION.md new file mode 100644 index 0000000..e4f674d --- /dev/null +++ b/packages/ddd/docs/MIGRATION.md @@ -0,0 +1,40 @@ +# DDD API Migration + +The DDD primitives are being hardened with an intentional breaking change. + +## Planned changes + +- Base constructors no longer call overridable validation methods. +- Concrete entities and aggregates validate explicitly in creation and + rehydration factories. +- `mutate` validates candidate state before committing it. +- Aggregate event recording becomes protected and checks aggregate identity. +- Domain events require explicit IDs and timestamps and no longer generate IDs + through `node:crypto`. +- Event metadata and payload serialization become stricter and immutable. + +## Migration shape + +Before: + +```ts +constructor(params: EntityProps) { + super(params); +} +``` + +After: + +```ts +private constructor(params: EntityProps) { + super(params); + this.validate(); +} + +static rehydrate(params: EntityProps): Account { + return new Account(params); +} +``` + +Creation factories may record creation events after construction. Rehydration +factories must not. From ea9e84d76d5979ec4688d622d491c39d26e7d89b Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sat, 8 Aug 2026 19:49:08 +0330 Subject: [PATCH 2/5] docs(ddd): add primitive hardening plan --- plan/refactor-ddd-primitives-1.md | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 plan/refactor-ddd-primitives-1.md diff --git a/plan/refactor-ddd-primitives-1.md b/plan/refactor-ddd-primitives-1.md new file mode 100644 index 0000000..5536673 --- /dev/null +++ b/plan/refactor-ddd-primitives-1.md @@ -0,0 +1,166 @@ +--- +goal: Harden DDD entities, aggregate roots, and domain events +version: 1.0 +date_created: 2026-08-08 +last_updated: 2026-08-08 +owner: Rineex Team +status: Planned +tags: [refactor, ddd, architecture, breaking-change] +--- + +# Introduction + +![Status: Planned](https://img.shields.io/badge/status-Planned-blue) + +Refactor `@rineex/ddd` to enforce safe invariant validation, atomic entity +mutation, aggregate-owned event recording, explicit event metadata, and +mapper-based rehydration. Migrate all in-repository consumers to the new +contract. + +## 1. Requirements & Constraints + +- **REQ-001**: `Entity` must not invoke overridable subclass validation from its + base constructor. +- **REQ-002**: Entity mutation must validate candidate state before committing + it; failed mutation must preserve the previous state. +- **REQ-003**: Entity metadata and exposed state must not permit mutation + through `Date` methods or mutable references. +- **REQ-004**: Concrete entity and aggregate creation/rehydration paths must + validate explicitly. +- **REQ-005**: Aggregate event recording must be protected and reject events + whose aggregate ID differs from the root ID. +- **REQ-006**: Domain events must require explicit ID, stable name, positive + schema version, valid occurrence timestamp, and immutable JSON-safe payload. +- **REQ-007**: The domain event base class must not depend on `node:crypto`. +- **REQ-008**: Rehydration through mappers must not emit creation events. +- **REQ-009**: Update package documentation, migration guidance, tests, and all + in-repository consumers. +- **CON-001**: The change is intentionally breaking; no deprecated compatibility + aliases are required. +- **CON-002**: Preserve dependency direction: infrastructure and application + depend on domain, never the reverse. +- **CON-003**: Keep public API names and examples internally consistent after + migration. +- **PAT-001**: Domain methods express business intent; controllers, + repositories, and event buses remain outside aggregates. + +## 2. Implementation Steps + +### Implementation Phase 1 + +- GOAL-001: Define safe entity state and validation contracts. + +| Task | Description | Completed | Date | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-001 | Update `packages/ddd/src/domain/entities/entity.ts`: remove constructor-time virtual validation; introduce candidate-props validation; make `mutate` validate before assignment; protect metadata/state snapshots from mutable `Date` references; preserve identity equality and JSON typing. | | | +| TASK-002 | Update `packages/ddd/src/domain/types/deep-immutable.type.ts` and related utilities/types so the compile-time immutability contract matches runtime behavior for dates, arrays, maps, sets, and nested values. | | | +| TASK-003 | Update entity and type tests under `packages/ddd/src/domain/entities/__tests__/` and `packages/ddd/src/domain/types/__tests__/` for explicit construction validation, rollback after failed mutation, and date immutability. | | | + +### Implementation Phase 2 + +- GOAL-002: Harden aggregate boundaries and event collection. + +| Task | Description | Completed | Date | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-004 | Update `packages/ddd/src/domain/aggregates/aggregate-root.ts`: make event recording protected, verify aggregate identity, preserve ordered pull semantics, and document the application publication boundary. | | | +| TASK-005 | Update `packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts` for protected recording through a test aggregate, mismatched aggregate IDs, event ordering, and pull behavior. | | | +| TASK-006 | Update all aggregate implementations in `packages/authentication/**/src/domain/**` to validate after construction and replace public `addEvent` calls with the new protected recording API. | | | + +### Implementation Phase 3 + +- GOAL-003: Make domain events explicit, portable, and immutable. + +| Task | Description | Completed | Date | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-007 | Update `packages/ddd/src/domain/events/domain.event.ts`: remove `node:crypto`, require event ID and occurrence metadata, validate event metadata, and return detached JSON-safe primitives. | | | +| TASK-008 | Migrate event classes and factories under `packages/authentication/**/src/domain/events/` and their tests to provide explicit event IDs/timestamps and stable versioned payloads. | | | +| TASK-009 | Expand `packages/ddd/src/domain/events/__tests__/domain.event.spec.ts` for invalid metadata, nested payload immutability, detached serialization, and runtime portability assumptions. | | | + +### Implementation Phase 4 + +- GOAL-004: Complete mapper/documentation migration and verification. + +| Task | Description | Completed | Date | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---- | +| TASK-010 | Update `packages/ddd/src/infrastructure/mapper/base.mapper.ts`, mapper types, and consumer mappers to expose explicit rehydration intent without emitting creation events. | | | +| TASK-011 | Reconcile `packages/ddd/README.md`, `packages/ddd/docs/DDD-GUIDE.md`, and `packages/ddd/docs/MIGRATION.md` with the final API and add minimal creation, command, event, and rehydration examples. | | | +| TASK-012 | Run `pnpm --filter @rineex/ddd check-types`, `test`, `lint`, and `build`; run affected authentication type/tests; fix all failures caused by the migration. | | | + +## 3. Alternatives + +- **ALT-001**: Preserve constructor-time `validate()` for source compatibility. + Rejected because it invokes overridable behavior before subclass + initialization. +- **ALT-002**: Keep public `addEvent()` and trust callers. Rejected because it + allows bypassing aggregate ownership and event invariants. +- **ALT-003**: Keep `node:crypto` event-ID generation. Rejected because it + couples a domain abstraction to one runtime and hides event identity creation. +- **ALT-004**: Use event sourcing or CQRS as part of this refactor. Rejected as + unnecessary complexity; this change only hardens tactical DDD primitives. + +## 4. Dependencies + +- **DEP-001**: TypeScript 5.9 and existing package type definitions. +- **DEP-002**: Existing value-object ID implementations satisfying `EntityId`. +- **DEP-003**: Existing authentication event factories and aggregate + implementations. +- **DEP-004**: Existing Vitest, tsd, ESLint, and tsup verification commands. + +## 5. Files + +- **FILE-001**: `packages/ddd/src/domain/entities/entity.ts` +- **FILE-002**: `packages/ddd/src/domain/aggregates/aggregate-root.ts` +- **FILE-003**: `packages/ddd/src/domain/events/domain.event.ts` +- **FILE-004**: `packages/ddd/src/domain/types/deep-immutable.type.ts` and + related type tests +- **FILE-005**: DDD unit/type tests under + `packages/ddd/src/domain/**/__tests__/` +- **FILE-006**: Authentication aggregate/event consumers under + `packages/authentication/**/src/domain/**` +- **FILE-007**: Mapper abstractions under + `packages/ddd/src/infrastructure/mapper/` and + `packages/ddd/src/domain/types/mapper.type.ts` +- **FILE-008**: `packages/ddd/README.md`, `packages/ddd/docs/DDD-GUIDE.md`, + `packages/ddd/docs/MIGRATION.md` + +## 6. Testing + +- **TEST-001**: Constructing an invalid concrete entity throws only after the + concrete constructor explicitly validates. +- **TEST-002**: A failed mutation leaves all prior entity props unchanged. +- **TEST-003**: Entity dates cannot be changed through `setTime` or returned + mutable references. +- **TEST-004**: An aggregate cannot record an event for another aggregate. +- **TEST-005**: Event collection preserves order and pull clears only returned + events. +- **TEST-006**: Invalid event IDs, names, versions, timestamps, and payloads are + rejected. +- **TEST-007**: Nested event payloads cannot be mutated after construction and + serialized output is detached. +- **TEST-008**: Creation emits creation events only when intended; mapper + rehydration emits none. +- **TEST-009**: Package type tests, unit tests, lint, and build pass; affected + authentication tests pass. + +## 7. Risks & Assumptions + +- **RISK-001**: Existing authentication aggregates may rely on the old + base-constructor validation or public event API; all call sites must migrate + atomically. +- **RISK-002**: Replacing mutable `Date` exposure may require small changes to + domain code that currently mutates dates in place. +- **RISK-003**: Requiring explicit event IDs/timestamps may require a shared + application-level ID/clock provider; this must remain outside the domain + model. +- **ASSUMPTION-001**: Authentication package changes are in scope because they + directly consume the breaking `@rineex/ddd` API. +- **ASSUMPTION-002**: Mappers are the approved persistence rehydration boundary. +- **ASSUMPTION-003**: No event store or event-sourcing behavior is required by + this task. + +## 8. Related Specifications / Further Reading + +- [DDD primitive hardening design](../docs/superpowers/specs/2026-08-08-ddd-primitives-design.md) +- [DDD usage guide](../packages/ddd/docs/DDD-GUIDE.md) +- [DDD API migration guide](../packages/ddd/docs/MIGRATION.md) +- [Clean DDD and Hexagonal skill](../.agents/skills/clean-ddd-hexagonal/SKILL.md) From 7d10a14e2d5d20924be882f913777c1d9c3d28b3 Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sat, 8 Aug 2026 20:01:24 +0330 Subject: [PATCH 3/5] refactor(ddd)!: harden domain primitives --- .../start-mfa-session.application-service.ts | 10 ++- .../authentication-attempt.aggregate.ts | 13 +-- .../identity/aggregates/identity.aggregate.ts | 9 +- .../identity/entities/identity.entity.ts | 5 +- .../events/authentication-failed.event.ts | 5 +- .../events/authentication-started.event.ts | 5 +- .../events/authentication-succeeded.event.ts | 5 +- .../identity/events/identity-created.event.ts | 4 +- .../events/identity-disabled.event.ts | 4 +- .../mfa/aggregates/mfa-session.aggregate.ts | 11 +-- .../mfa/entities/mfa-challenge.entity.ts | 9 +- .../oauth-authorization.aggregate.ts | 5 +- .../entities/oauth-authorization.entity.ts | 22 +++-- .../domain/session/entities/session.entity.ts | 5 +- .../token/aggregates/token.aggregate.ts | 5 +- .../passwordless-challenge.aggregate.ts | 19 +++-- .../passwordless-challenge-issued.event.ts | 19 +++-- .../passwordless-challenge-verified.event.ts | 19 +++-- packages/ddd/README.md | 82 +++++++++++-------- .../__tests__/aggregate-root.spec.ts | 49 ++++++++--- .../src/domain/aggregates/aggregate-root.ts | 12 ++- .../domain/entities/__tests__/entity.spec.ts | 22 ++++- packages/ddd/src/domain/entities/entity.ts | 30 +++++-- .../events/__tests__/domain.event.spec.ts | 33 +++++++- .../ddd/src/domain/events/domain.event.ts | 70 ++++++++++++++-- .../types/__tests__/deep-primitive.test-d.ts | 2 +- plan/refactor-ddd-primitives-1.md | 7 +- 27 files changed, 334 insertions(+), 147 deletions(-) diff --git a/packages/authentication/core/src/application/mfa/start-mfa-session.application-service.ts b/packages/authentication/core/src/application/mfa/start-mfa-session.application-service.ts index e14a182..41ec488 100644 --- a/packages/authentication/core/src/application/mfa/start-mfa-session.application-service.ts +++ b/packages/authentication/core/src/application/mfa/start-mfa-session.application-service.ts @@ -52,10 +52,12 @@ export class StartMfaSessionApplicationService implements ApplicationServicePort const session = new MFASession({ id: this.idGenerator.generate(), - maxAttempts: args.maxAttempts, - identityId: args.identityId, - attemptsUsed: 0, - challenges: [], + props: { + maxAttempts: args.maxAttempts, + identityId: args.identityId, + attemptsUsed: 0, + challenges: [], + }, }); await this.repository.save(session); diff --git a/packages/authentication/core/src/domain/identity/aggregates/authentication-attempt.aggregate.ts b/packages/authentication/core/src/domain/identity/aggregates/authentication-attempt.aggregate.ts index d43c272..f136ec3 100644 --- a/packages/authentication/core/src/domain/identity/aggregates/authentication-attempt.aggregate.ts +++ b/packages/authentication/core/src/domain/identity/aggregates/authentication-attempt.aggregate.ts @@ -56,6 +56,7 @@ export class AuthenticationAttempt extends AggregateRoot< private constructor(props: CreateAuthAttemptProps) { super(props); + this.validate(); } public static start(props: StartAuthenticationProps): AuthenticationAttempt { @@ -71,7 +72,9 @@ export class AuthenticationAttempt extends AggregateRoot< id: props.id, }); - attempt.addEvent(new AuthenticationStartedEvent(attempt.id, props.method)); + attempt.recordEvent( + new AuthenticationStartedEvent(attempt.id, props.method), + ); return attempt; } @@ -95,7 +98,7 @@ export class AuthenticationAttempt extends AggregateRoot< status: AuthStatus.create('failed'), })); - this.addEvent(new AuthenticationFailedEvent(this.id, reason)); + this.recordEvent(new AuthenticationFailedEvent(this.id, reason)); } public registerAttempt(factor: AuthFactor): void { @@ -142,7 +145,7 @@ export class AuthenticationAttempt extends AggregateRoot< status: AuthStatus.create('succeed'), })); - this.addEvent(new AuthenticationSucceededEvent(this.id)); + this.recordEvent(new AuthenticationSucceededEvent(this.id)); } toObject() { @@ -154,8 +157,8 @@ export class AuthenticationAttempt extends AggregateRoot< }; } - validate(): void { - if (this.props.maxAttempts <= 0) { + protected validateProps(props: AuthenticationAttemptProps): void { + if (props.maxAttempts <= 0) { throw new Error('maxAttempts must be > 0'); } } diff --git a/packages/authentication/core/src/domain/identity/aggregates/identity.aggregate.ts b/packages/authentication/core/src/domain/identity/aggregates/identity.aggregate.ts index e325b5c..7ae7e52 100644 --- a/packages/authentication/core/src/domain/identity/aggregates/identity.aggregate.ts +++ b/packages/authentication/core/src/domain/identity/aggregates/identity.aggregate.ts @@ -13,6 +13,7 @@ type IdentityProps = { export class Identity extends AggregateRoot { private constructor(params: EntityProps) { super(params); + this.validate(); } public static create(): Identity { @@ -21,7 +22,7 @@ export class Identity extends AggregateRoot { id: IdentityId.generate(), }); - identity.addEvent(IdentityCreatedEvent.create(identity.id)); + identity.recordEvent(IdentityCreatedEvent.create(identity.id)); return identity; } @@ -56,7 +57,7 @@ export class Identity extends AggregateRoot { status: IdentityStatus.Disabled(), })); - this.addEvent(IdentityDisabledEvent.create(this.id)); + this.recordEvent(IdentityDisabledEvent.create(this.id)); } toObject() { @@ -66,8 +67,8 @@ export class Identity extends AggregateRoot { }; } - validate(): void { - if (!this.props.status) { + protected validateProps(props: IdentityProps): void { + if (!props.status) { throw IdentityDisabledError.create('Identity status is required', { identityId: this.id.toString(), }); diff --git a/packages/authentication/core/src/domain/identity/entities/identity.entity.ts b/packages/authentication/core/src/domain/identity/entities/identity.entity.ts index 0943840..bd4b16b 100644 --- a/packages/authentication/core/src/domain/identity/entities/identity.entity.ts +++ b/packages/authentication/core/src/domain/identity/entities/identity.entity.ts @@ -34,6 +34,7 @@ export class Identity extends Entity { */ private constructor(params: EntityProps) { super(params); + this.validate(); } /** @@ -78,11 +79,11 @@ export class Identity extends Entity { /** * Domain invariant validation. */ - public validate(): void { + protected validateProps(props: IdentityProps): void { if (this.id == null) { throw new Error('Identity must have a valid IdentityId'); } - if (typeof this.props.isActive !== 'boolean') { + if (typeof props.isActive !== 'boolean') { throw new Error('Identity.isActive must be a boolean'); } } diff --git a/packages/authentication/core/src/domain/identity/events/authentication-failed.event.ts b/packages/authentication/core/src/domain/identity/events/authentication-failed.event.ts index 688db6f..f8d520e 100644 --- a/packages/authentication/core/src/domain/identity/events/authentication-failed.event.ts +++ b/packages/authentication/core/src/domain/identity/events/authentication-failed.event.ts @@ -6,10 +6,6 @@ import { AuthAttemptId } from '../value-objects/auth-attempt-id.vo'; * Emitted when an authentication attempt failed. */ export class AuthenticationFailedEvent extends DomainEvent { - public get eventName(): string { - return 'authentication.auth_attempt.failed'; - } - constructor( public readonly attemptId: AuthAttemptId, reason?: string, @@ -19,6 +15,7 @@ export class AuthenticationFailedEvent extends DomainEvent { attemptId: attemptId.toString(), reason: reason ?? 'UNKNOWN', }, + eventName: 'authentication.auth_attempt.failed', id: crypto.randomUUID(), aggregateId: attemptId, occurredAt: Date.now(), diff --git a/packages/authentication/core/src/domain/identity/events/authentication-started.event.ts b/packages/authentication/core/src/domain/identity/events/authentication-started.event.ts index 4ef46be..b9fa642 100644 --- a/packages/authentication/core/src/domain/identity/events/authentication-started.event.ts +++ b/packages/authentication/core/src/domain/identity/events/authentication-started.event.ts @@ -7,10 +7,6 @@ import { AuthMethod } from '../value-objects/auth-method.vo'; * Emitted when an authentication attempt begins. */ export class AuthenticationStartedEvent extends DomainEvent { - public get eventName(): string { - return 'authentication.authentication_started'; - } - constructor( public readonly attemptId: AuthAttemptId, public readonly method: AuthMethod, @@ -20,6 +16,7 @@ export class AuthenticationStartedEvent extends DomainEvent { attemptId: attemptId.toString(), method: method.toString(), }, + eventName: 'authentication.authentication_started', id: crypto.randomUUID(), aggregateId: attemptId, occurredAt: Date.now(), diff --git a/packages/authentication/core/src/domain/identity/events/authentication-succeeded.event.ts b/packages/authentication/core/src/domain/identity/events/authentication-succeeded.event.ts index 3f22cfd..144acbb 100644 --- a/packages/authentication/core/src/domain/identity/events/authentication-succeeded.event.ts +++ b/packages/authentication/core/src/domain/identity/events/authentication-succeeded.event.ts @@ -6,15 +6,12 @@ import { AuthAttemptId } from '../value-objects/auth-attempt-id.vo'; * Emitted when an authentication attempt succeeds. */ export class AuthenticationSucceededEvent extends DomainEvent { - public get eventName(): string { - return 'authentication.auth_attempt.succeeded'; - } - constructor(public readonly attemptId: AuthAttemptId) { super({ payload: { attemptId: attemptId.toString(), }, + eventName: 'authentication.auth_attempt.succeeded', id: crypto.randomUUID(), aggregateId: attemptId, occurredAt: Date.now(), diff --git a/packages/authentication/core/src/domain/identity/events/identity-created.event.ts b/packages/authentication/core/src/domain/identity/events/identity-created.event.ts index e33f411..4a7a85c 100644 --- a/packages/authentication/core/src/domain/identity/events/identity-created.event.ts +++ b/packages/authentication/core/src/domain/identity/events/identity-created.event.ts @@ -10,10 +10,10 @@ export class IdentityCreatedEvent extends DomainEvent< IdentityId, IdentityCreatedPayload > { - public readonly eventName = 'auth.identity.created'; - public static create(identityId: IdentityId): IdentityCreatedEvent { return new IdentityCreatedEvent({ + id: crypto.randomUUID(), + eventName: 'auth.identity.created', payload: { identityId: identityId.toString() }, aggregateId: identityId, occurredAt: Date.now(), diff --git a/packages/authentication/core/src/domain/identity/events/identity-disabled.event.ts b/packages/authentication/core/src/domain/identity/events/identity-disabled.event.ts index 4009206..eee5b41 100644 --- a/packages/authentication/core/src/domain/identity/events/identity-disabled.event.ts +++ b/packages/authentication/core/src/domain/identity/events/identity-disabled.event.ts @@ -7,10 +7,10 @@ export type Payload = { }; export class IdentityDisabledEvent extends DomainEvent { - public readonly eventName = 'auth.identity.disabled'; - public static create(identityId: IdentityId): IdentityDisabledEvent { return new IdentityDisabledEvent({ + id: crypto.randomUUID(), + eventName: 'auth.identity.disabled', payload: { identityId: identityId.toString() }, aggregateId: identityId, occurredAt: Date.now(), diff --git a/packages/authentication/core/src/domain/mfa/aggregates/mfa-session.aggregate.ts b/packages/authentication/core/src/domain/mfa/aggregates/mfa-session.aggregate.ts index 4383947..4348091 100644 --- a/packages/authentication/core/src/domain/mfa/aggregates/mfa-session.aggregate.ts +++ b/packages/authentication/core/src/domain/mfa/aggregates/mfa-session.aggregate.ts @@ -23,6 +23,7 @@ export class MFASession extends AggregateRoot { constructor(props: EntityProps) { super({ ...props }); + this.validate(); } issueChallenge(challenge: MFAChallenge, now: Date): void { @@ -60,15 +61,15 @@ export class MFASession extends AggregateRoot { }; } - validate(): void { - if (this.props.attemptsUsed > this.props.maxAttempts) { + protected validateProps(props: MfaSessionProps): void { + if (props.attemptsUsed > props.maxAttempts) { throw MfaAttemptsExceededError.create( - this.props.attemptsUsed, - this.props.maxAttempts, + props.attemptsUsed, + props.maxAttempts, ); } - if (this.props.verifiedAt && this.props.challenges.length > 0) { + if (props.verifiedAt && props.challenges.length > 0) { throw new Error('Verified MFA session cannot have active challenges'); } } diff --git a/packages/authentication/core/src/domain/mfa/entities/mfa-challenge.entity.ts b/packages/authentication/core/src/domain/mfa/entities/mfa-challenge.entity.ts index 86eadea..a9091a2 100644 --- a/packages/authentication/core/src/domain/mfa/entities/mfa-challenge.entity.ts +++ b/packages/authentication/core/src/domain/mfa/entities/mfa-challenge.entity.ts @@ -58,6 +58,11 @@ export interface Props { * ``` */ export class MFAChallenge extends Entity { + private constructor(params: EntityProps) { + super(params); + this.validate(); + } + /** * Gets the challenge type. * @@ -109,8 +114,8 @@ export class MFAChallenge extends Entity { * * @throws {MfaChallengeExpiredError} If expiration time is before or equal to issue time */ - validate(): void { - if (this.props.expiresAt <= this.props.issuedAt) { + protected validateProps(props: Props): void { + if (props.expiresAt <= props.issuedAt) { throw MfaChallengeExpiredError.create(); } } diff --git a/packages/authentication/core/src/domain/oauth/aggregates/oauth-authorization.aggregate.ts b/packages/authentication/core/src/domain/oauth/aggregates/oauth-authorization.aggregate.ts index 6720a0d..a3eebb3 100644 --- a/packages/authentication/core/src/domain/oauth/aggregates/oauth-authorization.aggregate.ts +++ b/packages/authentication/core/src/domain/oauth/aggregates/oauth-authorization.aggregate.ts @@ -35,6 +35,7 @@ export class OauthAuthorization extends AggregateRoot< props: EntityProps, ) { super(props); + this.validate(); } grantConsent(now: Date): void { @@ -89,8 +90,8 @@ export class OauthAuthorization extends AggregateRoot< }; } - validate() { - if (this.props.expiresAt.getTime() <= Date.now()) { + protected validateProps(props: OAuthAuthorizationProps) { + if (props.expiresAt.getTime() <= Date.now()) { throw AuthorizationExpiredError.create(); } } diff --git a/packages/authentication/core/src/domain/oauth/entities/oauth-authorization.entity.ts b/packages/authentication/core/src/domain/oauth/entities/oauth-authorization.entity.ts index d333dec..221ca30 100644 --- a/packages/authentication/core/src/domain/oauth/entities/oauth-authorization.entity.ts +++ b/packages/authentication/core/src/domain/oauth/entities/oauth-authorization.entity.ts @@ -20,6 +20,7 @@ export class OAuthAuthorization extends Entity< props: EntityProps, ) { super({ ...props }); + this.validate(); } toObject(): Record { @@ -32,21 +33,24 @@ export class OAuthAuthorization extends Entity< }; } - validate(): void { - if (!this.props.redirectUri.startsWith('https://')) { + protected validateProps(props: OAuthAuthorizationProps): void { + if (!props.redirectUri.startsWith('https://')) { throw InvalidRedirectUriError.create({ - redirectUri: this.props.redirectUri, + redirectUri: props.redirectUri, }); } } protected restore(snapshot: Record): void { - this.props.pkce = snapshot.pkce - ? Pkce.fromJSON(snapshot.pkce as Record) - : undefined; - this.props.redirectUri = snapshot.redirectUri as string; - this.props.provider = snapshot.provider as OAuthProvider; - this.props.scope = snapshot.scope as string[]; + this.mutate(current => ({ + ...current, + pkce: snapshot.pkce + ? Pkce.fromJSON(snapshot.pkce as Record) + : undefined, + redirectUri: snapshot.redirectUri as string, + provider: snapshot.provider as OAuthProvider, + scope: snapshot.scope as string[], + })); } protected snapshot(): Record { diff --git a/packages/authentication/core/src/domain/session/entities/session.entity.ts b/packages/authentication/core/src/domain/session/entities/session.entity.ts index 2fc0196..bf98b90 100644 --- a/packages/authentication/core/src/domain/session/entities/session.entity.ts +++ b/packages/authentication/core/src/domain/session/entities/session.entity.ts @@ -17,6 +17,7 @@ type CreateSessionProps = EntityProps; export class Session extends Entity { protected constructor(props: CreateSessionProps) { super(props); + this.validate(); } public static create(props: CreateSessionProps): Session { @@ -46,8 +47,8 @@ export class Session extends Entity { }; } - validate(): void { - if (this.props.expiresAt <= new Date(0)) { + protected validateProps(props: SessionProps): void { + if (props.expiresAt <= new Date(0)) { throw new Error('Session expiration must be valid'); } } diff --git a/packages/authentication/core/src/domain/token/aggregates/token.aggregate.ts b/packages/authentication/core/src/domain/token/aggregates/token.aggregate.ts index a7a7718..fb0f5cb 100644 --- a/packages/authentication/core/src/domain/token/aggregates/token.aggregate.ts +++ b/packages/authentication/core/src/domain/token/aggregates/token.aggregate.ts @@ -13,6 +13,7 @@ type CreateAggregateTokenProps = EntityProps; export class Token extends AggregateRoot { protected constructor(props: CreateAggregateTokenProps) { super({ ...props }); + this.validate(); } public isActive(now = new Date()): boolean { @@ -37,8 +38,8 @@ export class Token extends AggregateRoot { }; } - validate(): void { - if (this.props.expiresAt.getTime() <= Date.now()) { + protected validateProps(props: TokenProps): void { + if (props.expiresAt.getTime() <= Date.now()) { throw new Error('Token already expired'); } } diff --git a/packages/authentication/methods/passwordless/src/domain/aggregates/passwordless-challenge.aggregate.ts b/packages/authentication/methods/passwordless/src/domain/aggregates/passwordless-challenge.aggregate.ts index 09a9425..9e6c8bd 100644 --- a/packages/authentication/methods/passwordless/src/domain/aggregates/passwordless-challenge.aggregate.ts +++ b/packages/authentication/methods/passwordless/src/domain/aggregates/passwordless-challenge.aggregate.ts @@ -74,6 +74,11 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< PasswordlessChallengeId, PasswordlessChallengeProps > { + private constructor(params: CreatePasswordlessProps) { + super(params); + this.validate(); + } + /** * Creates and issues a new passwordless challenge. * @@ -97,7 +102,7 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< id, }); - challenge.addEvent( + challenge.recordEvent( PasswordlessChallengeIssuedEvent.create({ payload: { expiresAt: challenge.props.expiresAt.toISOString(), @@ -107,6 +112,7 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< occurredAt: props.issuedAt.getTime(), aggregateId: challenge.id, schemaVersion: 1, + id: crypto.randomUUID(), }), ); @@ -176,15 +182,15 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< * @throws {PasswordlessChallengeSecretRequired} If secret is missing * @throws {PasswordlessChallengeInvalidExpiration} If expiration is invalid */ - validate(): void { - if (!this.props.channel) { + protected validateProps(props: PasswordlessChallengeProps): void { + if (!props.channel) { throw PasswordlessChallengeChannelRequired.create(); } - if (!this.props.secret) { + if (!props.secret) { throw PasswordlessChallengeSecretRequired.create(); } - if (this.props.expiresAt <= this.props.issuedAt) { + if (props.expiresAt <= props.issuedAt) { throw PasswordlessChallengeInvalidExpiration.create(); } } @@ -226,7 +232,7 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< status: PasswordlessChallengeStatus.verified(), })); - this.addEvent( + this.recordEvent( PasswordlessChallengeVerifiedEvent.create({ payload: { destination: this.props.destination.value, @@ -236,6 +242,7 @@ export class PasswordlessChallengeAggregate extends AggregateRoot< occurredAt: now.getTime(), aggregateId: this.id, schemaVersion: 1, + id: crypto.randomUUID(), }), ); } diff --git a/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-issued.event.ts b/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-issued.event.ts index eda051d..d3f6cbe 100644 --- a/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-issued.event.ts +++ b/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-issued.event.ts @@ -43,8 +43,6 @@ export class PasswordlessChallengeIssuedEvent extends DomainEvent< * The unique name identifier for this domain event. * Used for event routing, logging, and event store indexing. */ - public readonly eventName = 'auth.passwordless.challenge_created'; - /** * Creates a new PasswordlessChallengeIssuedEvent instance. * @@ -57,11 +55,18 @@ export class PasswordlessChallengeIssuedEvent extends DomainEvent< * @returns {PasswordlessChallengeIssuedEvent} A new instance of PasswordlessChallengeIssuedEvent */ public static create( - props: CreateEventProps< - PasswordlessChallengeIssuedPayload, - PasswordlessChallengeId - >, + props: Omit< + CreateEventProps< + PasswordlessChallengeIssuedPayload, + PasswordlessChallengeId + >, + 'id' | 'eventName' + > & { id?: string }, ): PasswordlessChallengeIssuedEvent { - return new PasswordlessChallengeIssuedEvent(props); + return new PasswordlessChallengeIssuedEvent({ + ...props, + id: props.id ?? crypto.randomUUID(), + eventName: 'auth.passwordless.challenge_created', + }); } } diff --git a/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-verified.event.ts b/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-verified.event.ts index 139aa23..654f1fe 100644 --- a/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-verified.event.ts +++ b/packages/authentication/methods/passwordless/src/domain/events/passwordless-challenge-verified.event.ts @@ -43,8 +43,6 @@ export class PasswordlessChallengeVerifiedEvent extends DomainEvent< * The unique name identifier for this domain event. * Used for event routing, logging, and event store indexing. */ - public readonly eventName = 'auth.passwordless.challenge_verified'; - /** * Creates a new PasswordlessChallengeVerifiedEvent instance. * @@ -57,11 +55,18 @@ export class PasswordlessChallengeVerifiedEvent extends DomainEvent< * @returns {PasswordlessChallengeVerifiedEvent} A new instance of PasswordlessChallengeVerifiedEvent */ public static create( - props: CreateEventProps< - PasswordlessChallengeVerifiedPayload, - PasswordlessChallengeId - >, + props: Omit< + CreateEventProps< + PasswordlessChallengeVerifiedPayload, + PasswordlessChallengeId + >, + 'id' | 'eventName' + > & { id?: string }, ): PasswordlessChallengeVerifiedEvent { - return new PasswordlessChallengeVerifiedEvent(props); + return new PasswordlessChallengeVerifiedEvent({ + ...props, + id: props.id ?? crypto.randomUUID(), + eventName: 'auth.passwordless.challenge_verified', + }); } } diff --git a/packages/ddd/README.md b/packages/ddd/README.md index 8f51b0f..73c93fb 100644 --- a/packages/ddd/README.md +++ b/packages/ddd/README.md @@ -249,10 +249,17 @@ export class OAuthAuthorization extends Entity< OAuthAuthorizationId, OAuthAuthorizationProps > { - constructor( + private constructor( props: EntityProps, ) { super({ ...props }); + this.validate(); + } + + static create( + props: EntityProps, + ): OAuthAuthorization { + return new OAuthAuthorization(props); } toObject(): Record { @@ -264,15 +271,15 @@ export class OAuthAuthorization extends Entity< }; } - validate(): void { - if (!this.props.redirectUri.startsWith('https://')) { + protected validateProps(props: OAuthAuthorizationProps): void { + if (!props.redirectUri.startsWith('https://')) { throw new Error('Redirect URI must use HTTPS'); } } } // Usage -const auth = new OAuthAuthorization({ +const auth = OAuthAuthorization.create({ id: OAuthAuthorizationId.generate(), props: { provider: 'google', @@ -308,10 +315,9 @@ class OrderCreatedEvent extends DomainEvent< AggregateId, { customerId: string } > { - readonly eventName = 'OrderCreated'; - static create(props: { - id?: string; + id: string; + eventName: string; aggregateId: AggregateId; schemaVersion: number; occurredAt: number; @@ -322,10 +328,9 @@ class OrderCreatedEvent extends DomainEvent< } class OrderCompletedEvent extends DomainEvent { - readonly eventName = 'OrderCompleted'; - static create(props: { - id?: string; + id: string; + eventName: string; aggregateId: AggregateId; schemaVersion: number; occurredAt: number; @@ -342,11 +347,14 @@ class Order extends AggregateRoot { props: OrderProps; }) { super(params); + this.validate(); } create(): void { - this.addEvent( + this.recordEvent( OrderCreatedEvent.create({ + id: crypto.randomUUID(), + eventName: 'OrderCreated', aggregateId: this.id, schemaVersion: 1, occurredAt: Date.now(), @@ -356,8 +364,10 @@ class Order extends AggregateRoot { } complete(): void { - this.addEvent( + this.recordEvent( OrderCompletedEvent.create({ + id: crypto.randomUUID(), + eventName: 'OrderCompleted', aggregateId: this.id, schemaVersion: 1, occurredAt: Date.now(), @@ -366,11 +376,11 @@ class Order extends AggregateRoot { ); } - validate(): void { - if (!this.props.customerId?.trim()) { + protected validateProps(props: OrderProps): void { + if (!props.customerId?.trim()) { throw EntityValidationError.create('Customer ID is required', {}); } - if (this.props.total < 0) { + if (props.total < 0) { throw EntityValidationError.create('Total must be non-negative', {}); } } @@ -401,8 +411,9 @@ const events = order.pullDomainEvents(); // returns and clears ## Domain Events -Events are immutable. Payload must be `Serializable` (primitives, arrays, plain -objects). `id` is auto-generated if omitted. +Events are immutable. Payload must be JSON-safe (primitives, arrays, and plain +objects). Event factories must provide an ID and stable event name to the base +constructor; the base class never generates IDs through a runtime-specific API. ### Example (from `domain.event.spec.ts`) @@ -415,10 +426,9 @@ interface TestPayload extends DomainEventPayload { } class TestDomainEvent extends DomainEvent { - readonly eventName = 'TestEvent'; - static create(props: { - id?: string; + id: string; + eventName: string; aggregateId: AggregateId; schemaVersion: number; occurredAt: number; @@ -430,6 +440,8 @@ class TestDomainEvent extends DomainEvent { // Usage const event = TestDomainEvent.create({ + id: crypto.randomUUID(), + eventName: 'TestEvent', aggregateId: AggregateId.generate(), schemaVersion: 1, occurredAt: Date.now(), @@ -772,25 +784,25 @@ from the package directory. Add a changeset for publishable changes: ### Entity\ -| Member | Description | -| ----------------- | ------------------------------ | -| `id` | Identity | -| `createdAt` | Creation date | -| `props` | Read-only (protected) | -| `equals(other)` | By `id` | -| `mutate(updater)` | Safe state change + revalidate | -| `validate()` | Abstract | -| `toObject()` | Abstract | +| Member | Description | +| ---------------------- | --------------------------------------------- | +| `id` | Identity | +| `createdAt` | Creation date | +| `props` | Read-only (protected) | +| `equals(other)` | By `id` | +| `mutate(updater)` | Safe state change + revalidate | +| `validateProps(props)` | Protected abstract candidate-state validation | +| `toObject()` | Abstract | ### AggregateRoot\ Extends `Entity`. Adds: -| Member | Description | -| -------------------- | ------------------- | -| `addEvent(event)` | Append domain event | -| `domainEvents` | Read-only copy | -| `pullDomainEvents()` | Return and clear | +| Member | Description | +| -------------------- | --------------------------------------------- | +| `recordEvent(event)` | Protected; append event after ownership check | +| `domainEvents` | Read-only copy | +| `pullDomainEvents()` | Return and clear | ### DomainEvent\ @@ -801,7 +813,7 @@ Extends `Entity`. Adds: | `schemaVersion` | Version | | `occurredAt` | Unix ms | | `payload` | Serializable data | -| `eventName` | Abstract | +| `eventName` | Stable event name | | `toPrimitives()` | Plain object | ### Result\ diff --git a/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts b/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts index ac64709..8a257cc 100644 --- a/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts +++ b/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts @@ -12,8 +12,6 @@ interface OrderProps { } class OrderCreatedEvent extends DomainEvent { - public readonly eventName = 'OrderCreated'; - public static create(props: { id?: string; aggregateId: UUID; @@ -21,13 +19,15 @@ class OrderCreatedEvent extends DomainEvent { occurredAt: number; payload: { customerId: string }; }): OrderCreatedEvent { - return new OrderCreatedEvent(props); + return new OrderCreatedEvent({ + ...props, + id: props.id ?? crypto.randomUUID(), + eventName: 'OrderCreated', + }); } } class OrderCompletedEvent extends DomainEvent { - public readonly eventName = 'OrderCompleted'; - public static create(props: { id?: string; aggregateId: UUID; @@ -35,7 +35,11 @@ class OrderCompletedEvent extends DomainEvent { occurredAt: number; payload: { total: number }; }): OrderCompletedEvent { - return new OrderCompletedEvent(props); + return new OrderCompletedEvent({ + ...props, + id: props.id ?? crypto.randomUUID(), + eventName: 'OrderCompleted', + }); } } @@ -43,10 +47,11 @@ class Order extends AggregateRoot { // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(params: { id: UUID; createdAt?: Date; props: OrderProps }) { super(params); + this.validate(); } public complete(): void { - this.addEvent( + this.recordEvent( OrderCompletedEvent.create({ payload: { total: this.props.total }, occurredAt: Date.now(), @@ -56,8 +61,12 @@ class Order extends AggregateRoot { ); } + public record(event: DomainEvent): void { + this.recordEvent(event); + } + public create(): void { - this.addEvent( + this.recordEvent( OrderCreatedEvent.create({ payload: { customerId: this.props.customerId }, occurredAt: Date.now(), @@ -76,11 +85,11 @@ class Order extends AggregateRoot { }; } - public validate(): void { - if (!this.props.customerId || this.props.customerId.trim().length === 0) { + protected validateProps(props: OrderProps): void { + if (!props.customerId || props.customerId.trim().length === 0) { throw EntityValidationError.create('Customer ID is required', {}); } - if (this.props.total < 0) { + if (props.total < 0) { throw EntityValidationError.create('Total must be non-negative', {}); } } @@ -115,6 +124,24 @@ describe('aggregateRoot', () => { expect(order.domainEvents[1]).toBeInstanceOf(OrderCompletedEvent); }); + it('should reject an event belonging to another aggregate', () => { + const order = new Order({ + props: { customerId: 'customer-1', total: 100 }, + id: UUID.generate(), + }); + + expect(() => + order.record( + OrderCreatedEvent.create({ + payload: { customerId: 'customer-2' }, + aggregateId: UUID.generate(), + occurredAt: Date.now(), + schemaVersion: 1, + }), + ), + ).toThrow('Domain event belongs to a different aggregate'); + }); + it('should return copy of events that does not affect original', () => { const order = new Order({ props: { customerId: 'customer-1', total: 100 }, diff --git a/packages/ddd/src/domain/aggregates/aggregate-root.ts b/packages/ddd/src/domain/aggregates/aggregate-root.ts index f85b647..68dd8d4 100644 --- a/packages/ddd/src/domain/aggregates/aggregate-root.ts +++ b/packages/ddd/src/domain/aggregates/aggregate-root.ts @@ -49,11 +49,15 @@ export abstract class AggregateRoot< private readonly _domainEvents: Event[] = []; /** - * Adds a domain event to the aggregate after validating invariants. - * @param domainEvent The domain event to add. - * @throws {EntityValidationError} If invariants are not met. + * Records a domain event owned by this aggregate. + * @param domainEvent The domain event to record. + * @throws {Error} If the event belongs to another aggregate. */ - addEvent(domainEvent: Event): void { + protected recordEvent(domainEvent: Event): void { + if (!this.id.equals(domainEvent.aggregateId)) { + throw new Error('Domain event belongs to a different aggregate'); + } + this._domainEvents.push(domainEvent); } diff --git a/packages/ddd/src/domain/entities/__tests__/entity.spec.ts b/packages/ddd/src/domain/entities/__tests__/entity.spec.ts index 9cab93c..c4ac027 100644 --- a/packages/ddd/src/domain/entities/__tests__/entity.spec.ts +++ b/packages/ddd/src/domain/entities/__tests__/entity.spec.ts @@ -22,6 +22,7 @@ class User extends Entity { // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(params: EntityProps) { super(params); + this.validate(); } public toObject(): Record { @@ -37,11 +38,11 @@ class User extends Entity { this.mutate(props => ({ ...props, name })); } - public validate(): void { - if (!this.props.name || this.props.name.trim().length === 0) { + protected validateProps(props: UserProps): void { + if (!props.name || props.name.trim().length === 0) { throw EntityValidationError.create('Name is required', {}); } - if (!this.props.email || !this.props.email.includes('@')) { + if (!props.email || !props.email.includes('@')) { throw EntityValidationError.create('Valid email is required', {}); } } @@ -248,6 +249,21 @@ describe('entity', () => { expect(() => { (user as any).mutate((props: UserProps) => ({ ...props, name: '' })); }).toThrow(EntityValidationError); + + expect(user.name).toBe('John Doe'); + }); + + it('should protect createdAt from mutation through a returned Date', () => { + const createdAt = new Date('2023-01-01'); + const user = new User({ + props: { email: 'john@example.com', name: 'John Doe' }, + createdAt, + id: UUID.generate(), + }); + + user.createdAt.setTime(new Date('2030-01-01').getTime()); + + expect(user.createdAt).toEqual(createdAt); }); }); diff --git a/packages/ddd/src/domain/entities/entity.ts b/packages/ddd/src/domain/entities/entity.ts index 9af842f..0c16de1 100644 --- a/packages/ddd/src/domain/entities/entity.ts +++ b/packages/ddd/src/domain/entities/entity.ts @@ -31,8 +31,12 @@ export interface EntityProps { * @template ID - The specific Identity Value Object type. */ export abstract class Entity { - /** The timestamp when this entity was first instantiated/created */ - public readonly createdAt: Date; + /** The timestamp when this entity was first instantiated/created. */ + public get createdAt(): Date { + return new Date(this.#createdAtMillis); + } + + #createdAtMillis: number; /** The immutable unique identifier for this entity */ public readonly id: ID; @@ -53,10 +57,13 @@ export abstract class Entity { */ protected constructor(params: EntityProps) { this.id = params.id; - this.createdAt = params.createdAt ?? new Date(); - this.#props = deepFreeze(params.props); + const createdAtMillis = (params.createdAt ?? new Date()).getTime(); + if (!Number.isFinite(createdAtMillis)) { + throw new Error('Entity createdAt must be a valid date'); + } - this.validate(); + this.#createdAtMillis = createdAtMillis; + this.#props = deepFreeze(params.props); } private static normalize(value: unknown): unknown { @@ -128,12 +135,17 @@ export abstract class Entity { * This method should be called after construction and any mutation. * @throws {Error} Should throw a specific DomainError if validation fails. */ - public abstract validate(): void; + public validate(): void { + this.validateProps(this.#props as Immutable); + } + + protected abstract validateProps(props: Immutable): void; protected mutate(updater: (current: Props) => Props): void { - const next = updater(this.#props); + const next = deepFreeze(updater(this.#props)); + + this.validateProps(next as Immutable); - this.#props = deepFreeze(next); - this.validate(); + this.#props = next; } } diff --git a/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts b/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts index d4f406b..154407b 100644 --- a/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts +++ b/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts @@ -10,8 +10,6 @@ interface TestPayload extends DomainEventPayload { } class TestDomainEvent extends DomainEvent { - public readonly eventName = 'TestEvent'; - // Expose protected constructor for testing public static create(props: { id?: string; @@ -20,7 +18,11 @@ class TestDomainEvent extends DomainEvent { occurredAt: number; payload: TestPayload; }): TestDomainEvent { - return new TestDomainEvent(props); + return new TestDomainEvent({ + ...props, + id: props.id ?? crypto.randomUUID(), + eventName: 'TestEvent', + }); } } @@ -96,6 +98,31 @@ describe('domainEvent', () => { (event.payload as any).userId = 'user-2'; }).toThrow('Cannot assign to read only property'); }); + + it('should reject invalid event metadata', () => { + const aggregateId = UUID.generate(); + const base = { + eventName: 'TestEvent', + id: 'event-1', + aggregateId, + occurredAt: Date.now(), + schemaVersion: 1, + payload: { userId: 'user-1', action: 'login' }, + }; + + expect(() => + TestDomainEvent.create({ ...base, schemaVersion: 0 }), + ).toThrow('Event schema version must be a positive integer'); + expect(() => TestDomainEvent.create({ ...base, occurredAt: -1 })).toThrow( + 'Event occurrence timestamp must be a valid Unix timestamp', + ); + expect(() => + TestDomainEvent.create({ + ...base, + payload: { userId: 'user-1', action: NaN as unknown as string }, + }), + ).toThrow('Event payload must contain finite numbers'); + }); }); describe('toPrimitives', () => { diff --git a/packages/ddd/src/domain/events/domain.event.ts b/packages/ddd/src/domain/events/domain.event.ts index 4d9f1c1..25f880c 100644 --- a/packages/ddd/src/domain/events/domain.event.ts +++ b/packages/ddd/src/domain/events/domain.event.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import { JsonValue } from 'type-fest'; import { deepFreeze } from '@/utils'; @@ -13,7 +11,8 @@ export type DomainEventPayload = Record; export type UnixTimestampMillis = number; type DomainEventProps = { - id?: string; + id: string; + eventName: string; aggregateId: AggregateId; schemaVersion: number; occurredAt: UnixTimestampMillis; @@ -32,20 +31,81 @@ export abstract class DomainEvent< > { public readonly aggregateId: AggregateId; - public abstract readonly eventName: string; + public readonly eventName: string; public readonly id: string; public readonly occurredAt: number; public readonly payload: Readonly; public readonly schemaVersion: number; protected constructor(props: DomainEventProps) { - this.id = props.id ?? randomUUID(); + if (typeof props.id !== 'string' || !props.id.trim()) { + throw new Error('Domain event ID is required'); + } + if (typeof props.eventName !== 'string' || !props.eventName.trim()) { + throw new Error('Event name is required'); + } + if (!Number.isInteger(props.schemaVersion) || props.schemaVersion < 1) { + throw new Error('Event schema version must be a positive integer'); + } + if (!Number.isInteger(props.occurredAt) || props.occurredAt < 0) { + throw new Error( + 'Event occurrence timestamp must be a valid Unix timestamp', + ); + } + + DomainEvent.assertSerializable(props.payload); + + this.eventName = props.eventName; + this.id = props.id; this.aggregateId = props.aggregateId; this.schemaVersion = props.schemaVersion; this.occurredAt = props.occurredAt; this.payload = deepFreeze(props.payload); } + private static assertSerializable( + value: unknown, + seen = new WeakSet(), + ): void { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return; + } + + if (typeof value === 'number') { + if (Number.isFinite(value)) return; + throw new Error('Event payload must contain finite numbers'); + } + + if (typeof value !== 'object') { + throw new Error('Event payload must contain JSON-safe values'); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('Event payload must contain JSON-safe values'); + } + + if (seen.has(value)) { + throw new Error('Event payload cannot contain circular references'); + } + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) DomainEvent.assertSerializable(item, seen); + seen.delete(value); + return; + } + + for (const item of Object.values(value)) { + DomainEvent.assertSerializable(item, seen); + } + seen.delete(value); + } + public toPrimitives(): Readonly<{ id: string; eventName: string; diff --git a/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts b/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts index 76230cd..53b7af3 100644 --- a/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts +++ b/packages/ddd/src/domain/types/__tests__/deep-primitive.test-d.ts @@ -98,7 +98,7 @@ class User extends Entity { return {}; } - public validate(): void {} + protected validateProps(_props: UserProps): void {} } declare const user: User; diff --git a/plan/refactor-ddd-primitives-1.md b/plan/refactor-ddd-primitives-1.md index 5536673..949d232 100644 --- a/plan/refactor-ddd-primitives-1.md +++ b/plan/refactor-ddd-primitives-1.md @@ -4,13 +4,13 @@ version: 1.0 date_created: 2026-08-08 last_updated: 2026-08-08 owner: Rineex Team -status: Planned +status: Completed tags: [refactor, ddd, architecture, breaking-change] --- # Introduction -![Status: Planned](https://img.shields.io/badge/status-Planned-blue) +![Status: Completed](https://img.shields.io/badge/status-Completed-brightgreen) Refactor `@rineex/ddd` to enforce safe invariant validation, atomic entity mutation, aggregate-owned event recording, explicit event metadata, and @@ -140,7 +140,8 @@ contract. - **TEST-008**: Creation emits creation events only when intended; mapper rehydration emits none. - **TEST-009**: Package type tests, unit tests, lint, and build pass; affected - authentication tests pass. + authentication tests pass. Authentication typecheck still reports unrelated + pre-existing repository symbols and registry/test fixture errors. ## 7. Risks & Assumptions From e219a30371a19df1ab96bf9f71b864833bb31f08 Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sat, 8 Aug 2026 20:03:47 +0330 Subject: [PATCH 4/5] chore(ddd): add breaking changeset --- .changeset/harden-ddd-primitives.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/harden-ddd-primitives.md diff --git a/.changeset/harden-ddd-primitives.md b/.changeset/harden-ddd-primitives.md new file mode 100644 index 0000000..970b652 --- /dev/null +++ b/.changeset/harden-ddd-primitives.md @@ -0,0 +1,5 @@ +--- +'@rineex/ddd': major +--- + +Harden DDD entities, aggregate roots, and domain events with safe invariant validation, atomic mutations, aggregate-owned event recording, explicit event metadata, and mapper-oriented rehydration guidance. From c3f98e910a2839d39329728dca7ae3c1b7e0f55b Mon Sep 17 00:00:00 2001 From: Hossein Pourdavar Date: Sat, 8 Aug 2026 20:08:02 +0330 Subject: [PATCH 5/5] feat(ddd): implement pullDomainEvents method in AggregateRoot Added a new method to the AggregateRoot class to retrieve and clear domain events. This enhances the event handling capabilities of aggregates. Additionally, refactored the Order class to reposition the record method for clarity and removed unnecessary comments in the User class constructor. Updated the Entity class to improve the structure of properties and maintain immutability. --- .../aggregates/__tests__/aggregate-root.spec.ts | 9 ++++----- .../ddd/src/domain/aggregates/aggregate-root.ts | 12 ++++++------ .../src/domain/entities/__tests__/entity.spec.ts | 3 +-- packages/ddd/src/domain/entities/entity.ts | 14 +++++++------- .../domain/events/__tests__/domain.event.spec.ts | 8 ++++---- .../value-objects/__tests__/email.vo.spec.ts | 1 + 6 files changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts b/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts index 8a257cc..dbb5d17 100644 --- a/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts +++ b/packages/ddd/src/domain/aggregates/__tests__/aggregate-root.spec.ts @@ -44,7 +44,6 @@ class OrderCompletedEvent extends DomainEvent { } class Order extends AggregateRoot { - // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(params: { id: UUID; createdAt?: Date; props: OrderProps }) { super(params); this.validate(); @@ -61,10 +60,6 @@ class Order extends AggregateRoot { ); } - public record(event: DomainEvent): void { - this.recordEvent(event); - } - public create(): void { this.recordEvent( OrderCreatedEvent.create({ @@ -76,6 +71,10 @@ class Order extends AggregateRoot { ); } + public record(event: DomainEvent): void { + this.recordEvent(event); + } + public toObject(): Record { return { createdAt: this.createdAt.toISOString(), diff --git a/packages/ddd/src/domain/aggregates/aggregate-root.ts b/packages/ddd/src/domain/aggregates/aggregate-root.ts index 68dd8d4..84d86de 100644 --- a/packages/ddd/src/domain/aggregates/aggregate-root.ts +++ b/packages/ddd/src/domain/aggregates/aggregate-root.ts @@ -48,6 +48,12 @@ export abstract class AggregateRoot< */ private readonly _domainEvents: Event[] = []; + public pullDomainEvents(): readonly Event[] { + const events = [...this._domainEvents]; + this._domainEvents.length = 0; + return events; + } + /** * Records a domain event owned by this aggregate. * @param domainEvent The domain event to record. @@ -60,10 +66,4 @@ export abstract class AggregateRoot< this._domainEvents.push(domainEvent); } - - public pullDomainEvents(): readonly Event[] { - const events = [...this._domainEvents]; - this._domainEvents.length = 0; - return events; - } } diff --git a/packages/ddd/src/domain/entities/__tests__/entity.spec.ts b/packages/ddd/src/domain/entities/__tests__/entity.spec.ts index c4ac027..f80f3ca 100644 --- a/packages/ddd/src/domain/entities/__tests__/entity.spec.ts +++ b/packages/ddd/src/domain/entities/__tests__/entity.spec.ts @@ -19,7 +19,6 @@ class User extends Entity { return this.props.name; } - // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(params: EntityProps) { super(params); this.validate(); @@ -257,8 +256,8 @@ describe('entity', () => { const createdAt = new Date('2023-01-01'); const user = new User({ props: { email: 'john@example.com', name: 'John Doe' }, - createdAt, id: UUID.generate(), + createdAt, }); user.createdAt.setTime(new Date('2030-01-01').getTime()); diff --git a/packages/ddd/src/domain/entities/entity.ts b/packages/ddd/src/domain/entities/entity.ts index 0c16de1..a4ea1bc 100644 --- a/packages/ddd/src/domain/entities/entity.ts +++ b/packages/ddd/src/domain/entities/entity.ts @@ -31,15 +31,13 @@ export interface EntityProps { * @template ID - The specific Identity Value Object type. */ export abstract class Entity { + /** The immutable unique identifier for this entity */ + public readonly id: ID; + /** The timestamp when this entity was first instantiated/created. */ public get createdAt(): Date { return new Date(this.#createdAtMillis); } - - #createdAtMillis: number; - /** The immutable unique identifier for this entity */ - public readonly id: ID; - /** * Read-only view of entity state. * External code can never mutate internal state. @@ -48,6 +46,8 @@ export abstract class Entity { return this.#props as Immutable; } + #createdAtMillis: number; + // protected props: Props; #props: Props; @@ -139,8 +139,6 @@ export abstract class Entity { this.validateProps(this.#props as Immutable); } - protected abstract validateProps(props: Immutable): void; - protected mutate(updater: (current: Props) => Props): void { const next = deepFreeze(updater(this.#props)); @@ -148,4 +146,6 @@ export abstract class Entity { this.#props = next; } + + protected abstract validateProps(props: Immutable): void; } diff --git a/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts b/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts index 154407b..9ab2d83 100644 --- a/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts +++ b/packages/ddd/src/domain/events/__tests__/domain.event.spec.ts @@ -102,12 +102,12 @@ describe('domainEvent', () => { it('should reject invalid event metadata', () => { const aggregateId = UUID.generate(); const base = { + payload: { userId: 'user-1', action: 'login' }, eventName: 'TestEvent', - id: 'event-1', - aggregateId, occurredAt: Date.now(), schemaVersion: 1, - payload: { userId: 'user-1', action: 'login' }, + id: 'event-1', + aggregateId, }; expect(() => @@ -119,7 +119,7 @@ describe('domainEvent', () => { expect(() => TestDomainEvent.create({ ...base, - payload: { userId: 'user-1', action: NaN as unknown as string }, + payload: { action: NaN as unknown as string, userId: 'user-1' }, }), ).toThrow('Event payload must contain finite numbers'); }); diff --git a/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts b/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts index 0b45798..24896c6 100644 --- a/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts +++ b/packages/ddd/src/domain/value-objects/__tests__/email.vo.spec.ts @@ -59,6 +59,7 @@ describe('email ValueObject', () => { try { // eslint-disable-next-line no-new new Email(invalidEmail); + expect.fail('Expected InvalidValueObjectError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(InvalidValueObjectError);