Table of contents
- Two Ten-Thousand-Won Bills
- Entity — Objects Distinguished by Identifier
- Value Object — Objects Compared Solely by Value
- Value Objects Inside Entities
- Critique of the Anemic Domain Model
- Criteria for Distinguishing Entity and VO
- Use Value Objects Aggressively
- Key Takeaways
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.
| Criterion | Entity | Value Object |
|---|---|---|
| Equality determination | Identifier | All attribute values |
| Mutability | Mutable | Immutable |
| Lifecycle | Has one | Doesn’t have one |
| Change mechanism | Internal state change | Wholesale replacement |
| Examples | Order, User, Product | Money, 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.
- Entities are distinguished by identifier, and Value Objects are compared by value
- Entities are mutable with a lifecycle; VOs are immutable and express change through replacement
- Kotlin
data classis well-suited for expressing VOs. It auto-generatesequals()andhashCode() - Aggressively using VOs inside entities produces a rich domain model
- The Anemic Domain Model is an anti-pattern where data and logic are separated, scattering rules and breaking encapsulation
- Using VOs instead of primitive types produces code where the type system enforces business rules
- Making IDs into VOs (
value class) prevents mix-ups at compile time
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




Loading comments...