Õppetund 30/159
Kirjalik õppetund · 20 min

Entities, Value Objects, and DTOs

Identity, value and boundary data, and why a record or a database key does not decide which is which.

Eelvaade

Vaatad tasuta eelvaate õppetundi.

In the previous lesson, we turned business concepts into a Java domain model: Product, Inventory, Order, OrderItem, CustomerId, ProductId and OrderId. Now an important question appears:

Are all of these entities?

No. A backend works with three kinds of objects: entities, value objects and DTOs. All three may contain data and may be Java classes or records, but their meaning and responsibility differ. The distinction helps us model identity correctly, reduce mutation, keep API models apart from domain models and stop JPA from shaping the business model.

The Core Difference

A simple mental model:

KindWhat matters
EntityIdentity
Value objectValue
DTOData transfer across a boundary

Let's examine each one.

What Is an Entity?

An entity is a business object whose identity matters over time. Its state may change, but we keep recognizing it as the same business object. An Order with ID 8c7439b1-... starts as UNPAID and later becomes PAID; it is still the same Order because its OrderId did not change. Two in-memory objects with that ID but different statuses represent the same Order at different moments. Order is clearly an entity.

Product is an entity too. When the price of Product P, "Mechanical Keyboard", drops from 10000 to 9000 cents, it remains the same Product. And the reverse also holds: two Products both named "Keyboard", priced at 10000 and active, are still two different Products if their IDs differ. Their values happen to match; identity distinguishes them.

A concept is likely an entity when:

  • its identity matters independently of its current state,
  • its state can change while it remains the same business object,
  • we need to refer to it across several operations,
  • two instances with identical values could still be different things.

Domain Entity vs JPA @Entity

This distinction is critical. In domain terminology, an entity is a concept whose business identity matters. In JPA, @Entity is a persistence mapping instruction. The two often overlap, but they are not equivalent.

The Order class from the previous lesson is already a domain entity, with a stable OrderId, a status and a cancel() rule, and it has no @Entity annotation. Persistence annotations answer how this state is stored; domain classification answers what this business concept means.

Conversely, @Entity does not guarantee good domain modeling:

java
@Entity
public class Something {

    @Id
    private UUID id;

    private String value;
}

This tells JPA how to persist the class. It says nothing about whether the identity has business meaning, whether invariants are protected, whether state transitions are meaningful or whether responsibility is correct. ORM mapping is not domain modeling.

Entity State and Identity Are Protected

An entity is not merely an ID plus mutable fields. order.markPaid() is stronger than order.setStatus(OrderStatus.PAID), and product.changePrice(newPriceCents) is stronger than an unrestricted setPriceCents(...). Entities protect meaningful changes to their state.

Their identity is stable as well. A Product may change its name, price and active state, but it never changes from ProductId A to ProductId B. The same applies to Order: identity stays fixed throughout the entity lifecycle.

What About Inventory?

Inventory is slightly different. Our v1 model has one Inventory state per Product. Inventory owns availableQuantity, and that same state changes over time (10, then 8, then 5, then 9) while still representing the Inventory for Product P. So Inventory is a stateful, identity-bearing domain concept, and its identity is naturally the ProductId.

That is why Inventory needs no InventoryId. The ProductId already tells us which Inventory state we mean, and we do not invent another identifier just because relational tables often have primary keys. In our canonical persistence model, inventory.product_id is itself the primary key.

What About OrderItem?

OrderItem is more subtle. An Order contains OrderItems, but current requirements include no GET /order-items/{id}, no independent update of an OrderItem, no separate OrderItem lifecycle and no partial management of one item. The business talks about the Order, not independently managed OrderItems. And because the same Product may appear at most once in one Order, there is no business need for an OrderItemId: the persistence key (order_id, product_id) is enough, and it does not turn OrderItem into an independently meaningful entity.

OrderItem has several value-object characteristics: no independent lifecycle, no independent business identity, immutable purchase-time facts, and ownership by its Order. So our v1 model treats it as an owned value-like domain object, which is why the previous lesson made it a record: the compact constructor keeps the invariants, and the record gives us the immutability and value-based equality the concept needs. Immutability also keeps history safe, since no setUnitPriceCents(...) can rewrite the purchase-time price, and a ProductId reference rather than a live Product keeps Order decoupled from current Product state.

