Building REST APIs

Request Validation

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

We have already designed request DTOs such as:

public record CreateProductRequest(
        String name,
        Long priceCents
) {
}
public record SetInventoryRequest(
        Integer availableQuantity
) {
}
public record CreateOrderRequest(
        List<CreateOrderItemRequest> items
) {
}

and:

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

But these DTOs can still represent invalid input.

For example:

{
  "name": "",
  "priceCents": -1000
}

or:

{
  "items": []
}

or:

{
  "items": [
    {
      "productId": "",
      "quantity": 0
    }
  ]
}

Obvious request problems should be rejected as early as possible at the HTTP boundary.

In Spring Boot, we can use:

Jakarta Bean Validation

for this purpose.

The goal of this lesson is:

Validate transport-level input early while keeping live business validation, Domain invariants, and persistence integrity in the boundaries that actually own them.


Validation Is Not One Thing

The word:

validation

covers several different responsibilities.

In our application, there are four important boundaries:

Transport validation
        ↓
Application / UseCase validation
        ↓
Domain invariants
        ↓
Database integrity

Each protects something different.

Treating all of them as:

"validation logic"

and placing them in one layer produces poor architecture.


Transport Validation

Transport validation answers questions that can be decided from the request itself.

Examples:

Was a required field supplied?

Is a string blank?

Is a collection empty?

Is a numeric value within
its basic permitted range?

Can an identifier string
be parsed into its required representation?

Does the request have
the expected basic structure?

For example:

CreateOrderItemRequest.quantity > 0

can be checked without:

database access

authenticated business state

Product lookup

Inventory lookup

That makes it appropriate for the HTTP boundary.


Application / UseCase Validation

Other rules require live application state or coordination between multiple concepts.

Examples:

Does Product exist?

Is Product active?

Was the same Product requested twice?

Does the Order belong
to the authenticated Customer?

Can Inventory actually be consumed?

These may require:

Repositories

authenticated identity

multiple Domain objects

transaction semantics

concurrency control

They belong in the UseCase workflow.


Domain Validation

Domain objects protect invariants of the state they own.

Examples:

Product priceCents cannot be negative.

Inventory availableQuantity
cannot be negative.

OrderItem quantity must be positive.

Order cannot be empty.

Order cannot contain
duplicate Product lines.

A PAID Order cannot be cancelled.

These invariants remain protected even if the HTTP request was already validated.

A valid Domain object should not depend on one particular Handler having validated it first.


Database Integrity

PostgreSQL later reinforces persisted structural integrity.

Examples include:

NOT NULL

foreign keys

CHECK constraints

primary keys

composite keys

For our current schema:

CHECK (price_cents >= 0)

and:

CHECK (available_quantity >= 0)

reinforce important invariants.

The database is the final persistence guard.

It is not our primary HTTP request-validation mechanism.


Jakarta Bean Validation

Jakarta Bean Validation provides annotations such as:

@NotNull

@NotBlank

@NotEmpty

@Positive

@PositiveOrZero

@Size

@Valid

These are useful for describing local request requirements directly on transport DTOs.


Activating Validation With @Valid

In a Spring Handler:

@PostMapping
public ResponseEntity<ProductResponse> create(
        @Valid
        @RequestBody
        CreateProductRequest request
) {
    // ...
}

@Valid tells Spring to validate the deserialized DTO before normal Handler execution continues.

Conceptually:

HTTP JSON
    ↓
Jackson deserialization
    ↓
Bean Validation
    ↓
valid?
    ├── no
    │    ↓
    │  centralized error handling
    │    ↓
    │  400 VALIDATION_ERROR
    │
    └── yes
         ↓
       Handler
         ↓
       UseCase

If transport validation fails:

UseCase is not invoked.

CreateProductRequest

Our Product creation requirements are:

name required

name nonblank

priceCents required

priceCents >= 0

A suitable DTO is:

package io.tarkev.ordermanagement.product.handler;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;

public record CreateProductRequest(

        @NotBlank
        String name,

        @NotNull
        @PositiveOrZero
        Long priceCents

) {
}

Notice that the transport type is:

Long

rather than:

long

That distinction is important.


Why Long priceCents?

Suppose we used:

long priceCents

and the client sent:

{
  "name": "Keyboard"
}

A primitive may deserialize to its default value:

0

But zero is a valid Product price.

Then we could not reliably distinguish:

client omitted priceCents

