Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-ddd-primitives.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions docs/superpowers/specs/2026-08-08-ddd-primitives-design.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class AuthenticationAttempt extends AggregateRoot<

private constructor(props: CreateAuthAttemptProps) {
super(props);
this.validate();
}

public static start(props: StartAuthenticationProps): AuthenticationAttempt {
Expand All @@ -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;
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type IdentityProps = {
export class Identity extends AggregateRoot<IdentityId, IdentityProps> {
private constructor(params: EntityProps<IdentityId, IdentityProps>) {
super(params);
this.validate();
}

public static create(): Identity {
Expand All @@ -21,7 +22,7 @@ export class Identity extends AggregateRoot<IdentityId, IdentityProps> {
id: IdentityId.generate(),
});

identity.addEvent(IdentityCreatedEvent.create(identity.id));
identity.recordEvent(IdentityCreatedEvent.create(identity.id));

return identity;
}
Expand Down Expand Up @@ -56,7 +57,7 @@ export class Identity extends AggregateRoot<IdentityId, IdentityProps> {
status: IdentityStatus.Disabled(),
}));

this.addEvent(IdentityDisabledEvent.create(this.id));
this.recordEvent(IdentityDisabledEvent.create(this.id));
}

toObject() {
Expand All @@ -66,8 +67,8 @@ export class Identity extends AggregateRoot<IdentityId, IdentityProps> {
};
}

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(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class Identity extends Entity<IdentityId, IdentityProps> {
*/
private constructor(params: EntityProps<IdentityId, IdentityProps>) {
super(params);
this.validate();
}

/**
Expand Down Expand Up @@ -78,11 +79,11 @@ export class Identity extends Entity<IdentityId, IdentityProps> {
/**
* 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');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ export type Payload = {
};

export class IdentityDisabledEvent extends DomainEvent<IdentityId, Payload> {
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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class MFASession extends AggregateRoot<MfaSessionId, MfaSessionProps> {

constructor(props: EntityProps<MfaSessionId, MfaSessionProps>) {
super({ ...props });
this.validate();
}

issueChallenge(challenge: MFAChallenge, now: Date): void {
Expand Down Expand Up @@ -60,15 +61,15 @@ export class MFASession extends AggregateRoot<MfaSessionId, MfaSessionProps> {
};
}

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');
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface Props {
* ```
*/
export class MFAChallenge extends Entity<MfaChallengeId, Props> {
private constructor(params: EntityProps<MfaChallengeId, Props>) {
super(params);
this.validate();
}

/**
* Gets the challenge type.
*
Expand Down Expand Up @@ -109,8 +114,8 @@ export class MFAChallenge extends Entity<MfaChallengeId, Props> {
*
* @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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class OauthAuthorization extends AggregateRoot<
props: EntityProps<OAuthAuthorizationId, OAuthAuthorizationProps>,
) {
super(props);
this.validate();
}

grantConsent(now: Date): void {
Expand Down Expand Up @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class OAuthAuthorization extends Entity<
props: EntityProps<OAuthAuthorizationId, OAuthAuthorizationProps>,
) {
super({ ...props });
this.validate();
}

toObject(): Record<string, unknown> {
Expand All @@ -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<string, unknown>): void {
this.props.pkce = snapshot.pkce
? Pkce.fromJSON(snapshot.pkce as Record<string, unknown>)
: 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<string, unknown>)
: undefined,
redirectUri: snapshot.redirectUri as string,
provider: snapshot.provider as OAuthProvider,
scope: snapshot.scope as string[],
}));
}

protected snapshot(): Record<string, unknown> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type CreateSessionProps = EntityProps<SessionId, SessionProps>;
export class Session extends Entity<SessionId, SessionProps> {
protected constructor(props: CreateSessionProps) {
super(props);
this.validate();
}

public static create(props: CreateSessionProps): Session {
Expand Down Expand Up @@ -46,8 +47,8 @@ export class Session extends Entity<SessionId, SessionProps> {
};
}

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');
}
}
Expand Down
Loading
Loading