Domain Modeling

Entities, Value Objects, and DTOs

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

In the previous lesson, we began turning business concepts into a Java Domain model.

Our current concepts include:

Product

Inventory

Order

OrderItem

CustomerId

ProductId

OrderId

Now an important question appears:

Are all of these Entities?

No.

In a backend application, we frequently work with three different kinds of objects:

Entity

Value Object

DTO

All three may contain data.

All three may be implemented using Java classes or records.

But their meaning and responsibility are different.

Understanding that distinction helps us:

model identity correctly

reduce unnecessary mutation

keep API models separate from Domain models

avoid letting JPA shape the business model

make ownership clearer

The Core Difference

A simple mental model is:

Entity
→ identity matters
Value Object
→ value matters
DTO
→ data transfer matters

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 continue to recognize it as the same business object.

Consider an Order.

Initially:

OrderId = 8c7439b1-...
status = UNPAID

Later:

OrderId = 8c7439b1-...
status = PAID

The state changed.

But it is still the same Order because:

OrderId

did not change.

Therefore:

Order is clearly an Entity.


Entity Identity Survives State Changes

Suppose two in-memory objects represent:

Order A

id = X
status = UNPAID

and:

Order B

id = X
status = PAID

Their current state differs.

But conceptually they represent the same:

Order X

because Entity identity is independent of its current mutable state.


Product Is Also an Entity

Suppose today:

ProductId = P
name = Mechanical Keyboard
priceCents = 10000
active = true

Later:

ProductId = P
name = Mechanical Keyboard
priceCents = 9000
active = true

The Product's price changed.

But it remains the same Product.

Therefore Product is also an Entity.


Two Identical-Looking Products Can Still Be Different

Imagine:

Product A
name = Keyboard
priceCents = 10000
active = true

and:

Product B
name = Keyboard
priceCents = 10000
active = true

If their IDs are different:

ProductId A ≠ ProductId B

then they represent two different Products.

Their current values happen to match.

Identity still distinguishes them.


Questions That Suggest an Entity

Ask:

Does this concept have identity
that matters independently
of its current state?

Can its state change while
it remains the same business object?

Do we need to refer to it
across multiple operations?

Could two objects have identical values
but still represent different things?

If yes, the concept is likely an Entity.


Domain Entity vs JPA @Entity

This distinction is critical.

In Domain terminology:

Entity
→ business identity matters

In JPA:

@Entity
→ persistence mapping instruction

These concepts often overlap.

But they are not equivalent.


A Domain Entity Does Not Need JPA

This is already a Domain Entity:

public class Order {

    private final OrderId id;

    private OrderStatus status;

    public void cancel() {
        // Domain rule
    }
}

No:

@Entity

annotation is required to make Order conceptually an Entity.

Persistence annotations answer:

How is this state stored?

Domain classification answers:

What does this business concept mean?


JPA @Entity Does Not Guarantee Good Domain Modeling

We could write:

@Entity
public class Something {

    @Id
    private UUID id;

    private String value;
}

That tells JPA how to persist the class.

It does not tell us whether:

the identity has business meaning

business invariants are protected

state transitions are meaningful

responsibility is correct

ORM mapping is not Domain modeling.


Entity State Should Be Protected

An Entity is not merely:

ID
+
mutable fields

For Order:

order.markPaid();

is stronger than:

order.setStatus(
        OrderStatus.PAID
);

For Product:

product.changePrice(
        newPriceCents
);

is stronger than unrestricted:

product.setPriceCents(
        newPriceCents
);

Entities should protect meaningful changes to their state.


Entity Identity Should Be Stable

A Product may change:

name

priceCents

active

but it should not suddenly change from:

ProductId A

to:

ProductId B

The same applies to Order.

Identity remains stable throughout the Entity lifecycle.


What About Inventory?

Inventory is slightly different.

Our v1 model says:

one Inventory state
per Product

Inventory owns:

availableQuantity

and the same Inventory state changes over time:

10
→ 8
→ 5
→ 9

while still representing:

Inventory for Product P

So Inventory is a stateful identity-bearing Domain concept.

Its identity is naturally:

ProductId

Inventory Does Not Need InventoryId

Conceptually:

public class Inventory {

    private final ProductId productId;

    private int availableQuantity;
}

The ProductId already tells us which Inventory state we mean.