from:

client intentionally sent
priceCents = 0

For a required request field, presence matters.

Using:

Long

allows:

missing
→ null
→ @NotNull rejects it

while:

{
  "priceCents": 0
}

remains valid.


Why @PositiveOrZero?

Our accepted Product invariant is:

priceCents >= 0

not:

priceCents > 0

Free Products are not prohibited by our requirements.

Therefore:

@PositiveOrZero

matches the business rule.

Using:

@Positive

would silently invent a new restriction.

Validation annotations are part of Product behavior at the API boundary.

They must reflect actual requirements.


Product Name Validation

For Product creation:

@NotBlank
String name

rejects:

null

""

"   "

This matches the Product invariant that a name must be meaningful.

The Domain still protects the same rule.


Do Not Invent a Product Name Length Yet

We could write:

@Size(max = 200)
String name

But our Product requirements have not defined:

maximum Product name = 200 characters

So we should not invent that rule merely because a maximum often exists in other systems.

If a later API or database decision introduces a documented maximum, we can add it intentionally.


Why Product Still Validates Its Own State

Even though the request has:

@PositiveOrZero
Long priceCents

Product still protects:

priceCents >= 0

during creation and changePrice(...).

The guarantees are different.

Transport:

Bean Validation
→ this HTTP request satisfies
  the public request contract

Domain:

Product
→ Product cannot intentionally
  hold an invalid price

Both protections are useful.


This Is Not Wasteful Duplication

Consider the rule:

priceCents cannot be negative

At the transport boundary, it protects:

external input quality

Inside Product, it protects:

business-state correctness

A future test, migration, internal workflow, or another transport could construct Product without going through this HTTP DTO.

The Domain invariant must still hold.


SetInventoryRequest

Our Admin operation is:

set exact available quantity

with the rule:

availableQuantity >= 0

The request should be:

package io.tarkev.ordermanagement.inventory.handler;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;

public record SetInventoryRequest(

        @NotNull
        @PositiveOrZero
        Integer availableQuantity

) {
}

Again, the wrapper type is deliberate.


Why Not int availableQuantity?

Consider:

{}

If the DTO uses:

int availableQuantity

the value may become:

0

But:

0

is a legitimate Inventory quantity.

We want to distinguish:

field omitted
→ invalid request

from:

{
  "availableQuantity": 0
}

which means:

set Inventory exactly to zero

So transport uses:

Integer

while Domain Inventory can still use:

int

after presence and validity have been established.


Transport Types Do Not Need to Match Domain Types Exactly

This is an important principle.

Transport:

Integer

may represent:

present

missing

Domain:

int

represents:

a quantity that is already known
to exist

Different boundaries sometimes need different representations.

That is not inconsistency.

It reflects different responsibilities.


CreateOrderRequest

Our Create Order request is:

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

The basic request rules are:

items required

items non-empty

each item structurally valid

each Product ID supplied

each quantity supplied

each quantity > 0

Bean Validation can enforce these local request-shape rules.


Validate the Item Collection

A suitable DTO is:

package io.tarkev.ordermanagement.order.handler;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;

public record CreateOrderRequest(

        @NotEmpty
        @Valid
        List<CreateOrderItemRequest> items

) {
}

@NotEmpty rejects:

null

empty list

@Valid tells Jakarta Validation to recursively validate the nested item DTOs.


CreateOrderItemRequest

A suitable nested DTO is:

package io.tarkev.ordermanagement.order.handler;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public record CreateOrderItemRequest(

        @NotBlank
        String productId,

        @NotNull
        @Positive
        Integer quantity

) {
}

This protects basic transport structure.


Why Integer quantity?

Consider:

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

and:

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

Both requests are invalid.

But they fail for different reasons.

The first is:

quantity missing

The second is:

quantity supplied
but invalid

Using:

Integer

with:

@NotNull
@Positive

preserves that distinction.


Why @Positive?

Our accepted Order rule is:

quantity > 0

So:

@Positive

correctly rejects:

0

negative values

We do not need a custom validator for this rule.


Whole Number Is Already Expressed by the Type

The DTO uses:

Integer quantity

So normal Jackson deserialization already expresses the expectation that quantity is an integer-compatible value.

We do not need to invent:

@WholeNumber

for an integer field.

Use the Java type system when it already communicates the constraint.


Nested Validation Matters

Suppose the client sends:

{
  "items": [
    {
      "productId": "",
      "quantity": -2
    }
  ]
}

The list itself is:

non-null

non-empty

So:

@NotEmpty

alone would pass.

Without nested:

@Valid

the validator may not recursively check:

items[0].productId

items[0].quantity

That is why nested validation is important.


UUID Validation

Our Product and Order ID strategy is now established:

HTTP
→ UUID string

Domain
→ ProductId / OrderId
  wrapping java.util.UUID

For request DTOs, we still do not need a complicated UUID regular expression.

A Product ID can enter the Handler as:

String productId

and the transport boundary converts it:

new ProductId(
        UUID.fromString(
                rawProductId
        )
);

If parsing fails:

request identifier representation invalid

and centralized transport error handling maps it to:

400
INVALID_REQUEST

Use the actual UUID parser rather than maintaining a handwritten regex for the same syntax.


Path Variable Parsing

For:

GET /api/v1/orders/{orderId}

the Handler may receive:

@PathVariable
String orderId

and convert it to:

new OrderId(
        UUID.fromString(
                orderId
        )
);

A malformed UUID is not:

ORDER_NOT_FOUND

because there is no valid OrderId to look up.

It is:

invalid request representation

and maps to:

400
INVALID_REQUEST

A syntactically valid UUID that does not identify an accessible Order maps to:

404
ORDER_NOT_FOUND

These are different failures.


Duplicate Product Validation

Suppose Create Order contains:

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

Could we create a custom annotation such as:

@UniqueProductIds

on CreateOrderRequest?

We could.

But our current architecture deliberately does not.


Duplicate Products Belong Primarily to CreateOrderUseCase

Duplicate Product detection understands that:

these identifiers represent
Product references inside one Order

It is part of the Create Order operation.

The rule must also hold if Create Order is called later from:

another transport

internal workflow

test

So the primary early rejection remains in:

CreateOrderUseCase

before:

Product loading

Inventory mutation

Order persistence

Order Still Protects the Final Invariant

From Domain modeling, we also established:

A valid Order cannot contain
duplicate Product lines.

So:

CreateOrderUseCase
→ rejects duplicates early
  before side effects

while:

Order
→ protects final Domain validity

This is deliberate layered protection.


Product Existence Is Not Bean Validation

Avoid something like:

@ExistingProduct
String productId;

if the custom validator calls:

ProductRepository

Why?

Because now:

transport validation
→ persistence query

and CreateOrderUseCase still needs Product state afterward.

That introduces:

unnecessary coupling

duplicated lookup

additional database work

and possibly stale validation.


Product Existence Belongs in CreateOrderUseCase

The correct flow is:

CreateOrderUseCase
    ↓
ProductRepository.findById(...)

If Product does not exist:

PRODUCT_NOT_FOUND

is raised and mapped to:

404 Not Found

The Bean Validator does not need to query PostgreSQL to establish this.


Product Active State Is Not Request Validation

Likewise, avoid:

@OrderableProduct
String productId;

if it needs to load Product and inspect:

active

Whether a Product is currently orderable depends on live application state.

That belongs to:

CreateOrderUseCase

using Product state.

If inactive:

PRODUCT_NOT_ORDERABLE
→ 409 Conflict

Inventory Availability Is Definitely Not DTO Validation

Avoid:

@SufficientInventory
CreateOrderItemRequest item;

if the validator reads Inventory.

Availability must be decided:

inside the actual Create Order operation

with the correct transaction
and concurrency semantics

A request validator cannot safely guarantee Inventory availability.


Check-Then-Act Race

Imagine a validator performs:

availableQuantity = 5

requestedQuantity = 5

validation passes

Before the UseCase runs, another transaction consumes all five units.

Now:

availableQuantity = 0

The earlier validation is already stale.

That is why live Inventory state must be checked at the mutation boundary.


Our Canonical Inventory Check

During Create Order, we do not use:

Bean Validator
    ↓
InventoryRepository.find(...)
    ↓
check quantity

and we do not use the race-prone workflow:

load Inventory
    ↓
check quantity
    ↓
decrease Java object
    ↓
save

Instead, CreateOrderUseCase calls:

inventoryRepository.consumeIfAvailable(
        productId,
        quantity
);

backed by the atomic conditional update established in ADR-005:

UPDATE inventory
SET available_quantity =
        available_quantity - :quantity
WHERE product_id = :productId
  AND available_quantity >= :quantity;

