Loading learning content...
Understanding what an entity is does not automatically mean you'll design good entities. Many codebases contain entities that technically "work" but are fragile, difficult to maintain, and prone to subtle bugs.
The difference between a novice's entity and an expert's entity isn't in the definition—both understand identity and equality. The difference lies in dozens of accumulated design decisions: How is identity generated? How are invariants enforced? What constitutes behavior versus state? How are changes validated? How are relationships managed?
This page distills years of production experience into actionable guidelines. Following these principles will lead you to entity designs that remain maintainable as the domain evolves, that resist bugs naturally, and that express domain concepts clearly.
By the end of this page, you'll have a comprehensive set of design guidelines for entity identity, construction, mutation, encapsulation, invariants, and behavior. You'll understand not just what to do, but why—enabling you to adapt these principles to novel situations.
Identity is the soul of an entity. Getting identity right is foundational—mistakes here ripple through the entire system.
CustomerId, OrderId) rather than raw primitives. This prevents cross-assignment bugs where an order ID is accidentally used as a customer ID.final, readonly, or const appropriately. No setId() method should exist.generate(), parse(string), and toString() methods for serialization and debugging.12345678910111213141516171819202122232425262728293031323334353637383940414243
// ANTI-PATTERN: Primitive obsessionfunction getOrder(orderId: string): Order { ... }function getCustomer(customerId: string): Customer { ... } // Oops - passed wrong ID type, compiles fine!const order = getOrder(customerId); // Bug! // PATTERN: Strongly-typed IDsclass OrderId { private constructor(private readonly _value: string) {} static generate(): OrderId { return new OrderId(crypto.randomUUID()); } static parse(value: string): OrderId { if (!value || !OrderId.isValid(value)) { throw new InvalidOrderIdError(value); } return new OrderId(value); } static isValid(value: string): boolean { // UUID v4 pattern return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i .test(value); } get value(): string { return this._value; } equals(other: OrderId): boolean { return this._value === other._value; } toString(): string { return this._value; }} // Type safety: can't mix up IDs!function getOrder(orderId: OrderId): Order { ... }function getCustomer(customerId: CustomerId): Customer { ... } const order = getOrder(customerId); // COMPILE ERROR!Database sequences require a round-trip before the entity has identity, complicate testing, don't work in distributed systems, and force you to handle null IDs. UUIDs solve all these problems with negligible performance cost.
Entity construction is more than just calling a constructor. It's the enforcement point for all creation-time invariants and the opportunity to establish valid initial state.
Customer.create()) versus rebuilding from storage (Customer.reconstitute()).customerId, not the whole Customer.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
class Order { private readonly _id: OrderId; private _customerId: CustomerId; private _status: OrderStatus; private _lines: OrderLine[]; private _shippingAddress: Address; private _createdAt: Date; private _totalAmount: Money; // Private constructor - only factory methods can create private constructor( id: OrderId, customerId: CustomerId, shippingAddress: Address, createdAt: Date ) { this._id = id; this._customerId = customerId; this._shippingAddress = shippingAddress; this._createdAt = createdAt; this._status = OrderStatus.CREATED; this._lines = []; this._totalAmount = Money.zero("USD"); } /** * Factory method for creating NEW orders. * - Generates identity * - Sets creation timestamp * - Establishes valid initial state */ static create( customerId: CustomerId, shippingAddress: Address ): Order { // Validation if (!customerId) { throw new ValidationError("Customer ID is required"); } if (!shippingAddress) { throw new ValidationError("Shipping address is required"); } return new Order( OrderId.generate(), // Application generates ID customerId, shippingAddress, new Date() // Timestamp now ); } /** * Factory method for RECONSTITUTING from storage. * - Receives existing identity * - Receives all persisted state * - Does NOT generate IDs or timestamps * - Typically called by repository */ static reconstitute( id: OrderId, customerId: CustomerId, status: OrderStatus, lines: OrderLine[], shippingAddress: Address, totalAmount: Money, createdAt: Date ): Order { const order = new Order(id, customerId, shippingAddress, createdAt); order._status = status; order._lines = lines; order._totalAmount = totalAmount; return order; } // ... behavior methods} // Usage:// Creating a new orderconst order = Order.create( customerId, new Address("123 Main St")); // Repository reconstituting from DBconst order = Order.reconstitute( OrderId.parse(row.id), CustomerId.parse(row.customerId), OrderStatus[row.status], lineEntities, addressFromRow(row), Money.of(row.total, row.currency), new Date(row.createdAt));create() represents a domain action—a new order being placed. It should raise domain events, set timestamps, generate IDs. reconstitute() is just rebuilding existing state from storage—no events, no generation, just restoring what was saved.
Invariants are rules about entity state that must always be true. Examples:
Robust entities enforce their invariants, making it impossible for the entity to exist in an invalid state.
Email value object that validates on construction.List<OrderLine>. Provide addLine() that validates line limits.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
class Order { private _lines: OrderLine[] = []; private _status: OrderStatus = OrderStatus.CREATED; // Invariant: Cannot add lines to shipped/delivered orders // Invariant: Maximum 50 lines per order addLine(product: Product, quantity: number): void { // Check state invariant if (this._status !== OrderStatus.CREATED && this._status !== OrderStatus.PENDING) { throw new InvalidOrderStateError( `Cannot add lines to order in status: ${this._status}` ); } // Check quantity invariant if (quantity <= 0) { throw new ValidationError( "Quantity must be positive" ); } // Check line count invariant if (this._lines.length >= 50) { throw new OrderLineLimitExceededError( "Orders cannot have more than 50 lines" ); } // Invariants satisfied - make the change this._lines.push(new OrderLine(product, quantity)); this.recalculateTotal(); } // Invariant: Only PAID orders can be shipped ship(): void { if (this._status !== OrderStatus.PAID) { throw new InvalidOrderStateError( `Cannot ship order in status: ${this._status}. ` + "Only PAID orders can be shipped." ); } if (this._lines.length === 0) { throw new InvalidOrderStateError( "Cannot ship an order with no items" ); } this._status = OrderStatus.SHIPPED; // Could raise domain event: OrderShipped } // Derived invariant: total always matches sum of lines private recalculateTotal(): void { this._totalAmount = this._lines.reduce( (sum, line) => sum.add(line.subtotal()), Money.zero(this._currency) ); }} // Value Object enforces its own invariantsclass Email { private readonly _value: string; constructor(value: string) { if (!value || !this.isValidFormat(value)) { throw new InvalidEmailError(value); } this._value = value.toLowerCase().trim(); } private isValidFormat(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } get value(): string { return this._value; }} // Customer doesn't validate email format - Email doesclass Customer { private _email: Email; constructor(id: CustomerId, email: Email) { // Just accepts the value object - it's already valid! this._email = email; } changeEmail(newEmail: Email): void { // No format validation needed - Email is already valid this._email = newEmail; }}Even if the UI validates, even if the service layer validates, the entity must validate. Entities are the last line of defense. Other layers might change, be bypassed, or have bugs. The entity's invariants protect data integrity.
Encapsulation protects invariants by controlling how state is accessed and modified. Poor encapsulation leads to entities whose invariants can be violated from outside.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ❌ ANTI-PATTERN: Leaky encapsulation class Order { public lines: OrderLine[] = []; // Public array!} // External code can do anything:order.lines.push(invalidLine);order.lines = []; // Clear all linesorder.lines.length = 0; // Another way to break it // ✅ PATTERN: Proper encapsulation class Order { private readonly _lines: OrderLine[] = []; // Behavior method - only way to add addLine(product: Product, quantity: number): void { this.validateCanAddLine(); this._lines.push(new OrderLine(product, quantity)); this.recalculateTotal(); } removeLine(lineId: OrderLineId): void { const index = this._lines.findIndex( l => l.id.equals(lineId) ); if (index === -1) { throw new LineNotFoundError(lineId); } this._lines.splice(index, 1); this.recalculateTotal(); } // Return immutable view get lines(): readonly OrderLine[] { return Object.freeze([...this._lines]); } // Or return defensive copy getLines(): OrderLine[] { return [...this._lines]; // Copy, not reference } // Query methods for common needs get lineCount(): number { return this._lines.length; } hasProduct(productId: ProductId): boolean { return this._lines.some(l => l.productId.equals(productId)); } getLineSubtotal(lineId: OrderLineId): Money { const line = this._lines.find(l => l.id.equals(lineId)); if (!line) throw new LineNotFoundError(lineId); return line.subtotal; }}Instead of asking for state (order.getStatus()) and then making decisions, tell the entity what you want (order.ship()). Let the entity decide if the operation is valid. This keeps logic in the entity.
A common DDD anti-pattern is the Anemic Domain Model—entities that are mere data containers with getters and setters, while all business logic lives in separate "service" classes.
Rich entities are the opposite: they encapsulate both state and behavior. The entity knows how to validate itself, how to transition between states, and how to perform domain operations.
12345678910111213141516171819202122232425262728293031323334
// ❌ ANEMIC: Entity is just data class Account { id: string; balance: number; status: string; overdraftLimit: number;} // All logic in a serviceclass AccountService { withdraw( account: Account, amount: number ): void { if (account.status !== 'active') { throw new Error("Inactive"); } const available = account.balance + account.overdraftLimit; if (amount > available) { throw new Error("Insufficient"); } account.balance -= amount; } freeze(account: Account): void { account.status = 'frozen'; }}123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ✅ RICH: Entity has behavior class Account { private readonly _id: AccountId; private _balance: Money; private _status: AccountStatus; private _overdraftLimit: Money; // Behavior lives in entity withdraw(amount: Money): void { this.assertActive(); this.assertSufficientFunds(amount); this._balance = this._balance.subtract(amount); // Raise domain event this.raise( new FundsWithdrawn(this._id, amount) ); } freeze(): void { this.assertCanFreeze(); this._status = AccountStatus.FROZEN; this.raise(new AccountFrozen(this._id)); } // Private invariant checks private assertActive(): void { if (this._status !== AccountStatus.ACTIVE) { throw new InactiveAccountError(); } } private assertSufficientFunds( amount: Money ): void { const available = this._balance .add(this._overdraftLimit); if (!available.isAtLeast(amount)) { throw new InsufficientFundsError(); } }}Benefits of behavior-rich entities:
account. reveals all operations.account.freeze() not accountService.updateStatus(account, FROZEN).Some logic genuinely doesn't belong in a single entity: operations involving multiple aggregates, external service calls, or cross-cutting concerns. These belong in Application Services or Domain Services. But single-entity business logic belongs in the entity.
Many entities have a lifecycle with distinct states and allowed transitions. Orders go from Created → Paid → Shipped → Delivered. Accounts go from Pending → Active → Suspended → Closed.
Modeling these as explicit state machines prevents invalid transitions and makes the lifecycle clear:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
enum OrderStatus { CREATED = 'CREATED', PENDING_PAYMENT = 'PENDING_PAYMENT', PAID = 'PAID', PROCESSING = 'PROCESSING', SHIPPED = 'SHIPPED', DELIVERED = 'DELIVERED', CANCELLED = 'CANCELLED'} // Define allowed transitions explicitlyconst ALLOWED_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = { [OrderStatus.CREATED]: [OrderStatus.PENDING_PAYMENT, OrderStatus.CANCELLED], [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELLED], [OrderStatus.PAID]: [OrderStatus.PROCESSING, OrderStatus.CANCELLED], [OrderStatus.PROCESSING]: [OrderStatus.SHIPPED], [OrderStatus.SHIPPED]: [OrderStatus.DELIVERED], [OrderStatus.DELIVERED]: [], // Terminal state [OrderStatus.CANCELLED]: [], // Terminal state}; class Order { private _status: OrderStatus = OrderStatus.CREATED; // State query methods get status(): OrderStatus { return this._status; } canBeCancelled(): boolean { return ALLOWED_TRANSITIONS[this._status] .includes(OrderStatus.CANCELLED); } canBeShipped(): boolean { return ALLOWED_TRANSITIONS[this._status] .includes(OrderStatus.SHIPPED); } // State transition methods submitForPayment(): void { this.transitionTo(OrderStatus.PENDING_PAYMENT); } confirmPayment(transactionId: PaymentTransactionId): void { this.transitionTo(OrderStatus.PAID); this._paymentTransactionId = transactionId; this._paidAt = new Date(); this.raise(new OrderPaid(this._id, transactionId)); } startProcessing(): void { this.transitionTo(OrderStatus.PROCESSING); } ship(trackingNumber: TrackingNumber): void { this.transitionTo(OrderStatus.SHIPPED); this._trackingNumber = trackingNumber; this._shippedAt = new Date(); this.raise(new OrderShipped(this._id, trackingNumber)); } confirmDelivery(): void { this.transitionTo(OrderStatus.DELIVERED); this._deliveredAt = new Date(); this.raise(new OrderDelivered(this._id)); } cancel(reason: CancellationReason): void { this.transitionTo(OrderStatus.CANCELLED); this._cancellationReason = reason; this._cancelledAt = new Date(); this.raise(new OrderCancelled(this._id, reason)); } // Core transition logic private transitionTo(newStatus: OrderStatus): void { const allowedNext = ALLOWED_TRANSITIONS[this._status]; if (!allowedNext.includes(newStatus)) { throw new InvalidOrderTransitionError( this._id, this._status, newStatus, allowedNext ); } this._status = newStatus; }} // Custom error with helpful informationclass InvalidOrderTransitionError extends Error { constructor( public readonly orderId: OrderId, public readonly currentStatus: OrderStatus, public readonly attemptedStatus: OrderStatus, public readonly allowedStatuses: OrderStatus[] ) { super( `Cannot transition order ${orderId} from ` + `${currentStatus} to ${attemptedStatus}. ` + `Allowed transitions: ${allowedStatuses.join(', ') || 'none'}` ); }}Explicit state machines make impossible states actually impossible. The diagram of allowed transitions is documentation that's enforced by code. When someone asks "can a delivered order be cancelled?", you can answer by looking at ALLOWED_TRANSITIONS.
How large should an entity be? What belongs inside versus outside? These questions relate to Aggregate design (covered later), but some preliminary guidelines apply to individual entities:
| Question | Yes → Together | No → Separate |
|---|---|---|
| Do they have shared invariants? | Same entity/aggregate | Different entities |
| Do changes to one affect validity of the other? | Same entity/aggregate | Reference by ID |
| Are they loaded/saved together naturally? | Same entity/aggregate | Separate repositories |
| Would denormalization hurt? | Same entity | Separate entities |
| Do domain experts consider them one thing? | Same entity | Respect domain boundaries |
order.getCustomer().getAddress().getCity() is a code smell. Long navigation chains couple entities tightly, load more data than needed, and make it hard to split systems later. Prefer order.getCustomerId() and load Customer separately when needed.
We've covered comprehensive guidelines for designing production-quality entities. Let's consolidate the key principles:
What's next:
With design guidelines established, we're ready to explore the full entity lifecycle—from creation through modification to archival or deletion. The next page examines how entities are born, evolve, and end their existence in a DDD system.
You now have a comprehensive set of guidelines for designing robust entities. You understand how to handle identity, construction, invariants, encapsulation, behavior, and state transitions. Next, we'll explore the complete entity lifecycle.