Domain Modeling
Entities, Value Objects, and DTOs
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
@Entityand Domain Entity are different concepts. - Value Objects are defined by their values.
- Value Objects are usually immutable.
ProductIdandOrderIdare UUID-backed Value Objects.CustomerIdis 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
intin v1. - Money remains integer
longcents in v1 rather thanBigDecimal,double, orfloat. - We do not introduce a Money Value Object without richer monetary requirements.
- Entities commonly contain Value Objects.
- Java
recordis a strong implementation option for immutable Value Objects. - Java
recorddoes 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.
PayOrderUseCasedepends 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.