Do not assign entity identity merely because the database could have a row ID. Ask instead:

Does the business need to distinguish this object independently over time?

For OrderItem today, the answer is no, so we do not create fake domain identity.

What Is a Value Object?

A value object is defined by the values it contains rather than by an independent identity. If we create new ProductId(uuid) twice with the same UUID, we do not care that these are two Java objects: they represent the same business value, Product identifier X.

That gives value objects a different equality rule. For an entity, the same identity means the same entity. For a value object, the same contained value means the same value.

A strong value object is also normally immutable. We never mutate ProductId(uuidA) into ProductId(uuidB); we create a new value instead. Immutability makes value objects easy to reason about.

Our Typed IDs Are Value Objects

The previous lesson settled our typed IDs: ProductId and OrderId are UUID-backed records generated by the application, and CustomerId is a String-backed record for an external identity. They are clear examples of value objects, with equality based on the wrapped value, which makes a Java record a good fit.

Why Typed IDs Help

Compare these two signatures:

<!-- fragment -->
java
void cancelOrder(UUID orderId, UUID productId);

void cancelOrder(OrderId orderId, ProductId productId);

In the first, both arguments have the same Java type and can be accidentally swapped. In the second, the compiler detects the mismatch. The same goes for a method such as Order find(UUID id): which ID is that, an OrderId or a ProductId? Order find(OrderId orderId) answers the question in its signature. The types communicate meaning, which is one of the main reasons typed identity value objects are valuable in our backend.

A value object can also protect its own representation. The compact constructor of CustomerId rejects null and blank values, so every CustomerId carries that guarantee, and callers do not need to remember the rule. Validation should match the real representation, though. A rule such as "ID must be positive" does not apply to UUID-backed IDs; their main local guarantee is simply a non-null UUID.

Not Every Primitive Needs a Wrapper

Typed IDs provide real benefit. That does not mean we should immediately create ProductName, ProductPrice, Quantity, InventoryQuantity, OrderCreatedAt or Money for every primitive. Create a value object when at least one of these is true:

  • the wrong primitive can easily be passed,
  • validation is important and repeated,
  • several values form one coherent concept,
  • special behaviour belongs to the value,
  • the concept crosses important boundaries.

Not because "all primitives are bad". For v1, OrderItem.quantity and Inventory.availableQuantity are int, which is sufficient because the domain objects protect their ranges, and money stays long cents (lesson 4.06) until monetary semantics grow richer than v1's single currency.

Entities Contain Value Objects

Entities commonly contain value objects. Order is an entity whose OrderId and CustomerId fields are value objects; Product is an entity identified by the value object ProductId. This is normal, and value objects help express entity state more precisely. Inventory is mutable, identity-bearing domain state whose identity happens to be another value object, ProductId; there is no requirement for a separate Inventory ID type.

The two kinds also change differently. product.changePrice(newPriceCents) changes the state of the same Product. For an immutable value object, we create a replacement instead: new CustomerId("customer-a") and new CustomerId("customer-b") are two values, and we never mutate the first into the second. Their identity semantics are fundamentally different.

What Is a DTO?

DTO means Data Transfer Object. Its primary responsibility is to move data across a boundary. In our backend, the most obvious DTO boundaries are the HTTP request arriving at a handler and the handler producing the HTTP response; external provider protocols also use DTO-like representations.

A Create Order request might be:

java
public record CreateOrderRequest(List<CreateOrderItemRequest> items) {
}
java
public record CreateOrderItemRequest(String productId, int quantity) {
}

This represents the HTTP contract; it is not the Order domain entity. Although the domain uses ProductId(UUID), our HTTP API represents UUIDs as strings:

json
{
  "items": [
    {
      "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
      "quantity": 2
    }
  ]
}

After transport validation, the handler converts the string into a UUID and then a ProductId, so the domain never needs to know about the JSON representation. CreateOrderRequest exists to represent incoming HTTP data. It does not own the Order lifecycle, calculate totals or decide payment eligibility, and it offers no request.cancelOrder(): business operations belong to use cases and domain objects.

A DTO Is Neither an Entity Nor a Value Object

We never talk about CreateOrderRequest #123 persisting for months and changing state. A request DTO exists only while the request is handled: the HTTP request arrives, the DTO is created, the handler reads it, the data moves inward, and the DTO is discarded. Its lifecycle is boundary-scoped.