If the update affects no row:

INSUFFICIENT_INVENTORY

is raised.

This is a business-state conflict:

409 Conflict

not a Bean Validation failure.


Validation Must Be Side-Effect Free

Transport validation must never:

reserve Inventory

consume Inventory

restore Inventory

create Product

create Order

write database state

call Payment provider

It inspects request input.

The UseCase performs the business operation.


Authorization Is Not Bean Validation

Avoid trying to express:

Admin required

through an annotation on:

CreateProductRequest

Request validation answers:

Is this input structurally acceptable?

Authorization answers:

Is this authenticated caller allowed to perform this operation?

Spring Security and application security own the second question.


Ownership Is Not Bean Validation

Likewise, avoid a validator such as:

@OwnedOrder
String orderId;

That would require:

authenticated CustomerId

Order Repository

ownership state

Our Customer Order workflow instead uses ownership-aware queries such as:

findOwnedBy(
        orderId,
        customerId
);

or:

findOwnedForUpdate(
        orderId,
        customerId
);

Missing and non-owned Orders both become:

404
ORDER_NOT_FOUND

for Customer-facing operations.


Payment Eligibility Is Not Bean Validation

Avoid:

@PayableOrder
String orderId;

Payment eligibility depends on current Order state and later on Payment integration behavior.

The correct flow is:

PayOrderUseCase
    ↓
load Customer-owned Order
    ↓
verify current eligibility
    ↓
PaymentService
    ↓
confirmed result
    ↓
recheck current Order state
    ↓
order.markPaid()

Bean Validation does not belong in that workflow.


Transport Validation Should Be Cheap

Transport validation should generally use:

request-local values

not:

database calls

network calls

external Service calls

This keeps it:

fast

predictable

side-effect free

easy to reason about

Live application state belongs inside the actual UseCase.


Bean Validation Does Not Replace JSON Parsing Errors

Suppose the client sends:

{
  "priceCents": "not-a-number"
}

Jackson may fail before Bean Validation runs.

There may be no valid:

CreateProductRequest

instance to validate.

This is still an invalid request.

Centralized HTTP error handling should translate it to:

400
INVALID_REQUEST

rather than leaking a Jackson exception.


Request Processing Pipeline

A useful mental model is:

HTTP bytes
    ↓
JSON parsing
    ↓
DTO construction
    ↓
Bean Validation
    ↓
Handler conversion
    ↓
UseCase coordination
    ↓
Domain invariants
    ↓
Repository / Database integrity

Every stage rejects the problems it owns.


Example — Invalid JSON

Request:

{
  "quantity":

fails during:

JSON parsing

No valid DTO exists.

No UseCase runs.

Result:

400 Bad Request

with a safe:

INVALID_REQUEST

Problem Details response.


Example — Missing Product Price

Request:

{
  "name": "Keyboard"
}

deserializes to:

priceCents = null

Then:

@NotNull

fails.

The UseCase does not run.

Result:

400
VALIDATION_ERROR

with a field error for:

priceCents

Example — Product Does Not Exist

Request:

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

Transport validation passes:

productId
→ nonblank
→ valid UUID after Handler parsing

quantity
→ supplied
→ positive

Then:

CreateOrderUseCase

loads Product.

Repository finds no Product.

Application failure:

PRODUCT_NOT_FOUND

maps to:

404 Not Found

This is the correct boundary.


Example — Product Is Inactive

The same request is structurally valid.

The Product exists but:

active = false

CreateOrderUseCase observes current Product state and rejects the operation:

PRODUCT_NOT_ORDERABLE

mapped to:

409 Conflict

This is not DTO invalidity.


Example — Insufficient Inventory

The request is valid:

quantity = 5

but current Inventory can satisfy only:

2

CreateOrderUseCase attempts:

inventoryRepository.consumeIfAvailable(
        productId,
        5
);

The atomic operation fails to consume.

Application error:

INSUFFICIENT_INVENTORY

maps to:

409 Conflict

Again, this is not request validation.


Example — Negative Inventory Admin Input

Request:

{
  "availableQuantity": -5
}

Bean Validation rejects:

@PositiveOrZero

before:

SetInventoryUseCase

runs.

Response:

400
VALIDATION_ERROR

If an internal caller later executes:

inventory.setAvailableQuantity(
        -5
);

the Domain also rejects it.

Two boundaries.

Two different guarantees.


Example — Paid Order Cancellation

Request:

POST /api/v1/orders/{orderId}/cancel

has no malformed body.

The Order ID is a valid UUID.

Authentication succeeds.

The ownership-aware locked query finds the Customer-owned Order.

But the Order is:

PAID

Then:

order.cancel();

throws:

OrderNotCancellable

The HTTP layer maps that to:

409
ORDER_NOT_CANCELLABLE

This is business-state validation.

It is not transport validation.


Never Preload Domain State in the Handler "For Validation"

Bad:

@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(
        @PathVariable
        String orderId
) {
    Order order =
            orderRepository.findById(
                    ...
            );

    if (
            order.status() !=
            OrderStatus.UNPAID
    ) {
        // return conflict
    }

    return cancelOrder.execute(
            ...
    );
}

Now the Handler:

queries application state

duplicates lifecycle rules

creates a race between
pre-check and real operation

The Handler should convert input and invoke:

CancelOrderUseCase

The UseCase and Domain own the workflow.


Custom Validators

Custom Bean Validation constraints can be useful for reusable local input rules.

For example, if a future requirement establishes a reusable format such as:

SKU must follow a specific syntax

and several DTOs use that format, a custom validator may be reasonable.

But a custom Bean Validator should not become a disguised:

Repository layer

security layer

external Service client

business workflow

Do Not Use a UUID Regex When the UUID Parser Already Exists

Our ID contract is now UUID-based.

We could add a complex:

@Pattern(...)
String productId;

But that duplicates UUID syntax rules.

A simpler boundary is:

@NotBlank
+
UUID.fromString(...)

The first checks basic presence.

The second performs authoritative representation parsing.

Avoid maintaining regex logic when the platform already knows how to parse the type.


Query Parameter Validation

Our pagination contract is already established:

page
→ zero-based
→ default 0

size
→ default 20
→ min 1
→ max 100

These query parameters also require transport validation.

For example, a Handler may eventually validate:

page >= 0

1 <= size <= 100

We will handle the complete pagination implementation in its dedicated lesson.

The same principle applies:

pagination input shape
→ HTTP boundary

Handler Pattern With @Valid

Product:

@PostMapping
public ResponseEntity<ProductResponse> create(
        @Valid
        @RequestBody
        CreateProductRequest request
) {
    // map and delegate
}

Inventory:

@PutMapping("/{productId}")
public InventoryResponse set(
        @PathVariable
        String productId,

        @Valid
        @RequestBody
        SetInventoryRequest request
) {
    // map and delegate
}

Order:

@PostMapping
public ResponseEntity<OrderResponse> create(
        @Valid
        @RequestBody
        CreateOrderRequest request
) {
    // map and delegate
}

This is our standard transport-validation pattern.


What Happens When Bean Validation Fails?

Spring typically raises a framework validation exception before normal Handler logic executes.

We do not return that framework exception directly.

Conceptually:

Bean Validation failure
        ↓
central error handler
        ↓
Problem Details
        ↓
400 VALIDATION_ERROR

The response should describe our API.

Not Spring internals.


Canonical Validation Error Response

For example:

{
  "type": "https://api.tarkev.com/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/orders",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "items[0].quantity",
      "code": "INVALID_VALUE",
      "message": "Quantity must be positive."
    }
  ]
}

For a missing required field:

{
  "field": "priceCents",
  "code": "REQUIRED",
  "message": "Price is required."
}

Stable machine-readable codes are more important than forcing clients to parse human-readable messages.


Do Not Expose Framework Exception Names

Bad:

{
  "error": "MethodArgumentNotValidException"
}

or:

{
  "error": "HttpMessageNotReadableException"
}

Clients should understand:

VALIDATION_ERROR

INVALID_REQUEST

not Spring implementation details.


Validation Messages

Annotations may provide messages:

@NotBlank(
        message = "Product name is required."
)
String name

That can improve API responses.

But human-readable wording may evolve.

Clients should rely on:

field

stable field error code

rather than parsing message text.


@NotNull, @NotEmpty, and @NotBlank

These annotations solve different problems.

@NotNull:

rejects null

Useful when a value must be present.

For example:

@NotNull
Integer availableQuantity

@NotEmpty:

rejects null

rejects empty collection/string

Useful for:

@NotEmpty
List<CreateOrderItemRequest> items

@NotBlank:

rejects null

rejects empty string

rejects whitespace-only string

