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. 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/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 f10a7e4..73c93fb 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 @@ -245,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 { @@ -260,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', @@ -304,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; @@ -318,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; @@ -338,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(), @@ -352,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(), @@ -362,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', {}); } } @@ -397,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`) @@ -411,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; @@ -426,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(), @@ -768,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\ @@ -797,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/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. 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..dbb5d17 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,18 +35,22 @@ 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', + }); } } 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(), @@ -57,7 +61,7 @@ class Order extends AggregateRoot { } public create(): void { - this.addEvent( + this.recordEvent( OrderCreatedEvent.create({ payload: { customerId: this.props.customerId }, occurredAt: Date.now(), @@ -67,6 +71,10 @@ class Order extends AggregateRoot { ); } + public record(event: DomainEvent): void { + this.recordEvent(event); + } + public toObject(): Record { return { createdAt: this.createdAt.toISOString(), @@ -76,11 +84,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 +123,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..84d86de 100644 --- a/packages/ddd/src/domain/aggregates/aggregate-root.ts +++ b/packages/ddd/src/domain/aggregates/aggregate-root.ts @@ -48,18 +48,22 @@ 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. - */ - addEvent(domainEvent: Event): void { - this._domainEvents.push(domainEvent); - } - 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. + * @throws {Error} If the event belongs to another aggregate. + */ + 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..f80f3ca 100644 --- a/packages/ddd/src/domain/entities/__tests__/entity.spec.ts +++ b/packages/ddd/src/domain/entities/__tests__/entity.spec.ts @@ -19,9 +19,9 @@ class User extends Entity { return this.props.name; } - // eslint-disable-next-line @typescript-eslint/no-useless-constructor constructor(params: EntityProps) { super(params); + this.validate(); } public toObject(): Record { @@ -37,11 +37,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 +248,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' }, + id: UUID.generate(), + createdAt, + }); + + 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..a4ea1bc 100644 --- a/packages/ddd/src/domain/entities/entity.ts +++ b/packages/ddd/src/domain/entities/entity.ts @@ -31,11 +31,13 @@ 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 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); + } /** * Read-only view of entity state. * External code can never mutate internal state. @@ -44,6 +46,8 @@ export abstract class Entity { return this.#props as Immutable; } + #createdAtMillis: number; + // protected props: Props; #props: Props; @@ -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 mutate(updater: (current: Props) => Props): void { - const next = updater(this.#props); + const next = deepFreeze(updater(this.#props)); - this.#props = deepFreeze(next); - this.validate(); + this.validateProps(next as Immutable); + + 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 d4f406b..9ab2d83 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 = { + payload: { userId: 'user-1', action: 'login' }, + eventName: 'TestEvent', + occurredAt: Date.now(), + schemaVersion: 1, + id: 'event-1', + aggregateId, + }; + + 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: { action: NaN as unknown as string, userId: 'user-1' }, + }), + ).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/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); diff --git a/plan/refactor-ddd-primitives-1.md b/plan/refactor-ddd-primitives-1.md new file mode 100644 index 0000000..949d232 --- /dev/null +++ b/plan/refactor-ddd-primitives-1.md @@ -0,0 +1,167 @@ +--- +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: Completed +tags: [refactor, ddd, architecture, breaking-change] +--- + +# Introduction + +![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 +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. Authentication typecheck still reports unrelated + pre-existing repository symbols and registry/test fixture errors. + +## 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)