Identity Continuity Over Time
Where a value object is equal by structure, an entity is equal by identity. A Customer with id cu_abc remains the same Customer whether their email changes today and their plan tomorrow — same identity, evolving state. @Entity from @frenchexdev/ddd-entity reifies that discipline: every entity carries a typed identifier, the identifier is readonly, and equality is by id rather than by field comparison.
What @Entity Reifies
Evans (Domain-Driven Design, chapter 5) distinguishes entities from value objects by identity continuity. The same Customer may have a different email next month and a different subscriptionTier next year; the system treats them as the same customer because the id is unchanged. A Money value object, in contrast, has no identity — two Money(50, 'EUR') instances are interchangeable, even if they originated from different transactions.
Three properties define an entity in the corpus. Typed identity: the id field is typed with a brand from ddd-core (CustomerId = Id<'Customer'>), so the type system distinguishes a customer id from an order id. Immutable identity: the id is readonly. Mutating an entity's id silently changes which entity it is — every equality check, every cache lookup, every database row goes wrong after the mutation. Equality by id: customer.equals(other) returns true iff their ids match, regardless of what their other fields hold.
The decorator's optional invariants field is the bridge to design-by-contract. A declared invariant — say, subscriptionPlanActive — is a name that the analyzer expects to find enforced somewhere on the class. The pattern composes with @DesignByContract (Part 37) to materialise the invariant as a runtime guard.
The Runtime: ddd-entity
decorator.ts keeps the surface tight. EntityOptions requires id: string (the type name of the id, not the value — 'CustomerId' or "Id<'Customer'>") and accepts optional invariants?: readonly string[]. The decorator stamps __entity and __entityMetadata onto the class.
In our invented Subscription context:
import { Entity } from '@frenchexdev/ddd-entity';
import { id, type Id, Guard } from '@frenchexdev/ddd-core';
import type { EmailAddress } from '../value-objects/email-address.js';
import type { BillingPlanId } from './billing-plan.js';
export type CustomerId = Id<'Customer'>;
@Entity({
id: 'CustomerId',
invariants: ['emailIsValid', 'planIsActiveOrNone'],
})
export class Customer {
constructor(
public readonly id: CustomerId,
public email: EmailAddress, // mutable — entities evolve
public plan: BillingPlanId | null,
) {
Guard.defined(email, 'Customer.email');
}
changeEmail(newEmail: EmailAddress): void {
Guard.defined(newEmail, 'Customer.email');
this.email = newEmail;
}
}import { Entity } from '@frenchexdev/ddd-entity';
import { id, type Id, Guard } from '@frenchexdev/ddd-core';
import type { EmailAddress } from '../value-objects/email-address.js';
import type { BillingPlanId } from './billing-plan.js';
export type CustomerId = Id<'Customer'>;
@Entity({
id: 'CustomerId',
invariants: ['emailIsValid', 'planIsActiveOrNone'],
})
export class Customer {
constructor(
public readonly id: CustomerId,
public email: EmailAddress, // mutable — entities evolve
public plan: BillingPlanId | null,
) {
Guard.defined(email, 'Customer.email');
}
changeEmail(newEmail: EmailAddress): void {
Guard.defined(newEmail, 'Customer.email');
this.email = newEmail;
}
}The id is readonly (the analyzer's DDD0102 enforces). The email is mutable on purpose — that is the difference between an entity and a value object — and changes flow through methods, not direct field assignment. The Guard.defined call at construction prevents an entity from existing with an undefined invariant; subsequent guards live in the mutation methods.
The Analyzer: ddd-entity-analyzer
Hand-written, like Domain Event, Event Bus, and Value Object. codes.ts exports four diagnostics in the DDD0NNN namespace.
DDD0100_ENTITY_MISSING_ID is the most important — an entity without an id is not an entity, by definition. Error severity.
DDD0101_ENTITY_RAW_STRING_ID is the use a brand recommendation. An entity that types its id as raw string is technically valid, but the type system cannot prevent a CustomerId from being passed where an OrderId is expected. Warning severity — the team may have a reason (legacy id columns, externally-controlled strings) but the recommendation is to wrap.
DDD0102_ENTITY_MUTABLE_ID is the immutable-identity invariant. Mutating an id is the kind of bug that survives multiple test passes because it usually only manifests in production caching layers. Error severity.
DDD0103_ENTITY_DECLARED_INVARIANT_NOT_ENFORCED is the cross-pattern hint: an entity declares invariants: ['emailIsValid'] in the decorator, but no method on the class enforces it. Warning severity — the invariant may be enforced elsewhere (a value-object constructor inside the entity, a @Pre from DbC), and the analyzer cannot always tell.
entityMissingId('Customer', 'entities/customer.ts', 5);
// DDD0100 [error] @Entity class "Customer" does not expose an `id` field.
// Every Entity must carry a stable identifier.
entityRawStringId('Customer', 'entities/customer.ts', 6);
// DDD0101 [warning] @Entity class "Customer" types its id as raw `string`.
// Prefer `Id<T>` from @frenchexdev/ddd-core for type-safe identifiers.
entityMutableId('Customer', 'entities/customer.ts', 6);
// DDD0102 [error] @Entity class "Customer" exposes a mutable `id`.
// The identifier must be `readonly` and never change once assigned.entityMissingId('Customer', 'entities/customer.ts', 5);
// DDD0100 [error] @Entity class "Customer" does not expose an `id` field.
// Every Entity must carry a stable identifier.
entityRawStringId('Customer', 'entities/customer.ts', 6);
// DDD0101 [warning] @Entity class "Customer" types its id as raw `string`.
// Prefer `Id<T>` from @frenchexdev/ddd-core for type-safe identifiers.
entityMutableId('Customer', 'entities/customer.ts', 6);
// DDD0102 [error] @Entity class "Customer" exposes a mutable `id`.
// The identifier must be `readonly` and never change once assigned.The four codes will survive the eventual spec-first migration; downstream compliance and tooling depend on them by name.
The Codegen: ddd-entity-codegen
generator.ts emits a typed Base class per entity. The Base carries the readonly id field, an equals(other) implementing equality-by-id (with null/undefined tolerance), and a stable toString() for diagnostics:
// AUTO-GENERATED by ddd-entity-codegen@0.0.1 — do not edit.
// Base class for entity "Customer". Equality-by-id, readonly id field.
export abstract class CustomerBase {
protected constructor(public readonly id: CustomerId) {}
equals(other: { id: CustomerId } | null | undefined): boolean {
return other !== null && other !== undefined && this.id === other.id;
}
toString(): string {
return `Customer(${String(this.id)})`;
}
}// AUTO-GENERATED by ddd-entity-codegen@0.0.1 — do not edit.
// Base class for entity "Customer". Equality-by-id, readonly id field.
export abstract class CustomerBase {
protected constructor(public readonly id: CustomerId) {}
equals(other: { id: CustomerId } | null | undefined): boolean {
return other !== null && other !== undefined && this.id === other.id;
}
toString(): string {
return `Customer(${String(this.id)})`;
}
}The leaf class extends the Base, adds the mutable fields, adds the domain methods, and inherits the equality discipline. The Base owes the consumer three things — id, equals, toString — and the leaf class never re-implements them.
Two design choices in the equality method are worth noting. Structural duck-typing on other: the signature accepts { id: CustomerId } | null | undefined, not CustomerBase — comparing a deserialised DTO with { id: 'cu_abc' } against an in-memory entity is a common pattern and the signature accommodates it. Null/undefined tolerance: comparing against a potentially-absent value is also common (lookups that return null on miss); returning false rather than throwing keeps the call site simple.
Cross-Links
@Entity is the foundation of @AggregateRoot and the principal consumer of ddd-core.
- The
idfield is typed withId<T>—Customer.id: Id<'Customer'>, notstring. - Composed with
@ValueObject— entities hold value-object fields (email: EmailAddress,plan: BillingPlanId | null). - Each
@AggregateRootis itself an entity — the aggregate root inherits the entity discipline plus the cluster-and-consistency-boundary contract. - Declared invariants compose with
@DesignByContract—@Pre/@Post/@Invariantdecorators materialise the declarations.
Back to the series index.