Useful for:

Product name

and basic presence of:

Product ID string

before UUID parsing.


Numeric Constraints

For our current requirements:

Product priceCents
→ @PositiveOrZero
Inventory availableQuantity
→ @PositiveOrZero
Order quantity
→ @Positive

These constraints mirror established Domain semantics.


Do Not Invent @Max Rules

We could add:

@Max(100)
Integer quantity

but no requirement says:

maximum Order quantity = 100

Likewise, no requirement currently defines:

maximum Product price

maximum Inventory quantity

beyond what the chosen Java/database representations naturally support.

Do not invent product rules merely to demonstrate more validation annotations.


Product Update Validation Is Different

Create Product requires:

name

priceCents

Product PATCH allows both fields to be omitted.

Therefore we cannot reuse:

CreateProductRequest

for:

UpdateProductRequest

This is another reason operation-specific DTOs matter.


UpdateProductRequest

Our canonical PATCH model is:

package io.tarkev.ordermanagement.product.handler;

import jakarta.validation.constraints.PositiveOrZero;

public record UpdateProductRequest(

        String name,

        @PositiveOrZero
        Long priceCents

) {
}

Our semantics are:

name == null
→ unchanged

priceCents == null
→ unchanged

{}
→ valid no-op

and:

priceCents = 0
→ explicitly set Product price to zero

Optional Name Validation

We cannot simply write:

@NotBlank
String name

on UpdateProductRequest.

Why?

Because @NotBlank also rejects:

null

but:

name = null

means:

no name update requested

under our PATCH contract.

For now, the pragmatic flow is:

name == null
→ do nothing

name != null
→ Product.rename(name)

Product.rename(...) rejects blank values.

That failure is then translated to a controlled:

400

API error.

We do not need a custom optional-not-blank Bean Validator simply to make this DTO look more sophisticated.


Empty PATCH Is a Valid No-Op

Our Product PATCH decision is already settled.

This request:

{}

is accepted.

It means:

no Product fields changed

We therefore do not add:

@AtLeastOneFieldPresent

or similar custom validation.

That would contradict the canonical API contract.


Bean Validation Is Not Authorization

Do not use request-validation annotations to answer:

Is caller ADMIN?

That belongs to:

Spring Security

and our authorization model.


Bean Validation Is Not Ownership Validation

Do not use DTO annotations to answer:

Does this Customer own this Order?

That requires authenticated identity and persisted Order state.

It belongs to the UseCase/repository access pattern.


Bean Validation Is Not Payment Validation

Do not use Bean Validation to answer:

Can this Order be paid?

That belongs to:

PayOrderUseCase
+
Order.markPaid()

with external Payment coordination where required.


Security May Run Before DTO Validation

Spring Security filters may reject an unauthenticated request before the request reaches Handler validation.

So a protected endpoint may return:

401 Unauthorized

even if its JSON body is also malformed.

That is fine.

Security and transport validation are separate boundaries.


Do Not Depend on Framework Validation Order for Correctness

Whether:

authentication

or:

request-body validation

runs first does not define our business correctness.

Once a UseCase executes:

UseCase

Domain

Repository

must still protect the business rules they own.


Validation Testing

Transport validation should be tested through the HTTP boundary.

Useful cases include:

empty Product name
→ 400

missing priceCents
→ 400

negative priceCents
→ 400

missing availableQuantity
→ 400

negative availableQuantity
→ 400

empty Order items
→ 400

missing Order quantity
→ 400

zero Order quantity
→ 400

malformed Product UUID
→ 400

These tests verify observable API behavior and Spring configuration.


Do Not Test Only Annotation Presence

A weak test asks:

Does CreateProductRequest.name
have @NotBlank?

A stronger test sends:

{
  "name": "",
  "priceCents": 1000
}

and verifies:

400

VALIDATION_ERROR

field = name

Test the contract where practical.


Domain Tests Remain Separate

Even though HTTP validation rejects negative Product price, Product still deserves a unit test such as:

@Test
void productRejectsNegativePrice() {
    // ...
}

That test protects Product independently of:

Spring MVC

Bean Validation

HTTP

UseCase Tests Cover Live-State Rules

CreateOrderUseCase tests should cover:

duplicate Product IDs

missing Product

inactive Product

insufficient Inventory

Order access UseCases should cover:

missing Order

non-owned Order

and the canonical existence-hiding result.