We do not invent:

InventoryId

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:

Order
├── OrderItem
├── OrderItem
└── OrderItem

Current requirements do not include:

GET /order-items/{id}

update OrderItem independently

track an OrderItem lifecycle separately

partially manage one OrderItem

The business primarily talks about:

Order

rather than independently managed OrderItem resources.


OrderItem Has No Independent Identity

Our current rule also says:

the same Product may appear
at most once in one Order

Therefore there is no current business need for:

OrderItemId

The persistence key is:

(order_id, product_id)

That does not mean OrderItem becomes an independently meaningful Domain Entity.


OrderItem as an Owned Value-Like Object

OrderItem has several Value Object-like characteristics:

no independent lifecycle

no independent business identity

immutable purchase-time facts

owned by Order

So our v1 model treats it as an owned value-like Domain object.

Conceptually:

public record OrderItem(
        ProductId productId,
        int quantity,
        long unitPriceCents
) {

    public OrderItem {
        Objects.requireNonNull(
                productId,
                "productId"
        );

        if (quantity <= 0) {
            throw new IllegalArgumentException(
                    "Quantity must be positive."
            );
        }

        if (unitPriceCents < 0) {
            throw new IllegalArgumentException(
                    "Unit price cannot be negative."
            );
        }
    }

    public long totalCents() {
        return Math.multiplyExact(
                unitPriceCents,
                quantity
        );
    }
}

This fits the current Domain well.

If future requirements give OrderItem an independent lifecycle, the model can evolve.


Domain Identity Must Come From the Business Need

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:

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 independent identity.

Consider:

ProductId(UUID X)

If we create two instances:

ProductId first =
        new ProductId(uuid);

ProductId second =
        new ProductId(uuid);

we do not care that these are two different Java objects.

They represent the same business value:

Product identifier X

Value Equality

For a Value Object:

same value
→ same conceptual value

That is different from an Entity.

Entity:

same identity
→ same Entity

Value Object:

same contained value
→ same value

Value Objects Are Usually Immutable

A strong Value Object normally does not mutate after creation.

For example:

ProductId id =
        new ProductId(uuid);

We do not mutate:

ProductId(uuidA)

into:

ProductId(uuidB)

We create a new value instead.

Immutability makes Value Objects easier to reason about.


Our Canonical Typed IDs

Typed IDs are not merely hypothetical in this project.

We have already decided to use:

ProductId
→ UUID-backed

OrderId
→ UUID-backed

CustomerId
→ String-backed

They are clear examples of Value Objects.


ProductId

Conceptually:

public record ProductId(
        UUID value
) {

    public ProductId {
        Objects.requireNonNull(
                value,
                "value"
        );
    }

    public static ProductId newId() {
        return new ProductId(
                UUID.randomUUID()
        );
    }
}

Equality is naturally based on:

UUID value

which makes Java record a good fit.


OrderId

Similarly:

public record OrderId(
        UUID value
) {

    public OrderId {
        Objects.requireNonNull(
                value,
                "value"
        );
    }

    public static OrderId newId() {
        return new OrderId(
                UUID.randomUUID()
        );
    }
}

Application code generates the UUID.

We do not depend on database-generated identity.


CustomerId

Customer identity comes from external authentication, so we deliberately do not assume UUID semantics.

Conceptually:

public record CustomerId(
        String value
) {

    public CustomerId {
        if (
                value == null ||
                value.isBlank()
        ) {
            throw new IllegalArgumentException(
                    "CustomerId cannot be blank."
            );
        }
    }
}

Its meaning is:

stable external Customer identity

Why Typed IDs Help

Compare:

void cancelOrder(
        UUID orderId,
        UUID productId
) {
}

Both arguments have the same Java type.

They can be accidentally reversed.

With:

void cancelOrder(
        OrderId orderId,
        ProductId productId
) {
}

the compiler can detect the mismatch.

The types communicate meaning.


Value Objects Can Protect Representation

A Value Object can validate its own representation.

For example:

public CustomerId {
    if (
            value == null ||
            value.isBlank()
    ) {
        throw new IllegalArgumentException(
                "CustomerId cannot be blank."
        );
    }
}

Any valid CustomerId now carries a useful guarantee.

Callers do not need to repeatedly remember the same representation rule.


Do Not Over-Validate UUID IDs

