Skip to content
ioob.dev
Go back

DDD Part 4 — Entity and Value Object

· 8 min read
DDD Series (4/7)
  1. DDD Part 1 — Why Domain-Centric Design Matters
  2. DDD Part 2 — Ubiquitous Language and Bounded Context
  3. DDD Part 3 — Context Mapping
  4. DDD Part 4 — Entity and Value Object
  5. DDD Part 5 — Aggregate and Repository
  6. DDD Part 6 — Domain Service and Application Service
  7. DDD Part 7 — Domain Event and Anti-Corruption Layer
Table of contents

Table of contents

Two Ten-Thousand-Won Bills

You have two ten-thousand-won bills in your wallet. One is change from a convenience store, and the other came from an ATM. Are these two bills the same or different?

In everyday life, they’re the same. You can pay for a cup of coffee with either bill. You don’t care about the origin or serial number. What matters is the value of ten thousand won itself.

But from a bank’s perspective, they’re different. Each bill has a unique serial number, and that number is crucial when tracking counterfeits. Even though both are ten thousand won, different serial numbers mean different bills.

The distinction between DDD’s core building blocks — Entity and Value Object — is precisely this difference. Whether you distinguish by identifier (identity) or compare by value.

flowchart LR
    subgraph entity["Entity"]
        E1["Order A<br/>id = 1001<br/>amount: 50,000 won"]
        E2["Order B<br/>id = 1002<br/>amount: 50,000 won"]
        E1 -.-|"Different orders<br/>even with same amount"| E2
    end

    subgraph vo["Value Object"]
        V1["Money<br/>50,000 won"]
        V2["Money<br/>50,000 won"]
        V1 -.-|"Same if<br/>values match"| V2
    end

Entity — Objects Distinguished by Identifier

An Entity is an object with a unique identifier (identity), where sameness is determined by that identifier. Even if all attributes change, if the identifier is the same, it’s the same entity. Even if all attributes are identical, if the identifier differs, they are different entities.

Think of people and it becomes intuitive. Even after changing your name, moving to a new address, or altering your appearance, if the social security number is the same, you’re the same person. Conversely, two people with the same name and birthday who happen to be namesakes are still different people.

Let’s look at an Order entity in code.

// Entity: distinguished by identifier
class Order(
    val id: OrderId,                        // Identifier — sameness is judged by this
    val customerId: CustomerId,
    private var status: OrderStatus,
    private val lines: MutableList<OrderLine>,
    private var shippingAddress: Address,
) {
    fun changeShippingAddress(newAddress: Address) {
        require(status == OrderStatus.DRAFT) { "Shipping address can only be changed in draft status" }
        shippingAddress = newAddress
    }

    fun confirm() {
        require(lines.isNotEmpty()) { "Cannot confirm an order with no items" }
        status = OrderStatus.CONFIRMED
    }

    // Entity equality: compared by identifier only
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Order) return false
        return id == other.id
    }

    override fun hashCode(): Int = id.hashCode()
}

@JvmInline
value class OrderId(val value: Long)

Looking at equals(), you can see it only compares id. Even if the shipping address changes or the status changes, as long as id is the same, it’s the same order. This is the essence of an entity.

Here’s a summary of the key characteristics of entities.

They have an identifier. It could be a database PK (Primary Key), a UUID, or a number generated by domain rules. What matters is that it’s unique.

They have a lifecycle. Entities are created, modified, and sometimes deleted. An order has a lifecycle of draft -> confirmed -> paid -> shipped -> completed. Business rules apply at each state transition.

They are mutable. An entity’s attributes can change over time. Of course, you don’t change any attribute at will — changes are controlled according to domain rules. The key is that state changes only happen through methods like changeShippingAddress().

Value Object — Objects Compared Solely by Value

A Value Object (VO) is an object without an identifier, where equality is determined by the combination of its attribute values. If all attributes are the same, they’re equal; if even one differs, they’re different.

Money is a classic example. 50,000 won and 50,000 won are the same. Regardless of how they were created, if the values match, it’s the same money.