Those tests do not require HTTP.


Current DTO Validation Set

Our Product creation DTO becomes:

public record CreateProductRequest(

        @NotBlank
        String name,

        @NotNull
        @PositiveOrZero
        Long priceCents

) {
}

Inventory:

public record SetInventoryRequest(

        @NotNull
        @PositiveOrZero
        Integer availableQuantity

) {
}

Create Order:

public record CreateOrderRequest(

        @NotEmpty
        @Valid
        List<CreateOrderItemRequest> items

) {
}

Order item:

public record CreateOrderItemRequest(

        @NotBlank
        String productId,

        @NotNull
        @Positive
        Integer quantity

) {
}

Product PATCH:

public record UpdateProductRequest(

        String name,

        @PositiveOrZero
        Long priceCents

) {
}

These constraints cover confirmed local request rules without introducing database-backed custom validators or speculative limits.


Add the Validation Dependency When We Need It

This is the point in the project where request validation becomes a real feature.

So this is the appropriate ticket to add Spring Boot's validation support.

For Gradle:

implementation(
    "org.springframework.boot:spring-boot-starter-validation"
)

This follows our project principle:

Add dependencies when a concrete capability requires them.

We did not need this dependency during the original minimal bootstrap.

Now we do.


Do Not Put Jakarta Validation Annotations on Domain State by Default

We could write:

public class Product {

    @PositiveOrZero
    private long priceCents;
}

But then Product validity partly depends on:

someone invoking a Validator

Our Domain already protects its invariants directly through Java code.

For core Domain state, explicit guards are clearer and framework-independent.

Use Jakarta Bean Validation primarily at the transport boundary in this course.


Domain Should Remain Valid Without Spring

A Product unit test should be able to construct and modify Product directly and receive invariant protection.

It should not require:

Spring ApplicationContext

Jakarta Validator

Spring proxy

for Product to remain valid.

This is an important part of keeping our Domain plain.


Validation Is About Trust Boundaries

Think about data becoming more trustworthy as it moves inward:

Raw JSON
→ untrusted transport input

Validated DTO
→ HTTP request contract satisfied

Handler conversion
→ transport representations parsed

UseCase
→ live application state checked

Domain objects
→ intrinsic invariants satisfied

Repository / PostgreSQL
→ persisted integrity protected

Each stage increases confidence.

No stage should pretend to solve another stage's responsibility.


Validation Responsibility Table

RulePrimary Owner
Product create name present/nonblankRequest validation
Product create priceCents presentRequest validation
Product request priceCents >= 0Request validation
Product itself cannot hold negative priceProduct
Inventory request quantity presentRequest validation
Inventory request quantity non-negativeRequest validation
Inventory itself cannot become negativeInventory
Create Order items non-emptyRequest validation + Order invariant
Product ID request value nonblankRequest validation
Product/Order UUID syntaxHandler boundary parsing
Requested Order quantity present/positiveRequest validation + OrderItem invariant
Duplicate Product referencesCreateOrderUseCase + Order invariant
Product existsCreateOrderUseCase
Product is active/orderableCreateOrderUseCase using Product state
Inventory can be consumedInventoryRepository.consumeIfAvailable(...) coordinated by CreateOrderUseCase
Customer owns OrderOwnership-aware UseCase/Repository query
Order can be cancelledOrder.cancel()
Order can be marked paidOrder.markPaid()
Persisted quantity integrityDomain + PostgreSQL + transaction/repository strategy

That is the separation we want.


Request Validation Checklist

Before adding a validation rule, ask:

Can this rule be decided
using only request data?

Is it part of the public
request contract?

Am I accidentally querying
live application state?

Does the Domain still need
to protect this invariant?

Am I inventing a rule
that requirements never defined?

Does a primitive type hide
whether a required field was omitted?

Is this really request validation,
or is it authorization,
ownership, concurrency,
or business workflow?

These questions prevent Bean Validation from growing into another application layer.


Common Mistakes

Using Bean Validation for all business validation is a mistake because it cannot safely answer live questions such as Product existence or Inventory availability.

Database calls inside custom validators couple HTTP validation to persistence and create check-then-act races.

Removing Domain guards because DTO annotations exist allows internal callers to construct invalid business state.

Using primitives for required request fields can hide missing values behind defaults such as zero.

Adding arbitrary Product-name, price, Inventory, or Order-quantity limits invents requirements.

