When identity matters more than state
Domain-driven design teaches us that some objects in our system are fundamentally defined by their identity, while others exist only to describe properties. This distinction—between entities and aggregates—is one of the most practical patterns you'll implement in production systems. In this article, we'll explore entities and aggregates: real-world lessons and examples that show why this matters for consistency, performance, and maintainability.
We've learned this the hard way: building systems without clear entity and aggregate boundaries leads to subtle bugs, race conditions, and models that become harder to reason about as they grow. Let's walk through the principles and see how they apply to problems you'll encounter today.
Entities have identity — An Order is the same order even when its fields change
An entity is an object defined primarily by its identity rather than its attributes. Think about an Order in an e-commerce system: whether the status changes from pending to shipped, or the customer's shipping address is updated, it's still the same Order. The Order ID is its anchor; everything else is state that can evolve.
In contrast, a value object—like a Money amount or a shipping Address—has no identity. Two Money objects with the same currency and amount are interchangeable. If you copy a Money object, you have an equivalent replacement. But if you copy an Order, you've created a duplicate that violates your business rules.
The practical consequence: entity equality should be based on identity, not field comparison.
// WRONG: Comparing by fields
class Order {
constructor(
public customerId: string,
public total: number,
public status: string
) {}
equals(other: Order): boolean {
return this.customerId === other.customerId &&
this.total === other.total &&
this.status === other.status;
}
}
const order1 = new Order("cust-123", 99.99, "pending");
const order2 = new Order("cust-123", 99.99, "pending");
console.log(order1.equals(order2)); // true — but they're different orders!
// CORRECT: Comparing by identity
class Order {
constructor(
public id: string,
public customerId: string,
public total: number,
public status: string
) {}
equals(other: Order): boolean {
return this.id === other.id;
}
}
const order1 = new Order("order-456", "cust-123", 99.99, "pending");
const order2 = new Order("order-789", "cust-123", 99.99, "pending");
console.log(order1.equals(order2)); // false — different ordersThis distinction becomes critical when you're loading entities from a database, caching them, or passing them between services. An entity's ID never changes; it's the stable reference that ties together the entire lifecycle of that object.
Aggregates guard invariants — Order with its line items, changed only through the root
An aggregate is a cluster of entities and value objects bound together to enforce a business invariant. The aggregate root is the only entity that external code may hold a reference to. All changes to the aggregate must go through the root, ensuring the invariant is always satisfied.
Consider an Order aggregate: it contains the Order entity (the root) and a collection of LineItem entities. The business invariant might be: "An order cannot be shipped if it has no line items" or "The order total must equal the sum of line item prices." By forcing all modifications through the Order root, we guarantee this invariant is checked every time.
// The OrderLineItem is part of the aggregate but not exposed directly
class OrderLineItem {
constructor(
public productId: string,
public quantity: number,
public price: number
) {
if (quantity <= 0) {
throw new Error("Quantity must be positive");
}
}
total(): number {
return this.quantity * this.price;
}
}
// Order is the aggregate root
class Order {
private lineItems: OrderLineItem[] = [];
constructor(
public id: string,
public customerId: string,
private status: "pending" | "shipped" | "cancelled" = "pending"
) {}
// Only way to add items to the order
addLineItem(productId: string, quantity: number, price: number): void {
if (this.status === "shipped") {
throw new Error("Cannot add items to a shipped order");
}
this.lineItems.push(new OrderLineItem(productId, quantity, price));
}
// Only way to ship the order
ship(): void {
if (this.lineItems.length === 0) {
throw new Error("Cannot ship an order with no items");
}
this.status = "shipped";
}
// Expose line items as read-only
getLineItems(): ReadonlyArray {
return [...this.lineItems];
}
getTotal(): number {
return this.lineItems.reduce((sum, item) => sum + item.total(), 0);
}
}
// Usage — all invariants are maintained
const order = new Order("order-001", "cust-123");
order.addLineItem("prod-A", 2, 50);
order.addLineItem("prod-B", 1, 25);
console.log(order.getTotal()); // 125
order.ship();
// These invariant violations are caught
try {
const invalidOrder = new Order("order-002", "cust-456");
invalidOrder.ship(); // Throws: "Cannot ship an order with no items"
} catch (e) {
console.log(e.message);
}Notice that external code never constructs OrderLineItem directly; it goes through the Order's addLineItem method. We also return a read-only copy of line items so callers can't mutate them outside the aggregate. This is how we protect our invariants.
Small aggregates — Large aggregates cause contention and slow loads
A common mistake is to make aggregates too large. New practitioners often think "an Order has a Customer, which has an Address, which has..." and end up pulling the entire object graph into a single aggregate. This causes serious performance and concurrency problems.
Imagine you have a large Customer aggregate that includes order history, wishlist items, payment methods, and profile data. Every time you need to update a payment method, you load and lock the entire Customer aggregate, blocking other requests. In a high-traffic system, this becomes a bottleneck. Additionally, loading the entire aggregate for a simple query is wasteful.
The rule: keep aggregates small and focused on a single business invariant.
// TOO LARGE: Customer as a mega-aggregate
class Customer {
id: string;
name: string;
email: string;
addresses: Address[]; // Why?
orders: Order[]; // Why? Orders can be queried separately
wishlist: WishlistItem[]; // Belongs in a separate aggregate
paymentMethods: PaymentMethod[]; // Should be separate
loyaltyPoints: number;
// ... 50 more fields
}
// BETTER: Small, focused aggregates
class Customer {
constructor(
public id: string,
public name: string,
public email: string,
private primaryAddressId: string, // Reference by ID only
private loyaltyAccountId: string // Reference by ID only
) {}
getPrimaryAddressId(): string {
return this.primaryAddressId;
}
getLoyaltyAccountId(): string {
return this.loyaltyAccountId;
}
}
// Separate aggregates, loaded independently
class Address {
constructor(
public id: string,
public customerId: string,
public street: string,
public city: string
) {}
}
class LoyaltyAccount {
constructor(
public id: string,
public customerId: string,
public points: number
) {}
addPoints(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.points += amount;
}
}
// Now you can load and update Customer, Address, and LoyaltyAccount independently
// No contention, faster queries, clearer responsibilityIn a real system, you'd query separately: fetch the Customer, then fetch the Address by the ID you received, then fetch the LoyaltyAccount. This is slightly more code, but it eliminates lock contention, allows parallel loading, and keeps each aggregate focused on its core invariant.
Reference by ID — Aggregates refer to other aggregates by ID, not object references
This is the principle that enforces aggregate boundaries. When one aggregate needs to reference another, it should store only the ID, never a direct object reference. This prevents accidental coupling and ensures aggregates can be loaded, cached, and persisted independently.
If Order holds a direct reference to the Customer object, several problems arise: loading an Order forces you to load a Customer; you can't serialize an Order to the database without also deciding what to do with the Customer; testing becomes harder because you need to construct entire object graphs. Storing just the customerId solves all of this.
// WRONG: Order holds a reference to Customer
class Order {
constructor(
public id: string,
public customer: Customer, // Object reference — creates coupling
public lineItems: LineItem[]
) {}
}
// CORRECT: Order references Customer by ID
class Order {
constructor(
public id: string,
public customerId: string, // ID only — loosely coupled
public lineItems: LineItem[]
) {}
}
// When you need customer details, fetch separately
async function getOrderWithCustomer(orderId: string, db: Database) {
const order = await db.orders.findById(orderId);
const customer = await db.customers.findById(order.customerId);
return { order, customer };
}
// In a CQRS pattern, you might denormalize this into a read model
class OrderWithCustomerReadModel {
constructor(
public orderId: string,
public customerId: string,
public customerName: string, // Denormalized
public lineItems: LineItem[]
) {}
}By referencing only the ID, you also gain flexibility in how you load related data. You can use eager loading, lazy loading, or denormalized read models—the aggregate boundary doesn't prevent any of these strategies. In fact, it enables them by keeping aggregates independent.
Common mistakes
- Confusing entities with value objects: A User is an entity (has identity); an Email or PhoneNumber is typically a value object. Be clear about which is which, and design equality checks accordingly.
- Making aggregates too large: "It's all related" is not a reason to put everything in one aggregate. Keep aggregates focused on a single invariant and reference others by ID.
- Exposing mutable collections: If you return a mutable list of line items from
getLineItems(), external code can mutate it without going through your invariant checks. Always return read-only copies or use defensive copying. - Loading the entire object graph: Many ORMs encourage eager loading to "simplify queries," but this defeats the purpose of aggregate boundaries. Load the root, then fetch related aggregates on demand.
- Storing object references across boundaries: Even if your language allows it, don't hold a reference to another aggregate root. Store the ID and fetch when needed. This keeps your code testable and deployable independently.
Key takeaways
- Entities are defined by identity, not attributes. Use ID equality for entities; field equality for value objects.
- An aggregate root is the only object that external code should reference. All modifications flow through it, guaranteeing invariants.
- Keep aggregates small and focused. Large aggregates cause contention, slow loads, and tight coupling.
- Reference other aggregates by ID, not by object reference. This enforces loose coupling and enables flexible loading strategies.
- Protect invariants by controlling how aggregates are modified: no direct mutation of internal collections, no bypassing the root entity.






Comments
Loading comments…