Entities & Value Objects: The Core Domain Model for E-Commerce Billing
Entities are tracked by identity over time; Value Objects are equal by structural value. Applying this fundamental DDD distinction to an e-commerce billing domain — Orders, Products, Money, SKUs, and Addresses — produces a domain model that enforces invariants at construction and makes illegal states unrepresentable.
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But before we can talk about Aggregates or Repositories or Domain Events, we need to solve a more fundamental problem: in a billing system where money changes hands, the primitive number is not a valid type for amount, and string is not a valid type for orderId. The distinction between an Entity and a Value Object is the mechanism that makes illegal domain states unrepresentable — at compile time.
This article implements the first Domain layer objects in the billing engine: Order and Customer as Entities, and Money, OrderId, CustomerId, SKU, and Address as Value Objects. Every design decision is derived from two questions: does this concept have identity that persists across time? And is its equality determined by value or by identity?
Architectural Note
Prerequisites: Part 1 (OOP Theory Foundations) for Encapsulation and the DIP basis of domain object design; Part 2 (Clean Architecture Layers) for the layer structure these objects live in. All code in this article lives in src/domain/.
This is the domain model of a real e-commerce billing service at the moment a junior engineer first writes it:
TYPESCRIPT
// ❌ Anti-Pattern: Primitive Obsession — raw primitives for all domain conceptsinterfaceOrderData {
id: string; // What kind of ID? Order? Customer? Product? All the same type.customerId: string; // Could accidentally receive a productId here — TypeScript won't carestatus: string; // 'PENDING', 'SHIPPED', 'xyz', 'null' — any string compilestotalAmount: number; // -500 compiles. 0 compiles. NaN compiles.currency: string; // 'USD', 'MONOPOLY', '' — any string compilesitems: {
productId: string; // Same type as customerId — swappable by accidentquantity: number; // 0 compiles. -3 compiles.unitPrice: number; // Negative compiles. NaN compiles.
}[];
}
// Three months later, in a discount service:functionapplyDiscount(order: OrderData, customerId: string): number {
// Bug: wrong argument passed — customerId used where orderId expected// TypeScript sees string → string: compiles and ships to productionif (order.id === customerId) { /* ... */ }
return order.totalAmount * 0.9;
}
These are not hypothetical bugs. The Stripe Radar fraud detection team documented that cross-field ID confusion is a recurring pattern in billing security incidents — not because engineers are careless, but because the type system offers no resistance.
The solution is Branded Primitives (for Value Objects without complex behavior) and full Value Object classes (for domain concepts with validation logic and operations).
An Entity is a domain object that is tracked by a unique identity over time. Two Order instances with the same id are the same Order, regardless of whether their line items, status, or total have changed.
TYPESCRIPT
// src/domain/shared/Entity.tsexportabstractclassEntity<TIdextends { equals(other: TId): boolean }> {
protectedconstructor(publicreadonlyid: TId) {}
equals(other: Entity<TId>): boolean {
if (!(other instanceofEntity)) returnfalse;
if (other.constructor !== this.constructor) returnfalse; // Same class requiredreturnthis.id.equals(other.id);
}
// Entities are NOT equal by value — two Orders with different IDs are different orders// even if their state (status, items, total) happens to be identical at this moment
}
The generic constraint TId extends { equals(other: TId): boolean } ensures that every Entity uses a typed ID Value Object rather than a raw string. This makes cross-type ID comparison a compile error.
Aggregates (Part 4) extend Entity with domain event collection — the mechanism used to communicate state changes to the rest of the system without direct coupling:
TYPESCRIPT
// src/domain/shared/AggregateRoot.tsimport { Entity } from'./Entity';
import { DomainEvent } from'./DomainEvent';
exportabstractclassAggregateRoot<TIdextends { equals(other: TId): boolean }>
extendsEntity<TId> {
privatereadonly_domainEvents: DomainEvent[] = [];
protectedaddDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event);
}
/** Called by the Use Case after the repository.save() commits */pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents.length = 0; // Clear after pullingreturn events;
}
}
A Value Object has no identity. Two Money('USD', 99.99) instances are completely interchangeable. Equality is determined by the values of all their fields, not by reference or a generated ID.
The immutability contract for Value Objects: Object.freeze() prevents property assignment at runtime. A Money instance, once created, can never have its amount or currency changed. Operations like add() and multiply() return newMoney instances — they do not mutate the receiver. This is not just convention; it is enforced at runtime.
For simple identifier types that need compile-time nominal typing but no validation logic, use TypeScript branded primitives — a pattern that adds zero runtime overhead:
TYPESCRIPT
// src/domain/shared/brand.tsdeclareconst__brand: unique symbol;
typeBrand<T, B extendsstring> = T & { readonly [__brand]: B };
// Branded ID types — each is a distinct type, not assignable to each otherexporttypeOrderId = Brand<string, 'OrderId'>;
exporttypeCustomerId = Brand<string, 'CustomerId'>;
exporttypeProductId = Brand<string, 'ProductId'>;
exporttypeSKU = Brand<string, 'SKU'>;
exporttypeWarehouseId = Brand<string, 'WarehouseId'>;
// Factory functions with validationexportfunctionmakeOrderId(raw: string): OrderId {
if (!raw || raw.trim().length === 0) thrownewDomainError('OrderId cannot be empty');
return raw asOrderId;
}
exportfunctiongenerateOrderId(): OrderId {
return`ord_${crypto.randomUUID()}`asOrderId;
}
Now the cross-ID bug from §1 becomes a compiler error:
TYPESCRIPT
functionapplyDiscount(order: Order, customerId: CustomerId): Money {
// ❌ TypeScript Error: Type 'OrderId' is not assignable to type 'CustomerId'if (order.id === customerId) { /* ... */ }
// This line no longer compiles — the bug is caught before it reaches production
}
The branded primitive is a string at runtime (zero overhead), but a distinct type at compile time. Passing an OrderId where a CustomerId is expected is a type error.
Money is the Value Object that most billing systems get wrong. The common bugs:
Floating-point arithmetic: 0.1 + 0.2 === 0.30000000000000004. In JavaScript, this is literally true. For a billing system, this is a silent precision error.
Currency mixing: adding USD 10.00 and EUR 8.00 and treating the result as a valid sum.
Negative totals: allowing a refund to produce a negative account balance without a guard.
TYPESCRIPT
// src/domain/payment/Money.tsimport { ValueObject } from'../shared/ValueObject';
import { DomainError } from'../shared/DomainError';
exportconstSupportedCurrencies = ['USD', 'EUR', 'GBP', 'INR', 'JPY'] asconst;
exporttypeCurrency = typeofSupportedCurrencies[number];
interfaceMoneyProps {
amountCents: number; // Store as integer cents — eliminates floating-point errorscurrency: Currency;
}
exportclassMoneyextendsValueObject<MoneyProps> {
privateconstructor(props: MoneyProps) {
super(props);
}
staticof(currency: Currency, amount: number): Money {
if (!SupportedCurrencies.includes(currency))
thrownewDomainError(`Unsupported currency: ${currency}`);
if (!Number.isFinite(amount))
thrownewDomainError(`Money amount must be a finite number, got: ${amount}`);
if (amount < 0)
thrownewDomainError(`Money amount cannot be negative: ${amount}`);
// Convert to cents — integer arithmetic eliminates floating-point issuesconst amountCents = Math.round(amount * 100);
returnnewMoney({ amountCents, currency });
}
staticzero(currency: Currency): Money {
returnnewMoney({ amountCents: 0, currency });
}
// ✅ add() returns a new Money — does not mutate either operandadd(other: Money): Money {
this.assertSameCurrency(other);
returnnewMoney({ amountCents: this.props.amountCents + other.props.amountCents, currency: this.props.currency });
}
subtract(other: Money): Money {
this.assertSameCurrency(other);
const result = this.props.amountCents - other.props.amountCents;
if (result < 0) thrownewDomainError('Money subtraction cannot produce a negative result');
returnnewMoney({ amountCents: result, currency: this.props.currency });
}
multiply(factor: number): Money {
if (!Number.isFinite(factor) || factor < 0)
thrownewDomainError(`Money multiplier must be a non-negative finite number: ${factor}`);
returnnewMoney({
amountCents: Math.round(this.props.amountCents * factor),
currency: this.props.currency,
});
}
greaterThan(other: Money): boolean {
this.assertSameCurrency(other);
returnthis.props.amountCents > other.props.amountCents;
}
getcurrency(): Currency { returnthis.props.currency; }
getamountCents(): number { returnthis.props.amountCents; }
getamount(): number { returnthis.props.amountCents / 100; }
toString(): string { return`${this.props.currency}${this.amount.toFixed(2)}`; }
privateassertSameCurrency(other: Money): void {
if (this.props.currency !== other.props.currency)
thrownewDomainError(
`Currency mismatch: cannot operate on ${this.props.currency} and ${other.props.currency}`
);
}
}
Now money.add() with a different currency is a DomainError. It cannot silently produce a wrong number. This is what it means for domain objects to be autonomous state machines — the Money object enforces its own invariants without requiring callers to remember to check.
Performance / Safety Warning
Integer arithmetic for money: never store money as a JavaScript number in decimal form. 0.1 + 0.2 in JavaScript equals 0.30000000000000004. For billing, this is a real precision error at scale. Store amounts as integer cents (amountCents: number) and convert to decimal only for display. Libraries like decimal.js are an alternative for currencies with complex rounding rules.
The pattern is identical across all Value Objects: validate at construction, immutable after construction, equality by structural value, zero identity.
The Order entity is the most complex domain object in the billing engine. It is an Aggregate Root (covered fully in Part 4), meaning it is the sole entry point for all mutations within its consistency boundary. Here we focus on its construction, state machine, and invariant guards.
TYPESCRIPT
// src/domain/order/Order.tsimport { AggregateRoot } from'../shared/AggregateRoot';
import { OrderId, generateOrderId } from'./OrderId';
import { CustomerId } from'../customer/CustomerId';
import { LineItem } from'./LineItem';
import { Money } from'../payment/Money';
import { OrderStatus } from'./OrderStatus';
import { DomainError } from'../shared/DomainError';
import { OrderPlaced } from'./events/OrderPlaced';
import { OrderCancelled } from'./events/OrderCancelled';
import { WarehouseId } from'../fulfillment/WarehouseId';
interfaceOrderSnapshot {
id: OrderId;
customerId: CustomerId;
status: OrderStatus;
items: LineItem[];
shippingAddress: Address | null;
}
exportclassOrderextendsAggregateRoot<OrderId> {
private_customerId: CustomerId;
private_status: OrderStatus;
private_items: LineItem[];
private_shippingAddress: Address | null;
// ✅ Private constructor — the only way to get an Order is through the factoryprivateconstructor(snapshot: OrderSnapshot) {
super(snapshot.id);
this._customerId = snapshot.customerId;
this._status = snapshot.status;
this._items = [...snapshot.items];
this._shippingAddress = snapshot.shippingAddress;
}
// ✅ Static factory — enforces all creation invariants before the object exists in memorystaticcreate(customerId: CustomerId): Order {
if (!customerId) thrownewDomainError('CustomerId is required to create an Order');
returnnewOrder({
id: generateOrderId(),
customerId,
status: OrderStatus.PENDING,
items: [],
shippingAddress: null,
});
}
// ✅ Reconstitute from storage — used by the Repository adapter (Part 13)staticreconstitute(snapshot: OrderSnapshot): Order {
returnnewOrder(snapshot);
}
// ──── Command Methods (mutate state) ────addItem(item: LineItem): void {
if (this._status !== OrderStatus.PENDING)
thrownewDomainError(`Cannot add items to Order ${this.id} — status is ${this._status}`);
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
// Aggregate the quantity rather than creating a duplicate line itemthis.removeItemByProductId(item.productId);
this._items.push(existing.withAdditionalQuantity(item.quantity));
} else {
this._items.push(item);
}
}
removeItem(productId: ProductId): void {
if (this._status !== OrderStatus.PENDING)
thrownewDomainError(`Cannot remove items from Order ${this.id} — status is ${this._status}`);
this.removeItemByProductId(productId);
}
setShippingAddress(address: Address): void {
if (this._status !== OrderStatus.PENDING)
thrownewDomainError(`Cannot set shipping address on Order ${this.id} — already placed`);
this._shippingAddress = address;
}
place(): void {
if (this._status !== OrderStatus.PENDING)
thrownewDomainError(`Order ${this.id} cannot be placed — status is ${this._status}`);
if (this._items.length === 0)
thrownewDomainError(`Order ${this.id} cannot be placed with no line items`);
if (!this._shippingAddress)
thrownewDomainError(`Order ${this.id} cannot be placed without a shipping address`);
this._status = OrderStatus.PLACED;
this.addDomainEvent(newOrderPlaced(this.id, this._customerId, this.total));
}
cancel(reason: string): void {
if (![OrderStatus.PENDING, OrderStatus.PLACED].includes(this._status))
thrownewDomainError(
`Order ${this.id} cannot be cancelled — status is ${this._status}. Only PENDING or PLACED orders can be cancelled.`
);
this._status = OrderStatus.CANCELLED;
this.addDomainEvent(newOrderCancelled(this.id, reason));
}
ship(warehouseId: WarehouseId): void {
if (this._status !== OrderStatus.PAID)
thrownewDomainError(
`Order ${this.id} cannot be shipped — status is ${this._status}, expected PAID`
);
this._status = OrderStatus.SHIPPED;
this.addDomainEvent(newOrderShipped(this.id, warehouseId));
}
// ──── Query Methods (read state) ────getcustomerId(): CustomerId { returnthis._customerId; }
getstatus(): OrderStatus { returnthis._status; }
getitems(): ReadonlyArray<LineItem> { returnthis._items; }
getshippingAddress(): Address | null { returnthis._shippingAddress; }
gettotal(): Money {
returnthis._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero('USD'), // Default — will be overridden by real currency in Part 3
);
}
privateremoveItemByProductId(productId: ProductId): void {
const idx = this._items.findIndex(i => i.productId === productId);
if (idx === -1) thrownewDomainError(`Item with productId ${productId} not found in Order ${this.id}`);
this._items.splice(idx, 1);
}
}
The Order status transitions form a valid finite state machine:
Every place(), cancel(), ship() method checks the current status before proceeding. An attempt to call ship() on a PLACED (not yet PAID) order throws DomainError. The state machine is embedded in the methods — not in a separate state machine library.
LineItem is an Entity (tracked by LineItemId) because the same product can appear twice in an order with different prices (e.g., one at full price, one with an applied coupon). Two LineItem instances with the same productId but different unitPrice are still different LineItems.
A common and costly bug in billing systems is comparing domain objects by reference (===) when structural equality is needed, or comparing by value when identity equality is needed.
TYPESCRIPT
// ❌ Bug: Comparing Entities by value (should compare by ID)const orderA = Order.create(customerId);
const orderB = Order.reconstitute({ id: orderA.id, /* same state */ });
console.log(orderA === orderB); // false — different object referencesconsole.log(orderA.equals(orderB)); // true — same OrderId ✅// ❌ Bug: Comparing Value Objects by reference (should compare structurally)const priceA = Money.of('USD', 99.99);
const priceB = Money.of('USD', 99.99);
console.log(priceA === priceB); // false — different object references ❌console.log(priceA.equals(priceB)); // true — same amount and currency ✅// ✅ Using the correct equality methods in a discount eligibility check:const hasDiscountAmount = order.total.greaterThan(Money.of('USD', 100));
const isPreferredCustomer = order.customerId.equals(preferredCustomer.id);
In a subscription renewal system, getting this wrong means the same payment can be processed twice if the system uses reference equality to deduplicate processing. The equals() methods on both Entity and ValueObject are the guardrails.
In Part 4, we group related Entities into Aggregate boundaries and introduce the Repository pattern — making the domain layer completely agnostic of whether persistence is PostgreSQL, MongoDB, or an in-memory Map. Part 4: Aggregates & Repositories →
Research & Synthesis Note
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.