TypeScript DDD Entity
MANDATORY — READ ENTIRE FILE: Before any implementation step, read
references/entity-pattern.md completely.
Do NOT load other DDD skills (use-case, repository, dto) unless explicitly requested.
Before You Start
Before writing a single line, answer:
- Bounded context: which BC owns this entity? File lands at
apps/api/src/<bc>/domain/entities/<name>.entity.tsand is re-exported by that folder'sindex.tsbarrel. - Identity: is
idoptional on create (useId.tryCreate/ let theEntitybase auto-generate viaId.create(props.id!)) or a required foreign relation (validate withId.required)? - Closed-set fields: every status / kind / layout / provider / palette / discriminator must come from a string-backed TS enum in
libs/contracts/<bc>/src/interfaces/orlibs/shared— never inline string literals oras consttuples. - Invariants: which VO (
Slug,PaletteKey,ImageRefValue, …) validates each field? Are there arrays of IDs or nested entities (e.g.Section[]insideCelebration)? - State transitions: does behavior require a domain method? Use
cloneWithfor immutable swap-and-revalidate, or mutate_field+this.touch()for entities that own a mutable collection (seeCelebration). - Constructor visibility:
privatefor leaf entities;protectedonly when subclasses need access.
Core Rules
- Extend
Entity<Type, Props>from@acme/shared; keep constructorprivateorprotected. - Import enums and tagged unions from
@acme/<bc>-contracts(e.g.@acme/celebrations-contracts). Never redefine wire types in the entity file. - Expose dual API:
tryCreate(props): Result<T>(returns Result, canonical) andcreate(props): T(delegates totryCreate+throwIfFailed). - Validate every field via VOs / type guards + collect errors (either
Result.combine([...])or a manualerrors: string[]accumulator — both patterns exist in this codebase; useResult.combinewhen all checks returnResult<T>). - Always store normalized values. Spread
vo.instance.valuefor scalars; for sibling entities, prefer building fromSection.tryCreate(sp)and keeping the array asSection[]in a private field while keepingSectionProps[]inpropsfor serialization. - Getters expose domain values;
this.propsis never accessed from outside the entity class. cloneWith(overrides)deep-merges and re-runstryCreateautomatically — never calltryCreateby hand from a domain method whencloneWithsuffices.
Enum Rule (HARD)
Every closed set is a string-backed TS enum in the contracts package. Pattern:
export enum CelebrationStatusEnum {
DRAFT = "draft",
PUBLISHED = "published",
}
export const CELEBRATION_STATUSES = Object.values(CelebrationStatusEnum);
export type CelebrationStatus = (typeof CelebrationStatusEnum)[keyof typeof CelebrationStatusEnum];
export function isCelebrationStatus(v: unknown): v is CelebrationStatus {
return typeof v === "string" && CELEBRATION_STATUSES.includes(v as CelebrationStatus);
}
Inside the entity:
- Validate with the type guard (
isCelebrationStatus(props.status)→ push"INVALID_CELEBRATION_STATUS"on failure). - Store the value typed as the union (
CelebrationStatus), emit it from a getter. - Compare with the enum member:
this._status === CelebrationStatusEnum.PUBLISHED. Never=== "published". - Singleton / limit catalogs reference enum members:
const SINGLETON_KINDS = [SectionKindEnum.HERO, SectionKindEnum.GALLERY, SectionKindEnum.SIGNATURE] as const.
Base Class Behaviour You Must Know
Entity's protected constructor calls Id.create(props.id!, { attribute: "id" }) and stores the normalized id, plus initializes createdAt/updatedAt/deletedAt if absent. Therefore:
- Do not set
createdAt,updatedAt,deletedAtinsidetryCreate. Usethis.props.updatedAt = new Date()via a privatetouch()for mutations. cloneWithusesstructuredCloneonpropsbefore deep-merging, so nested objects are safe from caller mutation.
NEVER
- NEVER use a raw string literal where an enum member exists (
"published"→CelebrationStatusEnum.PUBLISHED). - NEVER store raw VO input in
props— always normalize viavo.instance.value. - NEVER add a public setter — mutate state through a named domain method.
- NEVER skip validating array elements — loop and either push into a manual
errors[]orResult.combineper-element results. - NEVER put a domain invariant in the use case if it must hold for the entity from any caller.
- NEVER expose
this.propsto callers outside the entity — use typed getters or a deliberatetoSnapshot()method. - NEVER import from
@ddd/shared(legacy alias). The shared lib is@acme/shared.
References
See references/entity-pattern.md for: real paths, canonical tryCreate snippet, enum-driven validation, array / nested-entity pattern, cloneWith vs mutable-collection mutation, test layout under apps/api/test/, and the pitfalls table.
See also examples/product.entity.ts and examples/product.entity.test.ts for a self-contained reference entity with an enum-driven status field.