Creating a custom duplicate-Product Bean Validator is unnecessary when CreateOrderUseCase already owns the early application check.

A Product-existence validator on a request DTO duplicates Repository work and puts live state in the wrong boundary.

Authorization and Order ownership are not request-shape validation.

Reusing CreateProductRequest for PATCH incorrectly makes optional update fields required.

Returning raw Spring or Jackson exception names leaks implementation details into the API.


Engineering Principle

The core principle is:

Validate request shape at the HTTP boundary, validate live business conditions inside the UseCase, and let Domain objects protect the invariants of the state they own.

Another:

Bean Validation should reject bad input early, but it must not become a hidden persistence, authorization, concurrency, or business-workflow layer.

And:

As data moves inward through the application, each boundary should strengthen its guarantees without taking over the responsibilities of the next boundary.


Summary

In this lesson, we established that:

Transport validation, UseCase validation, Domain invariants, and database integrity are separate responsibilities.

Jakarta Bean Validation is appropriate for request-local input constraints.

@Valid activates nested DTO validation at the Spring MVC boundary.

@NotNull, @NotBlank, @NotEmpty, @Positive, and @PositiveOrZero cover most of our confirmed transport rules.

Product creation uses Long priceCents so a missing field can be distinguished from valid zero pricing.

priceCents uses integer cents throughout the authoritative API and Domain model.

Product price uses @PositiveOrZero because zero is valid.

Product name uses @NotBlank during creation.

We do not invent an arbitrary Product-name maximum.

SetInventoryRequest uses Integer availableQuantity so omission is distinguishable from explicit zero.

Inventory quantity is required and non-negative.

CreateOrderRequest.items is non-empty.

Nested Order item DTOs are validated using @Valid.

Order item quantity uses Integer, @NotNull, and @Positive.

Product and Order identifiers are UUID strings at the HTTP boundary.

We use UUID parsing rather than a handwritten UUID regular expression.

Malformed UUID syntax is a 400 / INVALID_REQUEST.

A valid but missing ProductId becomes 404 / PRODUCT_NOT_FOUND.

A valid but inaccessible/missing OrderId becomes 404 / ORDER_NOT_FOUND for Customer-facing access.

Bean Validation does not replace Domain invariants.

Duplicate Product references remain an early CreateOrderUseCase rule and a defensive Order invariant.

Product existence is a UseCase/Repository concern.

Product active state is a Create Order workflow concern.

Inventory availability is not Bean Validation.

Create Order uses InventoryRepository.consumeIfAvailable(...) to enforce Inventory availability atomically at the persistence boundary.

Repository-backed request validators would create stale check-then-act behavior.

Transport validation must remain side-effect free.

Authorization is not Bean Validation.

Customer Order ownership is not Bean Validation.

Payment eligibility is not Bean Validation.

Jackson/deserialization failures occur before Bean Validation and still map to 400 / INVALID_REQUEST.

Validation errors map centrally to our RFC 7807-style VALIDATION_ERROR response.

Field-level errors use stable machine-readable codes such as REQUIRED and INVALID_VALUE.

Clients must not receive Spring exception names.

Product PATCH validation differs from Product creation because fields are optional.

Our PATCH contract keeps null fields unchanged.

An empty Product PATCH remains a valid no-op.

Optional non-null Product names are ultimately protected by Product.rename(...).

Spring Security may reject authentication before body validation, and that is acceptable.

Business correctness must not depend on framework validation ordering.

HTTP tests verify observable transport validation behavior.

Domain tests still protect Domain invariants independently of Spring.

UseCase tests cover live-state rules such as missing Product, inactive Product, duplicate Product references, insufficient Inventory, and ownership.

Spring Boot validation support should be added now because request validation is now a concrete feature.

Jakarta validation annotations do not replace explicit Domain guards.

Domain objects remain valid and testable without Spring or a Validator framework.

Next lesson:

Consistent API Error Responses

There, we will implement our canonical RFC 7807 Problem Details contract using centralized Spring error handling, including type, title, status, detail, instance, stable code, and field-level errors[] for validation failures—while mapping PRODUCT_NOT_FOUND, ORDER_NOT_FOUND, PRODUCT_NOT_ORDERABLE, INSUFFICIENT_INVENTORY, ORDER_NOT_CANCELLABLE, ORDER_NOT_PAYABLE, ACCESS_DENIED, and unexpected INTERNAL_ERROR consistently.