// Value Object: compared by values only
data class Money(
    val amount: BigDecimal,
    val currency: Currency = Currency.KRW,
) {
    init {
        require(amount >= BigDecimal.ZERO) { "Amount must be zero or greater" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Cannot add different currencies" }
        return Money(amount + other.amount, currency)
    }

    operator fun times(multiplier: Int): Money =
        Money(amount * multiplier.toBigDecimal(), currency)

    companion object {
        val ZERO = Money(BigDecimal.ZERO)
    }
}

enum class Currency { KRW, USD, JPY }

Kotlin’s data class pairs well with VOs. A data class automatically generates equals() and hashCode() based on all properties, so you get the VO’s compare by value characteristic without writing extra code. In Java, you’d have to manually implement equals(), hashCode(), and toString().

Address is another classic VO.

// Value Object: Address
data class Address(
    val city: String,
    val street: String,
    val zipCode: String,
) {
    init {
        require(city.isNotBlank()) { "City cannot be empty" }
        require(zipCode.matches(Regex("\\d{5}"))) { "Zip code must be a 5-digit number" }
    }
}

If there are two addresses of Seoul, Gangnam-gu, Yeoksam-dong, 123-45, they are the same address. Regardless of which order they’re attached to, if the values match, they’re equal.

Here’s a summary of the key characteristics of VOs.

They have no identifier. The value itself is the identity. Money(50000) doesn’t need an ID.

They are immutable. Once created, a VO doesn’t change. 50,000 won shouldn’t suddenly become 30,000 won. If you want a different value, create a new VO (replace).

// VOs aren't modified — you create new ones
val price = Money(BigDecimal(50000))
val discounted = Money(BigDecimal(45000))  // New object created
// price is still 50,000 won

They self-validate. The init block validates at creation time. Negative values can’t enter Money, and Address zip codes must be 5-digit numbers. The very existence of a VO guarantees its validity.

They are replaceable. When changing an entity’s attribute, the VO is not modified but replaced wholesale. When changing an order’s shipping address, you don’t modify the existing Address — you replace it with a new Address.

Value Objects Inside Entities

Entities contain VOs. In real domain models, most attributes of an entity are often VOs. Below is the full model for an order domain.

classDiagram
    class Order {
        +OrderId id
        +CustomerId customerId
        -OrderStatus status
        -List~OrderLine~ lines
        -Address shippingAddress
        +confirm()
        +changeShippingAddress(Address)
        +totalAmount() Money
    }

    class OrderLine {
        +ProductId productId
        +String productName
        +Money unitPrice
        +int quantity
        +subtotal() Money
    }

    class OrderId {
        +Long value
    }

    class CustomerId {
        +Long value
    }

    class Money {
        +BigDecimal amount
        +Currency currency
        +plus(Money) Money
        +times(int) Money
    }

    class Address {
        +String city
        +String street
        +String zipCode
    }

    Order *-- OrderLine : lines
    Order *-- OrderId : id
    Order *-- CustomerId : customerId
    Order *-- Address : shippingAddress
    OrderLine *-- Money : unitPrice
    OrderLine *-- ProductId : productId

Order is the entity, and OrderId, CustomerId, Money, Address are all VOs. OrderLine can also be designed as a VO. The order item itself doesn’t need an identifier, and equality can be determined by the combination of productID, quantity, and unit price.

In code, this structure looks like this:

// A rich domain model with multiple VOs inside an Entity (Order)
class Order(
    val id: OrderId,
    val customerId: CustomerId,
    private var status: OrderStatus,
    private val lines: MutableList<OrderLine>,
    private var shippingAddress: Address,
) {
    fun addLine(productId: ProductId, name: String, unitPrice: Money, quantity: Int) {
        require(status == OrderStatus.DRAFT) { "Items can only be added in draft status" }
        require(quantity > 0) { "Quantity must be at least 1" }
        lines.add(OrderLine(productId, name, unitPrice, quantity))
    }

    fun changeShippingAddress(newAddress: Address) {
        require(status == OrderStatus.DRAFT) { "Shipping address can only be changed in draft status" }
        // Replace the Address VO wholesale (replacement, not modification)
        shippingAddress = newAddress
    }

    fun confirm() {
        require(lines.isNotEmpty()) { "Cannot confirm an order with no items" }
        require(status == OrderStatus.DRAFT) { "Can only confirm from draft status" }
        status = OrderStatus.CONFIRMED
    }

    fun totalAmount(): Money =
        lines.fold(Money.ZERO) { acc, line -> acc + line.subtotal() }
}

// Value Object: Order line item
data class OrderLine(
    val productId: ProductId,
    val productName: String,
    val unitPrice: Money,
    val quantity: Int,
) {
    init {
        require(quantity > 0) { "Quantity must be at least 1" }
    }

    fun subtotal(): Money = unitPrice * quantity
}

Notice how business rules live inside the entity in this code. Items can only be added in draft status, an empty order can’t be confirmed — these rules are in Order itself, not in OrderService.

Critique of the Anemic Domain Model

A pattern frequently criticized in DDD is the Anemic Domain Model. Named by Martin Fowler, this anti-pattern describes a structure where domain objects only hold data while all logic resides in the service layer.

An anemic model looks like this:

// Anemic Domain Model: domain objects are just data bags
public class Order {
    private Long id;
    private Long customerId;
    private String status;
    private List<OrderLineDto> lines;
    private String city;
    private String street;
    private String zipCode;
    private BigDecimal totalAmount;

    // dozens of getters and setters only...
}

Business logic lives in the service:

// Anemic model's service: all logic piles up here
public class OrderService {
    public void confirmOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        if (order.getLines().isEmpty()) {
            throw new IllegalStateException("Order items are empty");
        }
        if (!order.getStatus().equals("DRAFT")) {
            throw new IllegalStateException("Not in draft status");
        }
        order.setStatus("CONFIRMED");

        BigDecimal total = BigDecimal.ZERO;
        for (OrderLineDto line : order.getLines()) {
            total = total.add(line.getPrice().multiply(
                BigDecimal.valueOf(line.getQuantity())));
        }
        order.setTotalAmount(total);

        orderRepository.save(order);
    }
}