A DTO is not automatically a value object either. A record such as OrderResponse has structural equality and immutable components, so technically it behaves value-like as a Java object. Architecturally, however, it exists because the HTTP API needs a response representation, so we classify it as a DTO.

Java syntax does not define architectural meaning. ProductId, CreateOrderCommand and OrderResponse might all be records, yet ProductId is a value object, CreateOrderCommand is use-case input and OrderResponse is a DTO. Ask why does this type exist?, not "is it a class or a record?"

HTTP DTOs therefore live next to their handlers in the api package, as our package structure lesson established, not in order/domain/:

text
order/
└── api/
    ├── OrderHandler.java
    ├── CreateOrderRequest.java
    ├── CreateOrderItemRequest.java
    └── OrderResponse.java

Do Not Use Domain Entities as Request Bodies

Avoid binding the request body directly to the entity:

<!-- fragment -->
java
@PostMapping("/orders")
Order create(@RequestBody Order order) {
    // ...
}

Now the external client may try to supply domain state it does not own: the OrderId, CustomerId, OrderStatus, unitPriceCents, totalCents and createdAt. These are server-owned facts, and transport input and domain state sit on different sides of a trust boundary.

For Create Order, the client provides only each ProductId and quantity. The backend determines everything else:

ValueSource
CustomerIdAuthenticated context
Product priceCentsCurrent Product state
OrderIdGenerated by the application
OrderStatusUNPAID by the creation rule
createdAtThe application Clock
totalCentsDerived from the OrderItems

The request DTO should reflect only what the client is allowed to control. This version gets it wrong:

<!-- not-in-project -->
java
package com.tarkev.ordermanagement.order.api;

import com.tarkev.ordermanagement.order.domain.OrderItem;
import java.util.List;

public record CreateOrderRequest(
        String customerId, String status, long totalCents,
        List<OrderItem> items) {
}

It exposes server-owned state to the client, and it leaks the domain OrderItem directly into transport. The CreateOrderRequest and CreateOrderItemRequest shown above, carrying nothing but Product IDs and quantities, make the contract intentional.

Response DTOs

The Order domain may contain state that should not automatically be exposed, so a response explicitly chooses the public contract:

java
public record OrderResponse(
        String id, String status, Instant createdAt,
        List<OrderItemResponse> items, long totalCents) {
}
java
public record OrderItemResponse(
        String productId, int quantity, long unitPriceCents,
        long totalCents) {
}

If the handler returned Order directly, the domain structure would become the API structure, and an internal refactor could accidentally alter the public JSON contract; a persistence-related field added later might even become exposed, depending on serialization configuration. Explicit response DTOs give us control. They are the place for accepted request fields, returned response fields, transport validation, JSON representation and serialization format, without forcing domain objects to become HTTP models.

So keep Jackson out of the domain where practical. Domain objects should not be responsible for JSON property names, serialization details or API formatting just because Spring MVC uses Jackson. The domain's OrderId id may appear in the DTO as String id, and the handler's mapping owns that representation.

Commands and Use-Case Input

The handler may translate the HTTP DTO into a use-case input next to the use case:

java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.order.domain.CustomerId;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.util.List;

public record CreateOrderCommand(CustomerId customerId, List<Item> items) {

    public record Item(ProductId productId, int quantity) {
    }
}

The flow is then CreateOrderRequest → handler → CreateOrderCommand → CreateOrderUseCase. Architecturally, a command also transfers data, but we call it use-case input or command because that describes its responsibility more precisely. CreateOrderRequest represents the HTTP contract; CreateOrderCommand represents application operation input. They may start with similar fields, but that does not make them conceptually identical. The command carries the authenticated CustomerId and typed ProductIds, not strings.

Do we always need both? Here we do, and every use case in this course takes one command record, because each combines the authenticated identity with request data. But do not mechanically build Request → Command → Input → Model → Domain when every type contains identical fields. A dedicated use-case input helps when:

  • the HTTP format differs from the application input,
  • the authentication context must be combined with request data,
  • several entry points invoke the same use case,
  • transport-specific types should not leak inward,
  • the input is complex enough to deserve a meaningful name.

The goal is clarity, not object count. The same applies to mapping: for a small response, plain code is perfectly readable:

java
return new OrderResponse(
        order.id().value().toString(), order.status().name(),
        order.createdAt(), items, order.totalCents());

