Building REST APIs

Request and Response Models

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

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 name and priceCents.
  • 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 priceCents allows PATCH to distinguish no update from an explicit zero price.
  • Inventory target identity remains in the URI.
  • SetInventoryRequest contains only availableQuantity.
  • Create Order accepts only Product IDs and quantities.
  • CustomerId comes from authenticated identity.
  • OrderId, status, authoritative price, createdAt, and total remain server-controlled.
  • OrderResponse may expose historical unitPriceCents and derived totalCents.
  • OrderResponse includes createdAt as an absolute timestamp.
  • ProductId and OrderId are represented as UUID strings in JSON.
  • Authoritative money values use integer cents throughout the API.
  • CreateOrderItemRequest and OrderItemResponse remain 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.