Our ProductId and OrderId are UUID-based.

Therefore rules such as:

ID must be positive

do not apply.

Validation should reflect the actual representation.

For UUID-backed IDs, the main local guarantee is simply:

non-null UUID

ID generation is handled by:

UUID.randomUUID()

when creating new application-owned Entities.


Value Objects Do Not Require Primitive Wrappers Everywhere

Typed IDs provide real benefit.

That does not mean we should immediately create:

ProductName

ProductPrice

Quantity

InventoryQuantity

OrderCreatedAt

Money

for every primitive.

Create a Value Object when it adds meaningful value such as:

type safety

repeated validation

strong Domain meaning

multiple values belonging together

useful behaviour

Not because:

all primitives are bad

Quantity Can Remain int

For v1:

OrderItem.quantity
→ int

Inventory.availableQuantity
→ int

is sufficient.

The Domain objects protect the relevant rules:

OrderItem.quantity > 0

Inventory.availableQuantity >= 0

We do not need a dedicated Quantity type yet.


Money in Our Project

Money is often used as an example of a Value Object.

But our canonical v1 decision is simpler:

priceCents
→ long

unitPriceCents
→ long

totalCents
→ long

For example:

8990
→ 89.90

We do not use authoritative:

double

float

BigDecimal

for these values.


Why No Money Value Object Yet?

Our v1 does not include:

multi-currency

currency conversion

tax calculation

rounding rules

discount arithmetic

Therefore:

long cents

plus Domain validation is sufficient.

If monetary semantics become richer, a Money Value Object may become justified later.


Entity Can Contain Value Objects

For example:

public class Order {

    private final OrderId id;

    private final CustomerId customerId;

    private OrderStatus status;
}

Here:

Order
→ Entity

while:

OrderId
CustomerId
→ Value Objects

This is normal.

Value Objects help express Entity state more precisely.


Product Contains a Value Object

Likewise:

public class Product {

    private final ProductId id;

    private String name;

    private long priceCents;

    private boolean active;
}

Product is an Entity.

ProductId is a Value Object.


Inventory Uses a Value Object as Its Identity

public class Inventory {

    private final ProductId productId;

    private int availableQuantity;
}

Inventory is mutable identity-bearing Domain state.

Its identity happens to be represented by another Value Object:

ProductId

There is no requirement for a separate Inventory ID type.


Entity Mutation vs Value Object Replacement

For an Entity:

product.changePrice(
        newPriceCents
);

changes the state of the same Product.

For an immutable Value Object, we typically create a replacement value:

CustomerId first =
        new CustomerId("customer-a");

CustomerId second =
        new CustomerId("customer-b");

We do not mutate first into second.

Their identity semantics are fundamentally different.


What Is a DTO?

DTO means:

Data Transfer Object

Its primary responsibility is:

Move data across a boundary.

In our backend, the most obvious DTO boundaries are:

HTTP request
→ Handler

and:

Handler
→ HTTP response

External provider protocols also use DTO-like representations.


Example Request DTO

A Create Order request might be:

public record CreateOrderRequest(
        List<CreateOrderItemRequest> items
) {
}

with:

public record CreateOrderItemRequest(
        String productId,
        int quantity
) {
}

This represents the HTTP contract.

It is not the Order Domain Entity.


HTTP Uses UUIDs as Strings

Although the Domain uses:

ProductId(UUID)

our HTTP API represents UUID values as strings.

Example:

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

The Handler boundary converts:

HTTP string
→ UUID
→ ProductId

after transport validation.

The Domain does not need to know about JSON representation.


DTO Exists for Communication

CreateOrderRequest exists to represent:

incoming HTTP data

It does not exist to:

own the Order lifecycle

calculate authoritative totals

decide payment eligibility

Those belong to application and Domain responsibilities.


DTO Is Not an Entity

We do not talk about:

CreateOrderRequest #123

persisting for months and changing state.

The request exists temporarily:

HTTP request arrives
        ↓
DTO created
        ↓
Handler reads it
        ↓
request data moves inward
        ↓
DTO discarded

Its lifecycle is boundary-scoped.


DTO Is Not Automatically a Value Object

A Java record gives structural equality and immutability-like syntax.

For example:

public record OrderResponse(
        String id,
        String status,
        long totalCents
) {
}

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

These might all be Java records:

record ProductId(...)

