Siddhant Deval
Siddhant Deval
backend5 min read

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.

Entities & Value Objects: The Core Domain Model for E-Commerce Billing

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/.


1. The Primitive Obsession Problem

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 concepts
interface OrderData {
  id: string;           // What kind of ID? Order? Customer? Product? All the same type.
  customerId: string;   // Could accidentally receive a productId here — TypeScript won't care
  status: string;       // 'PENDING', 'SHIPPED', 'xyz', 'null' — any string compiles
  totalAmount: number;  // -500 compiles. 0 compiles. NaN compiles.
  currency: string;     // 'USD', 'MONOPOLY', '' — any string compiles
  items: {
    productId: string;  // Same type as customerId — swappable by accident
    quantity: number;   // 0 compiles. -3 compiles.
    unitPrice: number;  // Negative compiles. NaN compiles.
  }[];
}

// Three months later, in a discount service:
function applyDiscount(order: OrderData, customerId: string): number {
  // Bug: wrong argument passed — customerId used where orderId expected
  // TypeScript sees string → string: compiles and ships to production
  if (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).


2. The Foundational Base Classes

2.1 Entity<T>: Identity Over Time

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.ts
export abstract class Entity<TId extends { equals(other: TId): boolean }> {
  protected constructor(public readonly id: TId) {}

  equals(other: Entity<TId>): boolean {
    if (!(other instanceof Entity)) return false;
    if (other.constructor !== this.constructor) return false; // Same class required
    return this.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.

2.2 AggregateRoot<T>: The Gateway + Event Collector

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.ts
import { Entity } from './Entity';
import { DomainEvent } from './DomainEvent';

export abstract class AggregateRoot<TId extends { equals(other: TId): boolean }>
  extends Entity<TId> {

  private readonly _domainEvents: DomainEvent[] = [];

  protected addDomainEvent(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 pulling
    return events;
  }
}

2.3 ValueObject<T>: Equality by Structural Value

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.

TYPESCRIPT
// src/domain/shared/ValueObject.ts
export abstract class ValueObject<T extends Record<string, unknown>> {
  protected readonly props: Readonly<T>;

  protected constructor(props: T) {
    this.props = Object.freeze({ ...props }); // Immutable — Object.freeze enforces at runtime
  }

  equals(other: ValueObject<T>): boolean {
    if (other === null || other === undefined) return false;
    if (other.constructor !== this.constructor) return false;
    return JSON.stringify(this.props) === JSON.stringify(other.props);
  }
}
Crucial Requirement

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 new Money instances — they do not mutate the receiver. This is not just convention; it is enforced at runtime.


3. Value Objects: Removing Primitive Obsession

3.1 Branded ID Primitives: Compile-Time Type Safety at Zero Runtime Cost

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.ts
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };

// Branded ID types — each is a distinct type, not assignable to each other
export type OrderId   = Brand<string, 'OrderId'>;
export type CustomerId = Brand<string, 'CustomerId'>;
export type ProductId  = Brand<string, 'ProductId'>;
export type SKU        = Brand<string, 'SKU'>;
export type WarehouseId = Brand<string, 'WarehouseId'>;

// Factory functions with validation
export function makeOrderId(raw: string): OrderId {
  if (!raw || raw.trim().length === 0) throw new DomainError('OrderId cannot be empty');
  return raw as OrderId;
}

export function generateOrderId(): OrderId {
  return `ord_${crypto.randomUUID()}` as OrderId;
}

Now the cross-ID bug from §1 becomes a compiler error:

TYPESCRIPT
function applyDiscount(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.

3.2 Money: The Most Critical Value Object in Any Billing System

Money is the Value Object that most billing systems get wrong. The common bugs:

  1. 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.
  2. Currency mixing: adding USD 10.00 and EUR 8.00 and treating the result as a valid sum.
  3. Negative totals: allowing a refund to produce a negative account balance without a guard.
TYPESCRIPT
// src/domain/payment/Money.ts
import { ValueObject } from '../shared/ValueObject';
import { DomainError } from '../shared/DomainError';

export const SupportedCurrencies = ['USD', 'EUR', 'GBP', 'INR', 'JPY'] as const;
export type Currency = typeof SupportedCurrencies[number];

interface MoneyProps {
  amountCents: number; // Store as integer cents — eliminates floating-point errors
  currency: Currency;
}

export class Money extends ValueObject<MoneyProps> {
  private constructor(props: MoneyProps) {
    super(props);
  }

  static of(currency: Currency, amount: number): Money {
    if (!SupportedCurrencies.includes(currency))
      throw new DomainError(`Unsupported currency: ${currency}`);
    if (!Number.isFinite(amount))
      throw new DomainError(`Money amount must be a finite number, got: ${amount}`);
    if (amount < 0)
      throw new DomainError(`Money amount cannot be negative: ${amount}`);

    // Convert to cents — integer arithmetic eliminates floating-point issues
    const amountCents = Math.round(amount * 100);
    return new Money({ amountCents, currency });
  }

  static zero(currency: Currency): Money {
    return new Money({ amountCents: 0, currency });
  }

  // ✅ add() returns a new Money — does not mutate either operand
  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money({ 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) throw new DomainError('Money subtraction cannot produce a negative result');
    return new Money({ amountCents: result, currency: this.props.currency });
  }

  multiply(factor: number): Money {
    if (!Number.isFinite(factor) || factor < 0)
      throw new DomainError(`Money multiplier must be a non-negative finite number: ${factor}`);
    return new Money({
      amountCents: Math.round(this.props.amountCents * factor),
      currency: this.props.currency,
    });
  }

  greaterThan(other: Money): boolean {
    this.assertSameCurrency(other);
    return this.props.amountCents > other.props.amountCents;
  }

  get currency(): Currency { return this.props.currency; }
  get amountCents(): number { return this.props.amountCents; }
  get amount(): number { return this.props.amountCents / 100; }

  toString(): string { return `${this.props.currency} ${this.amount.toFixed(2)}`; }

  private assertSameCurrency(other: Money): void {
    if (this.props.currency !== other.props.currency)
      throw new DomainError(
        `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.

3.3 Address and EmailAddress Value Objects

TYPESCRIPT
// src/domain/customer/Address.ts
interface AddressProps {
  street: string;
  city: string;
  state: string;
  postalCode: string;
  countryCode: string; // ISO 3166-1 alpha-2
}

export class Address extends ValueObject<AddressProps> {
  static create(props: AddressProps): Address {
    if (!props.street.trim()) throw new DomainError('Street is required');
    if (!props.city.trim()) throw new DomainError('City is required');
    if (!/^[A-Z]{2}$/.test(props.countryCode))
      throw new DomainError(`Invalid ISO country code: ${props.countryCode}`);
    return new Address(props);
  }

  get street()      { return this.props.street; }
  get city()        { return this.props.city; }
  get state()       { return this.props.state; }
  get postalCode()  { return this.props.postalCode; }
  get countryCode() { return this.props.countryCode; }
}
TYPESCRIPT
// src/domain/customer/EmailAddress.ts
export class EmailAddress extends ValueObject<{ value: string }> {
  private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  static create(raw: string): EmailAddress {
    if (!EmailAddress.EMAIL_REGEX.test(raw))
      throw new DomainError(`Invalid email address: ${raw}`);
    return new EmailAddress({ value: raw.toLowerCase().trim() });
  }

  get value(): string { return this.props.value; }
  toString(): string  { return this.props.value; }
}

The pattern is identical across all Value Objects: validate at construction, immutable after construction, equality by structural value, zero identity.


4. The Order Entity

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.ts
import { 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';

interface OrderSnapshot {
  id: OrderId;
  customerId: CustomerId;
  status: OrderStatus;
  items: LineItem[];
  shippingAddress: Address | null;
}

export class Order extends AggregateRoot<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 factory
  private constructor(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 memory
  static create(customerId: CustomerId): Order {
    if (!customerId) throw new DomainError('CustomerId is required to create an Order');
    return new Order({
      id: generateOrderId(),
      customerId,
      status: OrderStatus.PENDING,
      items: [],
      shippingAddress: null,
    });
  }

  // ✅ Reconstitute from storage — used by the Repository adapter (Part 13)
  static reconstitute(snapshot: OrderSnapshot): Order {
    return new Order(snapshot);
  }

  // ──── Command Methods (mutate state) ────

  addItem(item: LineItem): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`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 item
      this.removeItemByProductId(item.productId);
      this._items.push(existing.withAdditionalQuantity(item.quantity));
    } else {
      this._items.push(item);
    }
  }

  removeItem(productId: ProductId): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Cannot remove items from Order ${this.id} — status is ${this._status}`);
    this.removeItemByProductId(productId);
  }

  setShippingAddress(address: Address): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Cannot set shipping address on Order ${this.id} — already placed`);
    this._shippingAddress = address;
  }

  place(): void {
    if (this._status !== OrderStatus.PENDING)
      throw new DomainError(`Order ${this.id} cannot be placed — status is ${this._status}`);
    if (this._items.length === 0)
      throw new DomainError(`Order ${this.id} cannot be placed with no line items`);
    if (!this._shippingAddress)
      throw new DomainError(`Order ${this.id} cannot be placed without a shipping address`);

    this._status = OrderStatus.PLACED;
    this.addDomainEvent(new OrderPlaced(this.id, this._customerId, this.total));
  }

  cancel(reason: string): void {
    if (![OrderStatus.PENDING, OrderStatus.PLACED].includes(this._status))
      throw new DomainError(
        `Order ${this.id} cannot be cancelled — status is ${this._status}. Only PENDING or PLACED orders can be cancelled.`
      );
    this._status = OrderStatus.CANCELLED;
    this.addDomainEvent(new OrderCancelled(this.id, reason));
  }

  ship(warehouseId: WarehouseId): void {
    if (this._status !== OrderStatus.PAID)
      throw new DomainError(
        `Order ${this.id} cannot be shipped — status is ${this._status}, expected PAID`
      );
    this._status = OrderStatus.SHIPPED;
    this.addDomainEvent(new OrderShipped(this.id, warehouseId));
  }

  // ──── Query Methods (read state) ────

  get customerId(): CustomerId     { return this._customerId; }
  get status(): OrderStatus        { return this._status; }
  get items(): ReadonlyArray<LineItem> { return this._items; }
  get shippingAddress(): Address | null { return this._shippingAddress; }

  get total(): Money {
    return this._items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.zero('USD'), // Default — will be overridden by real currency in Part 3
    );
  }

  private removeItemByProductId(productId: ProductId): void {
    const idx = this._items.findIndex(i => i.productId === productId);
    if (idx === -1) throw new DomainError(`Item with productId ${productId} not found in Order ${this.id}`);
    this._items.splice(idx, 1);
  }
}

