Building REST APIs
Request and Response Models
We have now defined our HTTP endpoints and the basic Spring Handler structure.
For example:
POST /api/v1/products
PATCH /api/v1/products/{productId}
PUT /api/v1/inventory/{productId}
POST /api/v1/orders
GET /api/v1/orders
GET /api/v1/orders/{orderId}
The next part of the API contract is just as important:
Request Model
Response Model
In Spring MVC, these are usually represented as DTOs.
A good API design does not define only:
method + path
It must also define:
What data is the client allowed to send?
and:
What data does the server intentionally expose as public contract?
The goal of this lesson is:
Create explicit, safe, operation-specific transport DTOs while keeping them separate from Domain and persistence models.
DTOs Exist at a Boundary
DTO means:
Data Transfer Object
Its responsibility is to carry data across a boundary.
For HTTP input:
JSON
↓
Request DTO
↓
Handler
For HTTP output:
Handler
↓
Response DTO
↓
JSON
A DTO is not automatically:
a Domain Entity
a persistence Entity
an application Command
Those concepts may contain similar fields, but they serve different responsibilities.
Request and Response Models Have Different Jobs
A common mistake is to create one universal type:
public record ProductDto(
String id,
String name,
long priceCents,
boolean active
) {
}
and reuse it for:
Product creation
Product update
Product response
Customer browse
Admin management
That looks simple at first.
But the authority of each operation is different.
For Product creation, the client may control:
name
priceCents
The server controls:
ProductId
initial active state
A request model should reflect that distinction directly.
CreateProductRequest
Canonical endpoint:
POST /api/v1/products
Request:
{
"name": "Mechanical Keyboard",
"priceCents": 10000
}
A suitable DTO is:
package io.tarkev.ordermanagement.product.api;
public record CreateProductRequest(
String name,
long priceCents
) {
}
It contains exactly what an authorized client is allowed to request.
It does not contain:
id
active
because those are server-owned.
ProductResponse
The server may return:
{
"id": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"name": "Mechanical Keyboard",
"priceCents": 10000,
"active": true
}
For an Admin-facing Product representation, that may map to:
public record ProductResponse(
String id,
String name,
long priceCents,
boolean active
) {
}
The request and response are intentionally asymmetric.
That is normal.
Client Input and Server Output Are Not Symmetric
A client may send:
name
priceCents
while the server returns:
id
name
priceCents
active
There is no rule saying:
request fields
==
response fields
Trying to force symmetry often creates unsafe or confusing contracts.
Why One Product DTO Is Risky
If ProductDto is reused for both input and output, it must contain:
id
active
because responses need them.
Now the create endpoint appears to accept:
{
"id": "...",
"name": "Keyboard",
"priceCents": 10000,
"active": false
}
Even if the server currently ignores those fields, the contract becomes ambiguous.
Someone may later accidentally trust them.
Operation-specific DTOs make authority visible.
UpdateProductRequest
Canonical endpoint:
PATCH /api/v1/products/{productId}
Our settled PATCH semantics are:
name omitted/null
→ unchanged
priceCents omitted/null
→ unchanged
{}
→ accepted no-op
and:
priceCents = 0
→ valid explicit update
So a pragmatic request model is:
public record UpdateProductRequest(
String name,
Long priceCents
) {
}
Long is deliberate.
A primitive:
long
cannot represent:
no update requested
while still allowing:
0
as a real value.
PATCH Semantics Must Be Deliberate
For example:
{
"priceCents": 12000
}
means:
change priceCents
leave name unchanged
An empty request:
{}
means:
make no Product changes
And:
{
"priceCents": 0
}
means:
set current Product price to zero
because zero is valid in our Domain.
This is why PATCH input must be modeled carefully rather than accidentally.
What Does null Mean in Our PATCH Contract?
For this course, our accepted rule is:
null
→ no update requested
So:
{
"name": null
}
does not mean:
clear Product name
because Product name cannot be null or blank.
Likewise:
{
"priceCents": null
}
does not mean:
set price to null
Money is not nullable Domain state.
It simply means:
leave current price unchanged
This is a pragmatic v1 contract.
We do not introduce a general JSON Patch framework.
Request DTO Is Still Untrusted Input
Even a typed record such as:
new CreateProductRequest(
"",
-100
);
can exist.
DTO construction does not make data valid.
The request model represents:
untrusted transport input
The transition toward trusted business state is protected by:
transport validation
UseCase coordination
Domain invariants
These are complementary boundaries.
DTO Validation and Domain Invariants Are Different
Later we may validate:
CreateProductRequest.name
→ required
CreateProductRequest.priceCents
→ non-negative
at the transport boundary.
Product still protects:
name must be nonblank
priceCents >= 0
inside the Domain.
Why both?
Because Product might also be created through:
tests
internal application flows
migration tooling
A valid Domain object must not depend on one HTTP Handler having run first.
Inventory Request Model
Canonical endpoint:
PUT /api/v1/inventory/{productId}
The target identity is already in the URI.
Request:
{
"availableQuantity": 20
}
DTO:
package io.tarkev.ordermanagement.inventory.api;
public record SetInventoryRequest(
int availableQuantity
) {
}
There is no reason to duplicate:
productId
inside the request body.
Avoid Duplicated Identity
Bad:
PUT /api/v1/inventory/59ba787c-6d83-4a21-9bae-19c3d0469bd5
with:
{
"productId": "364ec915-01ab-4cf0-b19f-327ed160f42b",
"availableQuantity": 20
}
Now we have two conflicting identities.
Which one wins?
Better:
URI
→ resource identity
request body
→ requested mutable input
So SetInventoryRequest contains only the quantity.
InventoryResponse
A response may be:
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"availableQuantity": 20
}
DTO:
public record InventoryResponse(
String productId,
int availableQuantity
) {
}
The response includes Product identity because the client needs to know which Inventory state was returned.
CreateOrderRequest Is a Strong Authority Boundary
Order creation is one of the most important places where request DTO design protects the system.
The client may provide:
ProductId
quantity
for each requested line.
The client must not provide authoritative:
CustomerId
OrderId
OrderStatus
unitPriceCents
totalCents
createdAt
Those values are determined by the server.
Canonical CreateOrderRequest
package io.tarkev.ordermanagement.order.api;
import java.util.List;
public record CreateOrderRequest(
List<CreateOrderItemRequest> items
) {
}
with:
public record CreateOrderItemRequest(
String productId,
int quantity
) {
}
This expresses the Customer's intent:
I want this Product in this quantity.
Nothing more.
Bad CreateOrderRequest
Avoid:
public record CreateOrderRequest(
String customerId,
String status,
long totalCents,
List<CreateOrderItemRequest> items
) {
}
and:
public record CreateOrderItemRequest(
String productId,
int quantity,
long unitPriceCents
) {
}
That would make the client appear authoritative over:
ownership
lifecycle
pricing
total
which contradicts our Domain and API design.
Server Builds Trusted Order State
The correct flow is:
CreateOrderRequest
↓
ProductId + quantity
↓
OrderHandler
↓
CreateOrderUseCase
The backend then obtains:
CustomerId
→ authenticated identity
Product current priceCents
→ Product state
OrderId
→ application-generated UUID
OrderStatus
→ UNPAID
createdAt
→ Instant.now(clock)
totalCents
→ derived from OrderItems
Only after that does the application construct the Order.
Request Model Represents Intent, Not Result
The client says:
I want two units of Product X.
The client does not say:
The unit price is 2000.
The total is 4000.
This Order is UNPAID.
This Order belongs to Customer Y.
Those are server-determined facts.
This distinction should be visible directly in the request model.
OrderResponse
A Customer needs a useful representation of the resulting Order.
For example:
{
"id": "d2e7b44e-463f-4bbc-b16f-b1c80bc80442",
"status": "UNPAID",
"createdAt": "2026-10-01T00:20:00Z",
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2,
"unitPriceCents": 2000,
"totalCents": 4000
}
],
"totalCents": 4000
}
A corresponding DTO may be:
import java.time.Instant;
import java.util.List;
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
) {
}
Why Response Can Include Purchase-Time Price
The create request does not contain authoritative:
unitPriceCents
But the response may expose it.
Why?
Because the response contains:
server-confirmed historical Order state
OrderItem.unitPriceCents was captured from current Product price when Order creation succeeded.
The client may read that value.
The client did not choose it.
Derived Values Are Fine in Responses
The response may also contain:
OrderItem.totalCents
Order.totalCents
even though those values are derived.
That is useful client-facing information.
A response DTO is allowed to expose convenient representations of server-owned state.
The important rule is:
server may return derived values
client must not become authoritative
for those values on future mutations
CreateOrderItemRequest and OrderItemResponse Must Stay Separate
Incoming Order item:
productId
quantity
Outgoing Order item:
productId
quantity
unitPriceCents
totalCents
These are different contracts.
So separate:
CreateOrderItemRequest
OrderItemResponse
is clearer than a universal:
OrderItemDto
Avoid Universal OrderDto
A type such as:
public record OrderDto(
String id,
String customerId,
String status,
List<OrderItemDto> items,
long totalCents
) {
}
often ends up being reused for:
create
history
detail
cancel
payment
admin
Then every operation begins exposing or accepting fields it does not actually own.
Prefer contract-specific models where semantics differ materially.
But Avoid DTO Explosion
The opposite extreme is also unnecessary.
We do not need:
CreateOrderResponse
CancelOrderResponse
PayOrderResponse
GetOrderResponse
if all of those endpoints intentionally return the same public Order representation.
A shared:
OrderResponse
is appropriate when the semantics genuinely match.
The rule is:
Reuse because the contract is the same, not merely because two DTOs look similar.
List Response vs Detail Response
A future Order-history list may need only:
id
status
createdAt
totalCents
while Order detail needs full:
items
If that distinction becomes useful, we may introduce:
OrderSummaryResponse
OrderResponse
But we do not introduce two models before the API actually benefits from the difference.
Customer and Admin Representations May Diverge Later
For Customer Product browsing, our established minimal representation is:
id
name
priceCents
Because inclusion already means:
active = true
AND
Inventory > 0
there is little value in returning:
active = true
for every row unless the Customer contract needs it.
Admin Product management may need:
active
because Admins manage lifecycle state.
If Customer and Admin contracts materially diverge, separate response types may become appropriate.
Do not create them prematurely.
DTO Design Follows Audience and Operation
For every response, ask:
Who receives this?
What do they need?
What are they allowed to know?
Which fields are stable public contract?
Which fields are internal implementation details?
This is more useful than:
Which fields happen to exist on the Entity?
Customer OrderResponse Does Not Need CustomerId
For:
GET /api/v1/orders/{orderId}
an authenticated Customer is already viewing an ownership-scoped resource.
Returning:
customerId
usually adds little value.
So our Customer-facing OrderResponse does not expose it.
Admin Order views may eventually need Customer identity.
That can be handled when the Admin contract requires it.
Keep Persistence Fields Out of Responses
Persistence may later contain:
foreign-key columns
ORM metadata
technical timestamps
internal lock/version information
Those do not automatically belong in the API.
Public fields are deliberate contract decisions.
Do Not Return JPA Entities Directly
Avoid:
return orderEntity;
from the Handler.
This can accidentally expose:
persistence relationships
internal columns
lazy-loading behavior
ORM-specific structure
It also couples database mapping changes to the public API.
Use response DTOs.
Do Not Return Domain Entities Directly by Default
Likewise:
return order;
couples:
Domain structure
to:
public JSON
A future Domain refactor could accidentally break clients.
Explicit mapping creates a deliberate boundary.
Typed Domain IDs, Simple Transport IDs
Internally:
ProductId(UUID)
OrderId(UUID)
CustomerId(String)
provide useful type safety.
Externally, ProductId and OrderId are represented as:
JSON strings
For example:
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"
}
The Handler or response mapper translates between typed Domain identity and simple transport representation.
Why Not Serialize ProductId Directly?
Suppose Jackson serialized:
ProductId(
UUID value
)
using its object structure.
The JSON might become something conceptually like:
{
"productId": {
"value": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"
}
}
But our public contract wants:
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5"
}
Response mapping keeps that representation intentional.
ProductResponse Mapping
For an Admin-oriented Product representation:
public record ProductResponse(
String id,
String name,
long priceCents,
boolean active
) {
public static ProductResponse from(
Product product
) {
return new ProductResponse(
product.id()
.value()
.toString(),
product.name(),
product.priceCents(),
product.isActive()
);
}
}
This mapping is simple and explicit.
No mapping framework is required.
InventoryResponse Mapping
Conceptually:
public record InventoryResponse(
String productId,
int availableQuantity
) {
public static InventoryResponse from(
Inventory inventory
) {
return new InventoryResponse(
inventory.productId()
.value()
.toString(),
inventory.availableQuantity()
);
}
}
Again, Domain identity becomes a simple public string.
OrderItemResponse Mapping
Conceptually:
public record OrderItemResponse(
String productId,
int quantity,
long unitPriceCents,
long totalCents
) {
public static OrderItemResponse from(
OrderItem item
) {
return new OrderItemResponse(
item.productId()
.value()
.toString(),
item.quantity(),
item.unitPriceCents(),
item.totalCents()
);
}
}
The response exposes historical values already owned by OrderItem.
OrderResponse Mapping
Conceptually:
public record OrderResponse(
String id,
String status,
Instant createdAt,
List<OrderItemResponse> items,
long totalCents
) {
public static OrderResponse from(
Order order
) {
List<OrderItemResponse> items =
order.items()
.stream()
.map(
OrderItemResponse::from
)
.toList();
return new OrderResponse(
order.id()
.value()
.toString(),
order.status()
.name(),
order.createdAt(),
items,
order.totalCents()
);
}
}
Transport conversion remains outside the Domain.
Is status().name() Acceptable?
Our Domain enum is:
public enum OrderStatus {
UNPAID,
PAID,
CANCELLED
}
If we intentionally publish:
UNPAID
PAID
CANCELLED
as API values, then:
order.status().name()
is pragmatic.
But we should recognize the coupling.
Once clients depend on:
"UNPAID"
renaming the Java enum constant to something else could become an API-breaking change.
Explicit Status Mapping Is an Option
If we later want stronger separation, we could define:
public enum OrderStatusResponse {
UNPAID,
PAID,
CANCELLED
}
or map explicitly to strings.
That adds code.
For our current small API, direct mapping is acceptable because these values are intentionally part of the contract.
The important thing is to make that decision consciously.
Java Records Fit DTOs Well
DTOs are usually:
small
data-oriented
immutable after construction
So Java records are a strong fit.
For example:
public record SetInventoryRequest(
int availableQuantity
) {
}
We do not need mutable setters for simple transport data.
Records Do Not Provide Validation Automatically
This is valid Java construction:
new SetInventoryRequest(
-5
);
unless some validation protects the boundary.
Immutability means:
the value does not change later
It does not mean:
the value is automatically valid
Transport validation is introduced separately.
Domain invariants still remain important.
JSON Field Names Are Public Contract
Java:
availableQuantity
naturally becomes JSON:
{
"availableQuantity": 20
}
through normal Jackson conventions.
That is simple and clear.
Avoid adding custom:
@JsonProperty(...)
annotations everywhere without a real naming requirement.
Once published, field names become part of the client contract.
Prefer Clear API Vocabulary
Prefer:
availableQuantity
priceCents
unitPriceCents
totalCents
over abbreviations such as:
qtyAvail
amt
Clients should not need internal context to understand basic fields.
API Names Should Not Mirror Database Names
A PostgreSQL column may eventually be:
available_quantity
because SQL naming conventions differ.
That does not mean JSON needs to expose the same syntax.
Our public API vocabulary remains:
availableQuantity
Transport naming should reflect application meaning rather than schema implementation.
Money Representation in the API
Our canonical monetary model is:
Java
→ long cents
PostgreSQL
→ BIGINT
HTTP JSON
→ integer minor units
Examples:
{
"priceCents": 10000
}
{
"unitPriceCents": 2000,
"totalCents": 4000
}
This keeps the API aligned with our Domain and persistence decisions.
We do not convert authoritative monetary values to:
double
float
BigDecimal
at the HTTP boundary.
Why Minor Units Are Useful at the Boundary
Using integer cents gives clients an explicit representation:
10000
→ 100.00
without introducing:
binary floating-point ambiguity
hidden decimal-scale assumptions
different server/client rounding behavior
There is no multi-currency model in v1.
We therefore do not introduce a larger Money JSON object until requirements need one.
Date and Time Response
Order owns:
Instant createdAt
The response should expose it as an absolute timestamp.
For example:
{
"createdAt": "2026-10-01T00:20:00Z"
}
Avoid locale-specific canonical API strings such as:
01/10/2026 03:20
because their interpretation depends on locale and timezone conventions.
Product Browse Response Should Stay Small
Customer browse currently needs:
id
name
priceCents
because the query already guarantees:
active = true
AND
Inventory.availableQuantity > 0
We have not established that Customers need exact:
availableQuantity
Therefore we should not automatically expose it.
A smaller Customer browse model may be:
public record ProductBrowseResponse(
String id,
String name,
long priceCents
) {
}
if the Admin Product response needs additional state such as active.
Do Not Add Fields Merely Because They Are Always True
If:
GET /api/v1/products
returns only active/orderable Products, returning:
{
"active": true
}
for every Customer browse item may be redundant unless clients genuinely need that information.
Every field creates contract surface.
Expose useful meaning, not everything the server knows.
Response Models Should Be Deliberately Bounded
Every exposed field becomes:
documentation
support burden
compatibility surface
potential security/privacy surface
Therefore:
Return what clients need, not everything the server knows.
Keep Provider Fields Behind PaymentService
A future Payment provider may return:
providerTransactionId
rawStatus
gatewayCode
debugMessage
Those do not automatically belong in:
OrderResponse
or any public Payment response.
Provider protocol remains behind:
PaymentService
If clients later need a public Payment reference, define an application-facing field deliberately.
Never Expose Workflow Escape Hatches
Avoid request fields such as:
{
"skipInventoryCheck": true,
"forcePaid": true,
"ignoreProductStatus": true
}
These expose internal controls and can bypass business invariants.
Public request DTOs should represent legitimate Product operations.
Avoid Generic Request Maps
Bad:
public record GenericRequest(
Map<String, Object> data
) {
}
Now every Handler must manually determine:
Which keys exist?
What types are they?
Which operation do they represent?
Typed request DTOs make the contract explicit.
Avoid Generic Response Envelopes Without a Requirement
Some platforms wrap everything:
{
"success": true,
"data": {
...
}
}
That can be a valid platform-wide standard.
But we do not introduce one simply because it looks more "enterprise".
Our current API focuses on:
HTTP status
typed success body
consistent Problem Details errors
If a broader platform convention later requires an envelope, that becomes a separate API decision.
Response DTO Is Not a Database View
Suppose Product and Inventory are stored separately.
The Customer Product response may still be:
{
"id": "...",
"name": "Keyboard",
"priceCents": 10000
}
after the query has filtered by Inventory availability.
No table needs to contain exactly that shape.
Response DTOs are:
application-facing projections
not database mirrors.
Read Queries Can Be Optimized Later
A Product browse query may eventually return a projection such as:
ProductBrowseItem
instead of constructing complete Product and Inventory objects.
That is a persistence/query optimization.
The public:
ProductBrowseResponse
can remain stable.
This is one benefit of keeping API contracts explicit.
DTO Placement
Our capability-first package structure places HTTP DTOs near their Handler.
Product:
product/
└── api/
├── ProductHandler.java
├── CreateProductRequest.java
├── UpdateProductRequest.java
├── ProductResponse.java
└── ProductBrowseResponse.java
Inventory:
inventory/
└── api/
├── InventoryHandler.java
├── SetInventoryRequest.java
└── InventoryResponse.java
Order:
order/
└── api/
├── OrderHandler.java
├── CreateOrderRequest.java
├── CreateOrderItemRequest.java
├── OrderResponse.java
└── OrderItemResponse.java
Transport types stay close to the boundary that owns them.
Avoid a Global dto/ Package
Avoid:
dto/
├── ProductDto.java
├── OrderDto.java
├── InventoryDto.java
├── PaymentDto.java
└── ...
This quickly becomes a mixed dumping ground.
Capability ownership becomes harder to see.
Keep DTOs near:
product/api
inventory/api
order/api
instead.
Application Commands Are Different From HTTP Requests
If we introduce:
CreateOrderCommand
that is an application type.
It belongs near:
order/application
not in order/api.
The flow is:
CreateOrderRequest
↓
OrderHandler
↓
CreateOrderCommand
↓
CreateOrderUseCase
Request and Command may look similar.
They still represent different boundaries.
Request vs Command
Remember:
Request
→ HTTP contract
Command
→ application operation input
A request may contain:
String productId
while the Command may contain:
ProductId
This separation is useful when complexity justifies it.
A Command Is Not Mandatory Ceremony
For a simple Product request:
createProduct.execute(
request.name(),
request.priceCents()
);
may be perfectly clear.
We do not need:
CreateProductCommand
merely because every UseCase "should" have one.
Use additional types when they improve the design.
Do Not Pass HTTP DTOs Into Domain Objects
Avoid:
Product.create(
request
);
where request is:
CreateProductRequest
Now the Domain conceptually depends on an HTTP model.
Better:
Handler
→ request fields
→ UseCase
→ Domain values
The Domain receives business values rather than transport wrappers.
Handling Lists Safely
CreateOrderRequest contains:
List<CreateOrderItemRequest>
Transport records are not our final invariant boundary.
When the Handler maps them into application input, it can create a new list.
Then Order protects its own internal collection through:
List.copyOf(
items
);
Each layer protects what it owns.
Stable Response Collections
Mapping:
order.items()
.stream()
.map(
OrderItemResponse::from
)
.toList();
produces a stable response collection for normal use.
Again, DTO immutability is useful.
It does not replace Domain invariants.
Request DTOs Are an Authority Boundary
For every request field, ask:
If the client changes this value, should the server trust it as authoritative input to this operation?
Examples:
CreateOrderRequest.customerId
→ No
CreateOrderRequest.totalCents
→ No
CreateOrderItemRequest.unitPriceCents
→ No
CreateOrderItemRequest.quantity
→ Yes, as requested intent,
subject to validation
CreateProductRequest.priceCents
→ Yes, for an authorized Admin,
subject to validation
If the answer is:
No
strongly consider whether the field should be absent from the request model entirely.
Response DTOs Are an Exposure Boundary
For every response field, ask:
Does this client actually need this information as part of the public contract?
Examples:
Order.status
→ Yes
Order.createdAt
→ Yes
Order.totalCents
→ Yes
CustomerId in Customer Order response
→ Usually no
database implementation state
→ No
raw provider response
→ No
access token
→ Absolutely not
The fact that the server possesses a value is not enough reason to expose it.
Never Return Secrets
Response DTOs must never contain:
passwords
password hashes
API keys
access tokens
refresh tokens
provider secrets
database credentials
Explicit response models make accidental leakage less likely.
Error DTOs Must Also Be Bounded
Our Problem Details responses must not expose:
stack traces
SQL
Java class names
internal file paths
provider secrets
Error handling is covered separately, but the same exposure-boundary principle applies.
Public Fields Create Versioning Pressure
Suppose we expose:
{
"internalPaymentProvider": "provider-x"
}
and later migrate providers.
An infrastructure implementation detail has now become public API state.
Clients may depend on it.
This is why DTOs should expose:
application meaning
rather than:
internal implementation
DTOs Reduce Coupling
Without an explicit DTO boundary:
Domain change
→ API changes
With explicit mapping:
Domain change
↓
mapping
↓
stable API contract
Likewise, a database mapping change does not automatically require an API change.
This flexibility matters in production systems.
Mapping Has a Cost
Explicit DTOs require code such as:
new ProductResponse(
product.id()
.value()
.toString(),
product.name(),
product.priceCents(),
product.isActive()
);
That is additional code.
But at a public API boundary, the cost is usually small compared with the benefit of controlling the contract deliberately.
Do Not Introduce Mapping Libraries Prematurely
Our mappings are currently simple.
We do not need to introduce:
MapStruct
ModelMapper
reflection-based mappers
for a handful of fields.
Manual mapping is:
explicit
easy to debug
easy to review
If mapping complexity grows substantially, we can reconsider later.
DTO Naming
Prefer names that communicate direction and operation:
CreateProductRequest
UpdateProductRequest
ProductResponse
ProductBrowseResponse
SetInventoryRequest
InventoryResponse
CreateOrderRequest
CreateOrderItemRequest
OrderResponse
OrderItemResponse
Avoid generic names such as:
ProductModel
OrderPayload
CommonData
ObjectDto
unless they represent a genuine shared concept.
Current Product DTO Direction
Admin creation request:
public record CreateProductRequest(
String name,
long priceCents
) {
}
PATCH request:
public record UpdateProductRequest(
String name,
Long priceCents
) {
}
Customer browse response may remain:
public record ProductBrowseResponse(
String id,
String name,
long priceCents
) {
}
Admin-oriented Product response may be:
public record ProductResponse(
String id,
String name,
long priceCents,
boolean active
) {
}
We introduce audience-specific representations only where the contract actually differs.
Current Inventory DTO Direction
public record SetInventoryRequest(
int availableQuantity
) {
}
public record InventoryResponse(
String productId,
int availableQuantity
) {
}
Product identity is not duplicated in the mutation body.
Current Order DTO Direction
public record CreateOrderRequest(
List<CreateOrderItemRequest> items
) {
}
public record CreateOrderItemRequest(
String productId,
int quantity
) {
}
Response:
public record OrderResponse(
String id,
String status,
Instant createdAt,
List<OrderItemResponse> items,
long totalCents
) {
}
public record OrderItemResponse(
String productId,
int quantity,
long unitPriceCents,
long totalCents
) {
}
These contracts preserve the authority boundaries we have established.
DTOs We Intentionally Do Not Have
Current scope does not require:
CreateCustomerRequest
CustomerResponse
independent CreateOrderItemResponse
UpdateOrderStatusRequest
SetOrderTotalRequest
ChangeOrderOwnerRequest
CreatePaymentRequest with invented provider fields
Those operations do not exist in our accepted v1 design.
Common Mistake — One DTO for Everything
Create, update, and read operations often have different authority.
Use one model only when the contract semantics are genuinely the same.
Common Mistake — Domain Entity as Request Body
This exposes Domain structure to untrusted clients and may make server-owned fields writable.
Use explicit request DTOs.
Common Mistake — JPA Entity as Response
Persistence details are not the API contract.
Use explicit response DTOs.
Common Mistake — URI Identity Repeated in Body
Avoid creating two potentially conflicting identities.
Common Mistake — Client Sends Order Price or Total
Pricing authority belongs to the backend.
Common Mistake — Client Sends CustomerId for Own Order
Ownership comes from authenticated identity.
Common Mistake — Client Sends OrderStatus
Lifecycle changes are business operations:
pay
cancel
not arbitrary field assignments.
Common Mistake — Provider DTO Becomes Public API
External integration protocol must remain behind PaymentService.
Common Mistake — Response Exposes Every Internal Field
Every field creates compatibility and security cost.
Expose only what the client needs.
Common Mistake — Mapping Framework for Five Fields
Manual mapping is simpler until complexity actually earns another abstraction.
Request Model Review Checklist
For every request DTO, ask:
Does every field represent something
the caller is allowed to request?
Are server-owned values absent?
Is identity duplicated
between URI and body?
Does authenticated identity
come from security context?
Does the DTO represent
one clear operation?
Are transport and Domain
responsibilities separated?
Could this request bypass
a business workflow?
Are money fields represented
as integer cents?
Response Model Review Checklist
For every response DTO, ask:
Does the client need each field?
Are UUIDs exposed as strings?
Are money values exposed
as integer cents?
Is createdAt represented
as an absolute timestamp?
Are persistence details hidden?
Are provider-specific fields hidden?
Are secrets impossible
to serialize through this model?
Is the JSON shape intentional?
Can Domain implementation change
without unnecessarily breaking the API?
Is this response appropriately small
for its audience and operation?
Engineering Principle
The core principle is:
Request models define what the client is allowed to ask for; response models define what the server intentionally exposes.
Another:
Do not mirror Domain or database objects blindly across the HTTP boundary.
And:
A good DTO makes authority explicit: client-controlled intent comes in, while server-controlled business state comes out.
Summary
In this lesson, we established that:
- Request and response DTOs are HTTP boundary models.
- DTOs are not Domain Entities or persistence Entities.
- Request and response contracts often need different fields.
- Create Product accepts
nameandpriceCents. - Product identity and initial active state remain server-controlled.
- Product PATCH requires deliberate optional-field semantics.
- Our Product PATCH contract treats omitted/null values as unchanged.
Long priceCentsallows PATCH to distinguish no update from an explicit zero price.- Inventory target identity remains in the URI.
SetInventoryRequestcontains onlyavailableQuantity.- Create Order accepts only Product IDs and quantities.
- CustomerId comes from authenticated identity.
- OrderId, status, authoritative price, createdAt, and total remain server-controlled.
OrderResponsemay expose historicalunitPriceCentsand derivedtotalCents.OrderResponseincludescreatedAtas an absolute timestamp.- ProductId and OrderId are represented as UUID strings in JSON.
- Authoritative money values use integer cents throughout the API.
CreateOrderItemRequestandOrderItemResponseremain separate because their authority differs.- Shared response models are appropriate when the output semantics truly match.
- We avoid creating DTO variants before a real contract difference exists.
- Customer and Admin response models may diverge later if their information needs materially differ.
- Customer-facing Order responses do not need CustomerId by default.
- Persistence details do not automatically become API fields.
- Domain and JPA objects should not be serialized directly by default.
- Explicit mapping protects the API from internal refactors.
- Java records are a good fit for immutable transport DTOs.
- Record immutability does not replace validation.
- JSON field names are part of the public contract.
- API vocabulary should be clear and independent of database naming.
- Product browse responses should remain intentionally small.
- Exact Inventory quantity should not be exposed to Customers without a requirement.
- Provider-specific fields remain behind
PaymentService. - Request DTOs must not contain workflow-bypass flags.
- Typed request/response DTOs are preferable to generic maps.
- Generic success envelopes are not introduced without a broader platform requirement.
- Response DTOs are application projections, not database views.
- Optimized read projections can evolve behind a stable API contract.
- HTTP DTOs live in capability-local
api/packages. - Application Commands remain distinct from HTTP Request DTOs.
- Commands are introduced only when they improve the application boundary.
- Domain objects should not accept HTTP DTO wrappers directly.
- Request DTOs form an authority boundary.
- Response DTOs form an exposure boundary.
- Secrets and internal exception data must never appear in normal response DTOs.
- Public fields create versioning pressure, so every exposed field should be deliberate.
- Manual mapping is appropriate while mappings remain small.
- Mapping libraries should not be introduced prematurely.
- DTO names should communicate operation and direction clearly.
- The current API intentionally has no Customer CRUD DTOs, Order-status mutation DTO, Order-total mutation DTO, ownership-transfer DTO, or invented Payment-provider request model.
Next lesson:
HTTP Status Codes
There, we will define how our Product, Inventory, and Order endpoints use 200, 201, 204, 400, 401, 403, 404, 409, and 5xx responses, and distinguish malformed requests, missing resources, authentication failures, authorization failures, business-state conflicts, and unexpected server failures consistently.
