Domain-Driven Design (DDD) is an approach to software development that focuses on creating a rich, expressive model of the business domain. Instead of treating your core business logic as a thin layer on top of a database, DDD puts the domain at the very heart of your application.

At the core of DDD are three fundamental building blocks that help you create this model: Entities, Value Objects, and Aggregates. Understanding the difference between them is the first step toward writing cleaner, more maintainable, and business-focused code.

1. Value Objects

A Value Object is an object defined by its attributes, not by a unique identity. Two Value Objects are considered equal if all their attributes are equal.

Key characteristics of a Value Object:

  • No Identity: It doesn’t have a unique ID. The Street “123 Main St” is the same as any other Street “123 Main St”.
  • Immutability: Once created, a Value Object should not be changed. If you need a different value, you create a new instance. This makes them safe to pass around.
  • Represents a Concept: They are not just primitive types (strings, numbers). They represent a conceptual whole from your domain, like Money, DateRange, or Color.
  • Self-Validation: A Value Object should be responsible for validating its own invariants. You should not be able to create an invalid EmailAddress object.

Python Example: A Money Value Object

# Before: Using primitive types
def calculate_price(price: float, currency: str):
    if currency != "USD":
        raise ValueError("Only USD is supported")
    # ... logic ...

# This is dangerous! What if someone passes them in the wrong order?
# calculate_price("USD", 10.99) -> Fails at runtime

# After: Using a Money Value Object
from dataclasses import dataclass

@dataclass(frozen=True) # frozen=True makes it immutable
class Money:
    amount: int  # Store money in cents to avoid floating point issues
    currency: str

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Amount cannot be negative.")
        if len(self.currency) != 3:
            raise ValueError("Currency must be a 3-letter code.")

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies.")
        return Money(self.amount + other.amount, self.currency)

# The method signature is now much clearer and safer
def calculate_price(price: Money):
    # ... logic ...

price1 = Money(1099, "USD")
price2 = Money(550, "USD")
total = price1.add(price2) # total is a new Money object

Using the Money Value Object makes the code more expressive and prevents a whole class of bugs.

2. Entities

An Entity is an object that is defined by its unique identity and continuity, not by its attributes. Two entities are distinct even if they have the same attributes, because they have different IDs. An entity’s attributes can change over its lifetime.

Key characteristics of an Entity:

  • Has a Unique Identity: This is its defining characteristic. The ID is constant throughout the entity’s lifecycle. User with ID 123 is a specific person, even if they change their name.
  • Mutable: An entity’s state and attributes can change over time. A Product entity’s price can be updated. An Order entity’s status can change from Pending to Shipped.
  • Has a Lifecycle: Entities are created, updated, and eventually deleted. They have a history.

TypeScript Example: An Order Entity

// Value Object for Order Items
class OrderItem {
  constructor(
    readonly productId: string,
    public quantity: number,
    public price: number
  ) {}
}

// Order Entity
class Order {
  // The unique identity
  readonly id: string;
  private status: 'Pending' | 'Confirmed' | 'Shipped';
  private items: OrderItem[] = [];

  constructor(id: string) {
    this.id = id;
    this.status = 'Pending';
  }

  public addItem(productId: string, quantity: number, price: number): void {
    if (this.status !== 'Pending') {
      throw new Error("Can only add items to a pending order.");
    }
    this.items.push(new OrderItem(productId, quantity, price));
  }

  public confirm(): void {
    if (this.status !== 'Pending' || this.items.length === 0) {
      throw new Error("Cannot confirm an empty or non-pending order.");
    }
    this.status = 'Confirmed';
    // Logic to maybe dispatch an event, etc.
  }

  public ship(): void {
    if (this.status !== 'Confirmed') {
      throw new Error("Can only ship a confirmed order.");
    }
    this.status = 'Shipped';
  }

  // Getters to expose state without allowing direct modification
  public getStatus(): string {
    return this.status;
  }
}

const order1 = new Order("order-123");
const order2 = new Order("order-456");

// Even if they had the same data, they are different orders
// because their IDs are different.

order1.addItem("prod-abc", 2, 9.99);
order1.confirm();
order1.ship(); // The state of order1 changes over time.

3. Aggregates

An Aggregate is a cluster of associated objects (Entities and Value Objects) that are treated as a single unit for the purpose of data changes.

  • Aggregate Root: Each Aggregate has a single entry point, called the Aggregate Root. This is an Entity, and it’s the only object that external clients are allowed to hold a reference to.
  • Consistency Boundary: The Aggregate is a transactional consistency boundary. Any business rule (invariant) that spans multiple objects within the aggregate must be enforced every time the aggregate is changed. All objects inside the aggregate are saved or deleted together.

The Order in the example above is a perfect Aggregate Root. The Order entity, along with its list of OrderItem objects, forms the Order Aggregate.

Rules for Aggregates

  1. Access is only through the Root: You would never fetch an OrderItem directly from the database. You would fetch the Order (the Aggregate Root) and then navigate to its items. order.getItems().
  2. External objects can only hold references to the Root. This prevents someone from modifying an OrderItem without going through the Order, which might bypass important validation logic (like checking if the order is already shipped).
  3. One transaction per Aggregate: All changes to an aggregate should be saved within a single transaction.

Example: The Order as an Aggregate

  • Aggregate Root: Order
  • Internal Objects: OrderItem (which could be an Entity or a Value Object depending on the domain).
  • Invariant: The total price of the Order must always equal the sum of the prices of its OrderItems. Another invariant: you cannot add an item to a shipped Order.

By making Order the Aggregate Root, we ensure these rules are always enforced. Any change to an OrderItem has to go through a method on the Order class, which can perform the necessary validation.

// Correct way: Go through the Aggregate Root
const order = orderRepository.findById("order-123");
order.addItem("prod-def", 1, 19.99);
orderRepository.save(order); // Saves the whole aggregate

// Incorrect way: Bypassing the Aggregate Root
// This should not be possible. You should not have an OrderItemRepository.
const item = orderItemRepository.findByOrderIdAndProductId("order-123", "prod-abc");
item.quantity = 100; // This might break business rules!
orderItemRepository.save(item);

Conclusion

  • Value Objects: Use for descriptive attributes of a thing, where identity doesn’t matter. Make them immutable.
  • Entities: Use for objects that have a unique identity and a lifecycle of state changes.
  • Aggregates: Use to group Entities and Value Objects that need to be consistent with each other. Define a clear boundary and a single entry point (the Root) to enforce your business rules.

By thinking in terms of these building blocks, you can create a domain model that is a much more powerful and accurate representation of your business, leading to cleaner, more robust software.