Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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;
  }
}

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.

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)})`;
  }
}

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.


@Entity is the foundation of @AggregateRoot and the principal consumer of ddd-core.

  • The id field is typed with Id<T>Customer.id: Id<'Customer'>, not string.
  • Composed with @ValueObject — entities hold value-object fields (email: EmailAddress, plan: BillingPlanId | null).
  • Each @AggregateRoot is itself an entity — the aggregate root inherits the entity discipline plus the cluster-and-consistency-boundary contract.
  • Declared invariants compose with @DesignByContract@Pre/@Post/@Invariant decorators materialise the declarations.

Back to the series index.

⬇ Download