4.1 The State Machine

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.

4.2 The LineItem Entity

TYPESCRIPT
// src/domain/order/LineItem.ts
import { Entity } from '../shared/Entity';
import { LineItemId, generateLineItemId } from './LineItemId';
import { ProductId } from '../product/ProductId';
import { Money } from '../payment/Money';
import { DomainError } from '../shared/DomainError';

interface LineItemSnapshot {
  id: LineItemId;
  productId: ProductId;
  quantity: number;
  unitPrice: Money;
}

export class LineItem extends Entity<LineItemId> {
  private _productId: ProductId;
  private _quantity: number;
  private _unitPrice: Money;

  private constructor(snapshot: LineItemSnapshot) {
    super(snapshot.id);
    this._productId = snapshot.productId;
    this._quantity = snapshot.quantity;
    this._unitPrice = snapshot.unitPrice;
  }

  static create(productId: ProductId, quantity: number, unitPrice: Money): LineItem {
    if (quantity <= 0 || !Number.isInteger(quantity))
      throw new DomainError(`LineItem quantity must be a positive integer, got: ${quantity}`);
    if (unitPrice.amountCents <= 0)
      throw new DomainError(`LineItem unit price must be positive, got: ${unitPrice}`);
    return new LineItem({ id: generateLineItemId(), productId, quantity, unitPrice });
  }

  withAdditionalQuantity(additionalQty: number): LineItem {
    return LineItem.create(this._productId, this._quantity + additionalQty, this._unitPrice);
  }

  get productId():   ProductId { return this._productId; }
  get quantity():    number    { return this._quantity; }
  get unitPrice():   Money     { return this._unitPrice; }
  get subtotal():    Money     { return this._unitPrice.multiply(this._quantity); }
}

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.


5. Domain Object Equality: Why It Matters in Billing

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 references
console.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.


Summary

Concept Domain Rule
Entity Tracked by ID across time; two Entities with the same ID are the same domain object regardless of current state
Value Object No identity; structural equality via .equals(); strictly immutable after construction
Branded Primitives OrderId, CustomerId, ProductId are distinct compile-time types that prevent cross-assignment; zero runtime cost
Money Stored as integer cents to eliminate floating-point errors; operations return new instances; currency mismatch is a DomainError
Constructor Guards Throw DomainError at construction — an object in memory is always in a valid state
reconstitute() A secondary static factory for loading from storage that bypasses creation-only invariants

What's Next

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.

#Domain-Driven Design#Entities#Value Objects#TypeScript#Node.js#OOP#E-Commerce
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.