Building REST APIs
Request Validation
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
| Rule | Primary Owner |
|---|---|
| Product create name present/nonblank | Request validation |
Product create priceCents present | Request validation |
Product request priceCents >= 0 | Request validation |
| Product itself cannot hold negative price | Product |
| Inventory request quantity present | Request validation |
| Inventory request quantity non-negative | Request validation |
| Inventory itself cannot become negative | Inventory |
| Create Order items non-empty | Request validation + Order invariant |
| Product ID request value nonblank | Request validation |
| Product/Order UUID syntax | Handler boundary parsing |
| Requested Order quantity present/positive | Request validation + OrderItem invariant |
| Duplicate Product references | CreateOrderUseCase + Order invariant |
| Product exists | CreateOrderUseCase |
| Product is active/orderable | CreateOrderUseCase using Product state |
| Inventory can be consumed | InventoryRepository.consumeIfAvailable(...) coordinated by CreateOrderUseCase |
| Customer owns Order | Ownership-aware UseCase/Repository query |
| Order can be cancelled | Order.cancel() |
| Order can be marked paid | Order.markPaid() |
| Persisted quantity integrity | Domain + 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.