record CreateOrderCommand(...)

record OrderResponse(...)

But their responsibilities differ:

ProductId
→ Value Object

CreateOrderCommand
→ UseCase input

OrderResponse
→ DTO

Ask:

Why does this type exist?

not:

Is it a class or record?


DTOs Belong Near the Boundary

For Order HTTP handling:

order/
└── handler/
    ├── CreateOrderHandler.java
    ├── CreateOrderRequest.java
    ├── CreateOrderItemRequest.java
    └── OrderResponse.java

Transport DTOs belong near the Handler/API boundary.

They should not automatically live under:

order/domain/

Do Not Use Domain Entities as Request Bodies

Avoid:

@PostMapping("/orders")
Order create(
        @RequestBody Order order
) {
    // ...
}

Now the external client may attempt to provide Domain state it does not own.

For example:

OrderId

CustomerId

OrderStatus

unitPriceCents

totalCents

createdAt

These are server-owned facts.

Transport input and Domain state represent different trust boundaries.


Client Input Is Untrusted

For Create Order, the client provides only:

ProductId

quantity

The backend determines:

CustomerId
→ authenticated context

Product priceCents
→ current Product state

OrderId
→ generated by application

OrderStatus
→ UNPAID

createdAt
→ application Clock

totalCents
→ derived from OrderItems

The request DTO should reflect only what the client is allowed to control.


Bad CreateOrderRequest

Avoid:

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

This incorrectly exposes server-owned state to the client.

It also leaks the Domain object directly into transport.


Better Request DTO

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

Then:

CustomerId
→ security context

priceCents
→ Product

status
→ Order creation rule

totalCents
→ backend calculation

The contract is intentional.


Response DTOs

The Order Domain may contain state that should not automatically be exposed.

A response can explicitly choose the public contract.

For example:

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

with:

public record OrderItemResponse(
        String productId,
        int quantity,
        long unitPriceCents,
        long totalCents
) {
}

The HTTP representation remains separate from Domain implementation details.


Why Not Serialize Order Directly?

If the Handler directly returns:

Order

then:

Domain structure
=
API structure

Now an internal refactor may accidentally alter the public JSON contract.

For example, a persistence-related field added later might become exposed depending on serialization configuration.

Explicit response DTOs give us control.


DTOs Protect the Transport Boundary

DTOs give us an explicit place for:

accepted request fields

returned response fields

transport validation

JSON representation

serialization format

without forcing Domain objects to become HTTP models.


Keep Jackson Out of the Domain Where Practical

Avoid making Domain objects responsible for:

JSON property names

serialization details

API formatting

just because Spring MVC uses Jackson.

For example, Domain:

OrderId id

may be exposed through DTO as:

String id

The Handler mapping owns that representation.


DTOs Should Not Own Business Behaviour

A request DTO should not contain:

request.cancelOrder();

or:

request.calculateOrderTotal();

A DTO represents data moving across a boundary.

Business operations belong to:

UseCases

Domain objects

Commands and UseCase Input

The Handler may translate the HTTP DTO into a UseCase input such as:

public record CreateOrderCommand(
        List<Item> items
) {
}

Then:

CreateOrderRequest
        ↓
Handler
        ↓
CreateOrderCommand
        ↓
CreateOrderUseCase

Architecturally, a Command also transfers data.

But we call it:

UseCase input

or:

Command

because that describes its responsibility more precisely.


Request DTO vs Command

CreateOrderRequest represents:

HTTP contract

CreateOrderCommand represents:

application operation input

Those boundaries are different.

They may initially contain similar fields.

That does not make them conceptually identical.


Do We Always Need Both?

No.

If there is no useful separation, the Handler may simply do:

createOrderUseCase.execute(
        customerId,
        request.items()
);

or convert the request directly into the smallest useful application representation.

Do not mechanically build:

Request
→ Command
→ Input
→ Model
→ Domain

when every type contains identical fields.

Create a new type when it clarifies responsibility.


When a Command Becomes Useful

A dedicated UseCase input type can help when:

HTTP format differs from application input

authentication context must be combined
with request data

multiple entry points invoke the same UseCase

transport-specific types should not
leak inward

the input is complex enough
to deserve a meaningful name

The goal is clarity—not object count.


Mapping Does Not Require a Framework

For a small response:

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

may be perfectly readable.