We need no OrderMapper, OrderResponseMapper or mapping library until complexity or reuse justifies one.

Entity, Value Object and DTO Together

Consider Create Order from end to end. Each type exists for a different reason:

Diagrammi joonistamine…

Around the use case, persistence runs through repositories, and payment runs from the use case through PaymentService to the payment integration and its provider DTOs. Each type stays close to the responsibility it serves.

Keys and Annotations Do Not Classify

A technical row key does not prove that a concept is a domain entity. Even if a future OrderItem table added a technical row identifier, or a payment_attempts table had an ID, that would not create independent domain identity. Likewise, calling ProductId a value object does not require JPA's @Embeddable; how typed IDs are mapped is addressed in the persistence module.

Provider DTOs

DTOs are not limited to HTTP. External provider requests and responses, message payloads and file-import structures are DTOs too. The payment provider may eventually use ProviderPaymentRequest and ProviderPaymentResponse, which belong inside payment/integration/provider/ and must not leak into order/domain/ or order/application/. The payment integration translates them into an application-facing result such as PaymentResult, so PayOrderUseCase depends on application semantics rather than provider protocol details.

Trust Increases Inward

A DTO may hold data that is invalid for the domain, and that is normal: after deserialization we might have new CreateOrderItemRequest("not-a-uuid", -5), untrusted input that transport validation rejects before domain values are created. Trust increases as data moves inward:

TypeTrust
DTOUntrusted boundary data
Use-case inputStructured application input
Value objects and domain objectsStrong invariants

Transport validation never replaces domain invariants, and HTTP rules such as @NotBlank never move into the domain; the next lesson decides which boundary owns each rule.

Equality, Records and Lifecycles

The three kinds also differ in equality and lifecycle. An entity with the same identity is conceptually the same entity, even when its current state differs; a value object with the same contained value is the same value; for a DTO, equality may be convenient but is not central to its meaning. An entity such as Order moves through a lifecycle, while a request DTO is deserialized, validated, mapped and discarded.

Records fit immutable value objects naturally, but using a record does not automatically make a type a value object: records are just as useful for DTOs such as OrderResponse. Entities with a meaningful mutable lifecycle are usually regular classes, and a regular class still does not mean public setters: Product encapsulates its lifecycle behind changePrice(...) and deactivate().

Keeping the API and the Domain Independent

Suppose our HTTP response shows "status": "UNPAID", and the API contract later decides to expose "status": "unpaid". That is a transport representation decision; we should not need to rename OrderStatus.UNPAID inside the domain. DTO mapping keeps the representations separate.

It works in both directions: we may add pagination metadata to a response without changing Order's behaviour, and richer internal abstractions can leave the API returning "totalCents": 12000. The domain model is not the API contract, and the DTO boundary prevents every internal refactor from becoming a public API change.

How Many Models Do We Need?

Eventually we may conceptually have a domain Order, a persistence representation and an OrderResponse. Do we always need three separate classes? No. But business behaviour, database mapping and transport representation remain different responsibilities, and we split them into separate Java types when the separation is worth the mapping it costs.

What we avoid is one object per layer as a ritual: OrderEntity, OrderDomain, OrderDto, OrderResponse, OrderCommand, OrderModel and OrderView for one simple workflow create mapping overhead without improving the architecture. Two distinctions matter most for this project: request and response DTOs are not domain entities, and provider DTOs are not application or domain models. The persistence representation will be decided pragmatically when JPA is introduced.

Current Classification

Based on our established v1 model:

ClassificationTypesWhy
EntitiesProduct, OrderIdentity stays stable while state changes
Stateful identity-bearing conceptInventoryIdentified by ProductId; availableQuantity changes; no InventoryId
Owned value-like domain objectOrderItemNo independent identity, immutable purchase-time state, lifecycle owned by Order; no OrderItemId
Value objectsProductId, OrderId, CustomerIdDefined by their values; part of our canonical model
DTOsCreateOrderRequest, CreateOrderItemRequest, OrderResponse, OrderItemResponse, ProductResponse, ProviderPaymentRequest, ProviderPaymentResponseBoundary communication

This classification can evolve. If a future requirement says "Refund a specific OrderItem independently" or "Track fulfilment status for each OrderItem separately", independent OrderItem identity may become useful, and its classification may change then. We do not future-proof by inventing that identity today.