This structure has several problems.

Rules are scattered. The rule can only confirm from draft status lives in OrderService. Since other services can also change order status, there’s a risk of code that bypasses the rule. Since order.setStatus("CONFIRMED") can be called from anywhere, the rule isn’t protected.

Encapsulation is broken. You can set any value with setStatus() and arbitrarily change the amount with setTotalAmount(). The object can’t maintain its own consistency.

Duplication emerges. The validation that order items must not be empty might exist in confirmOrder(), addLine(), and changeShippingAddress(). The same rule repeats across multiple service methods.

A Rich Domain Model solves these problems. Since domain logic lives inside entities, there’s no way to bypass the rules. Calling Order.confirm() handles all state validation and transitions internally. The service becomes a thin layer that delegates work to domain objects.

// Rich model's service: delegates to domain objects
class OrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: DomainEventPublisher,
) {
    fun confirmOrder(orderId: OrderId) {
        val order = orderRepository.findById(orderId)
        order.confirm()  // Validation and state transition happen inside Order
        orderRepository.save(order)
        eventPublisher.publish(OrderConfirmedEvent(order.id))
    }
}

Notice how the service has gotten shorter? Business logic is inside Order.confirm(), and the service only handles orchestration: fetching the object (find), invoking behavior (confirm), persisting (save), and publishing events (publish).

Criteria for Distinguishing Entity and VO

Here are helpful questions for deciding “Is this an Entity or a VO?” in practice.

“Do I need to track this object?” If an order is created, its status changes, and you need to view its history, it’s an entity. The order amount doesn’t need tracking. If 50,000 won is 50,000 won, that’s enough. It’s a VO.

“Are two instances with all the same attributes the same thing?” If there are two members both named Kim Cheolsu living in Seoul, they’re different members. Entity. On the other hand, two addresses of Seoul, Gangnam-gu, Yeoksam-dong are the same address. VO.

“Does this object change, or get replaced?” An order’s status changes from DRAFT to CONFIRMED. The same order object changes. Entity. When changing the shipping address, you don’t modify the existing address — you replace it with a new one. VO.

CriterionEntityValue Object
Equality determinationIdentifierAll attribute values
MutabilityMutableImmutable
LifecycleHas oneDoesn’t have one
Change mechanismInternal state changeWholesale replacement
ExamplesOrder, User, ProductMoney, Address, DateRange

Use Value Objects Aggressively

One common problem is using primitive types when a VO would work.

// Primitive type abuse: amount is int, address is String
public class Order {
    private Long id;
    private int totalAmount;       // Currency? Allow negatives?
    private String shippingCity;   // Where does validation happen?
    private String shippingStreet;
    private String shippingZip;
}

If totalAmount is int, the compiler can’t catch negative values. Adding won and dollars together won’t produce an error. If address fields are individual Strings, validation logic for whether the combination of city + street + zip code is valid has to be scattered somewhere outside this object.

Wrapping them in VOs eliminates these problems.

// Wrapping with VOs lets the type system enforce business rules
data class Money(val amount: BigDecimal, val currency: Currency) {
    init {
        require(amount >= BigDecimal.ZERO) { "Amount must be zero or greater" }
    }

    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "Currencies must match" }
        return Money(amount + other.amount, currency)
    }
}

data class Address(val city: String, val street: String, val zipCode: String) {
    init {
        require(city.isNotBlank()) { "City is required" }
        require(zipCode.matches(Regex("\\d{5}"))) { "Zip code format is invalid" }
    }
}

Money values with different currencies can’t be added, and Address validity is guaranteed at creation time. As these VOs accumulate, you reach a state where the type system enforces business rules. Runtime errors decrease, and code intent becomes clear.

ID types are also better as VOs. Using OrderId and CustomerId instead of Long lets you catch the mistake of passing an order ID where a customer ID is expected at compile time.

// IDs as VOs: prevent mistakes through types
@JvmInline
value class OrderId(val value: Long)

@JvmInline
value class CustomerId(val value: Long)

// Now OrderId and CustomerId can't be confused
fun findOrder(orderId: OrderId): Order = ...
fun findCustomer(customerId: CustomerId): Customer = ...

Kotlin’s value class (inline class) provides type safety with no runtime overhead, making it ideal for turning ID types into VOs.

Key Takeaways

Here’s a summary of what this article covered.


The next article covers Aggregate and Repository. We’ll examine the structure where multiple entities and VOs come together to form a consistency boundary, and the design of repositories that handle persistence and retrieval at that boundary level.

-> Part 5: Aggregate and Repository — Consistency Boundaries and Persistence


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 3 — Context Mapping
Next Post
DDD Part 5 — Aggregate and Repository