We do not automatically need:

OrderMapper

OrderDtoMapper

OrderResponseMapper

or a mapping library.

Introduce mapping abstraction when complexity or reuse justifies it.


Entity, Value Object, and DTO Together

Consider Create Order.

Incoming:

CreateOrderRequest
[DTO]

Handler resolves:

CustomerId
[Value Object]

The UseCase loads:

Product
[Entity]

and works with:

Inventory
[stateful identity-bearing concept]

It creates:

OrderItem
[owned value-like Domain object]

then:

Order
[Entity]

Finally the Handler returns:

OrderResponse
[DTO]

Each type exists for a different reason.


Visual Model

HTTP
 │
 ▼
CreateOrderRequest
[DTO]
 │
 ▼
CreateOrderHandler
 │
 ├── resolves CustomerId
 │   [Value Object]
 │
 ▼
CreateOrderUseCase
 │
 ├── Product
 │   [Entity]
 │
 ├── Inventory
 │   [identity-bearing Domain state]
 │
 ├── OrderItem
 │   [owned value-like object]
 │
 └── Order
     [Entity]
 │
 ▼
OrderResponse
[DTO]

Database ID Does Not Define Domain Classification

Suppose a database table contains a technical row key.

That alone does not prove the corresponding concept is a Domain Entity.

Our OrderItem persistence deliberately uses:

PRIMARY KEY(
    order_id,
    product_id
)

rather than an order_item_id.

But even if a future persistence implementation introduced a technical row identifier, that would not automatically create independent OrderItem Domain identity.

Domain identity is a business question.


Database Row ≠ Domain Entity

Likewise, a future table such as:

payment_attempts

might contain an ID.

That does not automatically mean:

PaymentAttempt

must become a rich Domain Entity.

We first ask what the business needs to identify and manage over time.


Value Object ≠ JPA @Embeddable

Similarly, calling:

ProductId

a Value Object does not require:

@Embeddable

Domain classification and persistence mapping are separate decisions.

How typed IDs are mapped through JPA will be addressed in the persistence module.


DTOs Are Not Limited to HTTP

DTO means data transferred across a boundary.

Other examples include:

external provider requests

external provider responses

message payloads

file-import structures

For our current system, HTTP and Payment-provider boundaries are the most relevant examples.


Provider DTOs

The Payment provider may eventually use:

ProviderPaymentRequest

ProviderPaymentResponse

These belong inside the Payment integration boundary.

For example:

payment/
└── integration/

They should not leak into:

order/domain/

or:

order/usecase/

Map Provider DTOs at the Boundary

The external provider might return fields such as:

provider status

provider reference

provider-specific error code

raw provider metadata

The Payment integration should translate these into an application-facing result such as:

PaymentResult

Then:

PayOrderUseCase

depends on application semantics rather than provider protocol details.


DTO Validation vs Value Object Validation

Suppose HTTP input contains:

productId = ""

Transport validation can reject that malformed request.

If it reaches conversion into:

ProductId

the Value Object also protects its own valid representation.

Different boundaries can validate different guarantees.


DTO May Temporarily Hold Invalid Domain Data

This is normal.

After deserialization, we could temporarily have:

new CreateOrderItemRequest(
        "not-a-uuid",
        -5
);

That DTO represents untrusted input.

Transport validation rejects it before valid Domain/application values are created.

A Domain object should provide stronger guarantees.


Trust Should Increase as Data Moves Inward

A useful mental model is:

DTO
→ untrusted boundary data
UseCase input
→ structured application input
Value Objects / Domain objects
→ stronger invariants

Data should become more trustworthy as it crosses validated boundaries.


DTO Validation Must Not Be the Only Domain Protection

Suppose HTTP validation guarantees:

quantity > 0

Should OrderItem then allow:

quantity = -5

because:

The Handler already checks it?

No.

Another internal caller or test can create OrderItem directly.

If positive quantity is a Domain invariant, OrderItem must protect it.


But HTTP Rules Do Not Belong in the Domain

For example:

JSON field name must be "productId"

is a transport rule.

Likewise:

@NotBlank

on an HTTP request field is boundary validation.

The Domain cares about:

valid ProductId

valid quantity

valid lifecycle

not JSON mechanics.


Equality Rules

The distinction can also be expressed through equality.

Entity

same identity
→ conceptually same Entity