Practice Exercise 1: Equality in Code

Solutions: lesson 4.09.

Write a plain JUnit 5 test class that turns this lesson's equality rules into assertions you can run against your project:

  1. Two ProductIds built from the same UUID are equal; two from ProductId.newId() are not.
  2. Two OrderItems with the same Product, quantity and unit price are equal.
  3. Two Products reconstituted with the same ProductId, name, price and active state: does equals say they are the same Product?
  4. Product.create(...) twice with different IDs but the same name, price and active state: equal or not?

Predict each result before you run ./gradlew test. Then explain case 3: which rule from this lesson does the result break, and why does Product not fix it yet?

Practice Exercise 2: Classify the Types

Classify each type as an entity, a value object, a DTO or use-case input, and give the reason in one sentence. "It is a record" is not a reason.

  1. CreateProductCommand(String name, long priceCents)
  2. ProductResponse(String id, String name, long priceCents, boolean active)
  3. Inventory
  4. OrderStatus
  5. ProviderPaymentResponse
  6. OrderItem
  7. A record ProductBrowseRow(ProductId id, String name, long priceCents, int availableQuantity) that a read query returns for Customer browsing

Practice Exercise 3: Trim the Request

A teammate proposes this request body for creating a Product:

<!-- fragment -->
java
public record CreateProductRequest(
        String id, String name, long priceCents, boolean active,
        int availableQuantity) {
}

Which fields should the client not control, and who provides each value instead? Which field belongs to a different concept altogether?

Knowledge Check

Question 1

order_items will have a row key (order_id, product_id). Why does that key not make OrderItem an entity?

Question 2

What kind of bug does cancelOrder(OrderId orderId, ProductId productId) prevent that cancelOrder(UUID orderId, UUID productId) allows?

Question 3

What goes wrong if a handler binds the request body directly to the domain Order?

Question 4

ProductId and OrderResponse are both records with value-based equality. Why is one a value object and the other a DTO?

Question 5

Why may a DTO hold data the domain would reject, such as new CreateOrderItemRequest("not-a-uuid", -5)?

Knowledge Check Answers

Answer 1

The key only tells the database how to find the row. The business never refers to an OrderItem on its own, changes it, or follows it over time, so it has no domain identity.

Answer 2

Swapping the two arguments compiles silently when both are UUID. With typed IDs the compiler rejects the swap.

Answer 3

The client could then supply server-owned facts: the OrderId, the owner, the status, the prices, the total and createdAt. And the domain's structure becomes the API contract, so an internal refactor can change the public JSON.

Answer 4

Architectural role comes from why the type exists, not from Java syntax. ProductId expresses a domain value with its own guarantee; OrderResponse exists only to carry the API's response representation.

Answer 5

A DTO only carries what arrived at the boundary. Transport validation rejects bad input before domain values are built, so trust increases as data moves inward, and the domain invariants still hold whatever the source.

Engineering Principle

Entity identity survives state changes, value objects are defined by their values, and DTOs exist to carry data across boundaries.

Do not let database keys, JPA annotations, Java syntax or JSON shape decide what a business concept means, and separate models only where responsibilities genuinely differ.

Summary

  • Entities, value objects and DTOs all carry data but exist for different reasons; class or record, database keys and @Entity do not decide which is which.
  • Product and Order are entities: stable identity, changing state, regular classes protected by business operations.
  • Inventory is identity-bearing state keyed by ProductId; OrderItem is an immutable, owned value-like object; neither gets an invented ID.
  • Value objects are defined by their values and usually immutable records; our typed IDs add type safety, while quantities stay int and money long cents.
  • HTTP DTOs live next to their handlers, carry UUIDs as strings, never expose server-owned state, own no behaviour and decouple the API from the domain.
  • A command is use-case input, separate from the request DTO only when that adds value; simple mapping needs no framework.
  • Provider DTOs stay inside payment/integration/provider/, translated into results such as PaymentResult.
  • Trust increases inward: DTOs carry untrusted input, and domain invariants hold regardless of the data's source.

In the next lesson, Separating Domain Logic from Transport Logic, we follow a request through HTTP, handler, use case and domain, and decide which validation, mapping and business decisions belong at each boundary.

Mõtle enne

Tulekul

Kontrollküsimused, mille puhul valid vastuse enne, kui õppetund selle näitab.