even when current state differs.

Value Object

same contained value
→ conceptually same value

DTO

Equality may be convenient, especially with records, but it is not normally central to its Domain meaning.


Java Records and Value Objects

Records are a strong fit for immutable Value Objects.

For example:

public record ProductId(
        UUID value
) {
}

Java provides:

immutable component reference

value-based equals()

value-based hashCode()

compact syntax

This aligns naturally with Value Object semantics.

But:

Using a record does not automatically make a type a Value Object.

Its purpose still matters.


Java Records and DTOs

Records are also useful for DTOs:

public record OrderResponse(
        String id,
        String status,
        long totalCents
) {
}

Same Java feature.

Different architectural role.


Should Entities Be Records?

Usually not when they have a meaningful mutable lifecycle.

Order changes:

UNPAID
→ PAID

Product may change:

name

priceCents

active

Inventory quantity changes.

Regular classes are therefore more natural for these stateful Entities.


Regular Class Does Not Mean Public Setters

For example:

public class Product {

    private long priceCents;

    private boolean active;

    public void changePrice(
            long newPriceCents
    ) {
        if (newPriceCents < 0) {
            throw new IllegalArgumentException(
                    "Price cannot be negative."
            );
        }

        priceCents =
                newPriceCents;
    }

    public void deactivate() {
        active = false;
    }
}

Mutable lifecycle should still be encapsulated.


Value Objects Reduce Primitive Confusion

Compare:

public Order find(
        UUID id
) {
}

What ID is that?

Could it be:

OrderId

ProductId

?

With:

public Order find(
        OrderId orderId
) {
}

the method becomes much clearer.

This is one of the main reasons typed identity Value Objects are valuable in our backend.


Avoid Wrapper Explosion

We do not need:

ProductName

ProductPrice

InventoryQuantity

OrderItemQuantity

OrderCreatedAt

simply because they are technically possible.

A useful threshold for introducing a Value Object is when at least one is true:

wrong primitive can easily be passed

validation is important and repeated

multiple values form one coherent concept

special behaviour belongs to the value

the concept crosses important boundaries

DTO Versioning and Domain Stability

Suppose our HTTP response represents:

{
  "status": "UNPAID"
}

and later the API contract 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 transport representation separate.


API Can Change Without Domain Lifecycle Changing

We may later add:

createdAt

items

pagination metadata

to an API response.

That does not mean Order's core Domain behaviour changed.

Again:

Domain model
≠
API contract

Domain Can Change Internally Without API Change

Likewise, if we later introduce richer internal abstractions, the external API can remain:

{
  "totalCents": 12000
}

The DTO boundary prevents every internal refactor from becoming a public API change.


Persistence Is Another Responsibility

Eventually we may conceptually have:

Domain Order

Persistence representation

OrderResponse

Do we always need three separate classes?

No.

But these responsibilities remain conceptually different:

business behaviour

database mapping

transport representation

We split them into separate Java types when the separation provides enough value to justify mapping complexity.


Avoid One Object Per Layer as a Ritual

Over-engineered:

OrderEntity

OrderDomain

OrderDto

OrderResponse

OrderCommand

OrderModel

OrderView

for one simple workflow.

That creates mapping overhead without automatically improving architecture.

We separate models where boundaries genuinely differ.


Strong Distinctions for This Project

Two distinctions are especially important:

Request / Response DTO
≠
Domain Entity

and:

Provider DTO
≠
Application / Domain model

Persistence representation will be decided pragmatically when JPA is introduced.


Entity Lifecycle

Order has identity and lifecycle:

create
   ↓
UNPAID
 ├── markPaid()
 │       ↓
 │      PAID
 │
 └── cancel()
         ↓
     CANCELLED

That identity-based lifecycle is part of what makes Order an Entity.


Value Object Lifecycle

A Value Object does not usually have identity-based lifecycle.

For example:

ProductId(UUID A)

does not transition into:

ProductId(UUID B)

We simply have a different value.


DTO Lifecycle

A request DTO typically exists only for:

deserialize
    ↓
validate/map
    ↓
discard

A response DTO typically exists for:

construct
    ↓
serialize
    ↓
discard

This is very different from the persistent lifecycle of an Entity.


Order Owns Its OrderItems

OrderItem's value-like classification also supports Order ownership.

Order should not expose:

order.items()
        .add(...);

because external mutation could violate the Order's invariants.

The Order owns its item collection.


Immutable OrderItem Fits Our Requirements

Current requirements do not allow:

add item after Order creation

remove item after Order creation

change quantity after Order creation

change purchase-time price

Therefore immutable OrderItem is a strong fit.

Its historical price must remain stable.


Historical Price Strengthens Value-Like Semantics

unitPriceCents represents:

price at successful Order creation

If we allowed:

orderItem.setUnitPriceCents(...);

we could rewrite history.

Immutability protects that historical fact.


Reference Product Through ProductId

Prefer:

OrderItem(
        ProductId productId,
        int quantity,
        long unitPriceCents
)

rather than making OrderItem own a live:

Product product

OrderItem needs:

historical Product reference

not ownership of current Product state.

This reduces coupling between Order and Product.


API Representation Does Not Need Domain Types

Domain:

ProductId

HTTP:

{
  "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"
}

The Handler maps between them.

The Domain does not need Jackson annotations just to control HTTP formatting.


Current Classification

Based on our established v1 model:

Entities

Product

Order

Their identity remains stable while their state changes.


Stateful Identity-Bearing Domain Concept

Inventory

Its identity is:

ProductId

and its availableQuantity changes over time.

There is no independent InventoryId.


Owned Value-Like Domain Object

OrderItem

It has:

no independent business identity

immutable purchase-time state

lifecycle owned by Order

There is no OrderItemId.


Value Objects

ProductId

OrderId

CustomerId

These are already part of our canonical model.


DTOs

Examples include:

CreateOrderRequest

CreateOrderItemRequest

OrderResponse

OrderItemResponse

ProductResponse

ProviderPaymentRequest

ProviderPaymentResponse

Their responsibility is boundary communication.


This Classification Can Evolve

Suppose a future requirement says:

Refund a specific OrderItem independently.

Or:

Track fulfilment status for each OrderItem separately.

Then independent OrderItem identity may become useful.

At that point, its classification may evolve.

We do not future-proof by inventing that identity today.


Entity and Value Object Checklist

When modeling a new Domain concept, ask:

Does identity matter independently
of current values?

Does state change while identity
remains stable?

Could two objects with equal-looking
state still represent different things?

If identity does not matter,
is the concept defined by its values?

Should the value be immutable?

Does the type protect useful
Domain semantics?

DTO Checklist

For boundary data, ask:

Which boundary is this crossing?

What fields does the boundary need?

Which fields are untrusted?

Are we exposing server-owned state?

Does this DTO accidentally mirror
the entire Domain object?

Could an internal Domain change
accidentally change the API?

Are provider-specific fields leaking
outside the integration boundary?

Common Mistake — Every Database Row Is an Entity

A technical database key does not automatically create meaningful Domain identity.


Common Mistake — JPA Defines the Domain

@Entity is a persistence annotation.

It does not determine whether a business concept has Entity semantics.


Common Mistake — Domain Entity as Request DTO

Do not deserialize untrusted client data directly into the Order Entity.

That exposes too much server-owned state and couples HTTP to the Domain.


Common Mistake — Domain Entity as Response Contract

Do not automatically serialize internal Domain objects as the public API contract.

Explicit response DTOs provide better control.


Common Mistake — Value Object for Every Primitive

Typed IDs provide real benefit.

Wrapping every primitive does not.

Add Value Objects strategically.


Common Mistake — Mutable Value Objects

If identity does not matter, mutable Value Object state usually makes reasoning harder.

Prefer immutable values.


Common Mistake — Business Logic in DTOs

DTOs transfer data.

They should not own Order lifecycle or authoritative calculations.


Common Mistake — Provider DTO in UseCase

PayOrderUseCase should depend on:

PaymentService

PaymentResult

not:

ProviderPaymentResponse

Provider protocol stays inside the integration boundary.


Common Mistake — Inventing OrderItem Identity

Current business requirements do not manage OrderItems independently.

Do not create OrderItemId merely because another system might someday need it.


Common Mistake — One Class for API, Domain, and Persistence by Accident

Sometimes sharing one type may be pragmatic.

But it should be a deliberate trade-off.

Do not accidentally erase important boundaries merely because one class is convenient.


How This Fits Our Architecture

Incoming request:

HTTP
    ↓
DTO
    ↓
Handler
    ↓
UseCase

Inside the application:

Entities
+
Value Objects

represent business concepts.

Persistence:

UseCase
    ↓
Repository

External integration:

UseCase
    ↓
PaymentService
    ↓
Payment integration
    ↓
Provider DTOs

Each type stays close to the responsibility it exists to serve.


Example: Create Order

Incoming:

CreateOrderRequest
[DTO]

The Handler obtains:

CustomerId
[Value Object]

The UseCase loads:

Product
[Entity]

and works with:

Inventory
[stateful identity-bearing concept]

It creates:

OrderItem
[owned value-like Domain object]

then:

Order
[Entity]

Finally the Handler maps the result to:

OrderResponse
[DTO]

This gives us a clear path from transport to Domain and back.


Engineering Principle

The core principle is:

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

Another:

Do not let database keys, JPA annotations, Java syntax, or JSON shape decide what a business concept means.

And:

Separate models where responsibilities genuinely differ, but do not multiply classes without a concrete reason.


Summary

In this lesson, we established that:

  • Entities, Value Objects, and DTOs may all carry data but exist for different reasons.
  • Entity identity matters independently of current mutable state.
  • Product and Order are clear Domain Entities.
  • Inventory is mutable identity-bearing Domain state naturally identified by ProductId.
  • Inventory does not need an InventoryId.
  • OrderItem has no independent business lifecycle or identity in v1.
  • OrderItem is modeled as an immutable owned value-like Domain object.
  • OrderItem persistence identity is (order_id, product_id).
  • A database primary key does not automatically imply Domain Entity identity.
  • JPA @Entity and Domain Entity are different concepts.
  • Value Objects are defined by their values.
  • Value Objects are usually immutable.
  • ProductId and OrderId are UUID-backed Value Objects.
  • CustomerId is a String-backed Value Object representing externally resolved identity.
  • Typed IDs improve type safety and communicate intent.
  • UUID-backed IDs should not use irrelevant positive-number validation.
  • We should not wrap every primitive without meaningful benefit.
  • Quantity remains int in v1.
  • Money remains integer long cents in v1 rather than BigDecimal, double, or float.
  • We do not introduce a Money Value Object without richer monetary requirements.
  • Entities commonly contain Value Objects.
  • Java record is a strong implementation option for immutable Value Objects.
  • Java record does not determine architectural classification.
  • Records are also useful for DTOs.
  • Mutable lifecycle Entities such as Product, Inventory, and Order are generally better represented as regular classes.
  • Regular classes should still protect mutation through meaningful business operations.
  • DTOs exist to transfer data across boundaries.
  • HTTP DTOs belong near the Handler/API boundary.
  • UUIDs are represented as strings in our HTTP API.
  • Domain Entities must not be used directly as untrusted request bodies.
  • Clients do not control Customer ownership, Order status, authoritative prices, totals, IDs, or creation time.
  • Response DTOs keep the public API separate from Domain internals.
  • DTOs should not own business behaviour.
  • A UseCase Command is application input and may be separate from the HTTP DTO when that distinction adds value.
  • We do not mechanically create Request → Command → Input → Model layers.
  • Simple mapping does not require a mapping framework.
  • Provider request/response DTOs stay inside the Payment integration boundary.
  • PayOrderUseCase depends on application-facing Payment abstractions rather than provider schemas.
  • DTOs may temporarily contain invalid external data.
  • Domain and Value Object instances should offer stronger guarantees after validation.
  • Transport validation does not replace Domain invariants.
  • HTTP-specific rules do not belong in the Domain.
  • Entity equality is identity-oriented.
  • Value Object equality is value-oriented.
  • DTO equality is normally incidental to its transport role.
  • API models, Domain models, and persistence models are conceptually separate responsibilities.
  • We separate concrete classes only when the boundary provides enough value to justify the mapping cost.
  • OrderItem classification may evolve only if future business requirements give it independent identity or lifecycle.
  • Our model continues to implement the architecture already established rather than allowing persistence or transport concerns to redefine it.

Next lesson:

Separating Domain Logic from Transport Logic

There, we will follow a request through HTTP → Handler → UseCase → Domain and determine exactly which validation, mapping, authorization context, and business decisions belong at each boundary—without allowing Spring MVC or JSON concerns to leak into the Domain.