Building REST APIs
Controllers in Spring Boot
So far, we have designed our HTTP API contract.
Our current v1 endpoints are:
PRODUCTS
GET /api/v1/products
GET /api/v1/products/{productId}
POST /api/v1/products
PATCH /api/v1/products/{productId}
POST /api/v1/products/{productId}/deactivate
INVENTORY
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
ORDERS
POST /api/v1/orders
GET /api/v1/orders
GET /api/v1/orders/{orderId}
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/orders/{orderId}/pay
Now we need to implement those HTTP endpoints in Spring Boot.
In Spring MVC, the HTTP entry boundary is usually implemented using:
@RestController
Our architecture terminology remains:
Handler
↓
UseCase
↓
Repository
So in our application:
Spring @RestController
=
HTTP Handler
The goal of this lesson is:
Use Spring MVC to receive HTTP requests, map transport data, invoke UseCases, and return HTTP responses without moving business workflows into Controllers.
What Is a Spring MVC Controller?
A Spring MVC Controller is an HTTP entry point.
Conceptually:
HTTP Request
↓
Spring MVC
↓
Handler
↓
UseCase
The Handler understands transport concerns such as:
HTTP method
URI
path variables
query parameters
request body
authenticated request context
HTTP status
headers
response body
It should not become the place that owns:
Order lifecycle
Inventory concurrency
database queries
transactions
Payment-provider protocol
Those responsibilities belong deeper in the application.
@RestController
A simple HTTP Handler might begin as:
package io.tarkev.ordermanagement.product.api;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductHandler {
}
@RestController tells Spring that:
this class receives HTTP requests
and that its returned values should normally be serialized into:
HTTP response bodies
For JSON APIs, Jackson commonly performs the serialization.
Spring Controller vs Architectural Handler
Spring documentation commonly uses the word:
Controller
Our application architecture uses:
Handler
These are not competing concepts.
We can write:
@RestController
public class ProductHandler {
}
The class is technically a Spring Controller.
Architecturally, it is our:
HTTP Handler
For course consistency, we will prefer names such as:
ProductHandler
InventoryHandler
OrderHandler
rather than making Controller a separate architectural layer.
Controllers Are Spring Beans
@RestController is a Spring component annotation.
Spring therefore creates the Handler as a Bean inside the:
ApplicationContext
Dependencies should be supplied using constructor injection.
For example:
@RestController
public class ProductHandler {
private final CreateProductUseCase createProduct;
public ProductHandler(
CreateProductUseCase createProduct
) {
this.createProduct =
createProduct;
}
}
Spring resolves:
CreateProductUseCase
from the Application Context.
Avoid Field Injection
Avoid:
@RestController
public class ProductHandler {
@Autowired
private CreateProductUseCase createProduct;
}
Constructor injection is stronger because:
dependencies are explicit
the field can be final
construction requirements are visible
plain unit tests can instantiate the Handler
the object cannot exist without required dependencies
This is consistent with the dependency-injection principles we established earlier.
@RequestMapping
A Handler may define a common capability-level URI prefix.
For Product:
@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {
}
Now mappings inside the class are relative to:
/api/v1/products
For example:
@GetMapping
maps:
GET /api/v1/products
while:
@GetMapping("/{productId}")
maps:
GET /api/v1/products/{productId}
HTTP Method Mapping Annotations
Spring provides dedicated mapping annotations:
@GetMapping
@PostMapping
@PutMapping
@PatchMapping
@DeleteMapping
Our current API primarily needs:
@GetMapping
@PostMapping
@PutMapping
@PatchMapping
because Product deletion is not part of our v1 Domain.
Java Method Names Are Internal
Consider:
@GetMapping
public ProductPageResponse browse(...) {
...
}
The public API contract is:
GET /api/v1/products
not:
browse()
We could rename the Java method:
listProducts(...)
without changing the API, as long as the mapping remains unchanged.
Do not confuse:
Java method naming
with:
HTTP contract design
Path Variables
For:
GET /api/v1/products/{productId}
Spring binds the dynamic value using:
@PathVariable
For example:
@GetMapping("/{productId}")
public ProductResponse get(
@PathVariable String productId
) {
...
}
A request such as:
GET /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5
produces the transport value:
"59ba787c-6d83-4a21-9bae-19c3d0469bd5"
inside the Handler.
Convert Transport Types at the Boundary
HTTP represents our UUID IDs as strings.
The Domain uses:
ProductId(UUID)
OrderId(UUID)
So the Handler boundary can convert:
String
↓
UUID
↓
ProductId
For example:
private ProductId toProductId(
String rawProductId
) {
return new ProductId(
UUID.fromString(
rawProductId
)
);
}
Then:
Product product =
getProduct.execute(
toProductId(
productId
)
);
Raw HTTP strings do not need to leak unnecessarily into application code.
Why Conversion Belongs Near the Boundary
The HTTP layer knows:
UUID came from a path string
The application should work with:
ProductId
instead.
This gives us:
type safety
clear Domain vocabulary
one transport conversion boundary
Malformed UUID syntax is an HTTP/request problem and will later map to our:
400 / INVALID_REQUEST
or validation-error contract.
Request Bodies
For Product creation:
POST /api/v1/products
Content-Type: application/json
with:
{
"name": "Mechanical Keyboard",
"priceCents": 10000
}
Spring can deserialize the JSON using:
@RequestBody
For example:
@PostMapping
public ResponseEntity<ProductResponse> create(
@RequestBody
CreateProductRequest request
) {
...
}
Request DTOs
A Product creation request may be:
package io.tarkev.ordermanagement.product.api;
public record CreateProductRequest(
String name,
long priceCents
) {
}
This is:
transport data
It is not the Product Domain Entity.
The DTO represents exactly what the client is allowed to send.
Server-Owned Product Fields Stay Out of the Request
CreateProductRequest does not contain:
ProductId
active
because those values are server-controlled.
The client provides:
name
priceCents
The application decides:
ProductId
→ generated UUID
active
→ true
This preserves our trust boundary.
Handler Maps Request to UseCase Input
A straightforward Product creation flow is:
@PostMapping
public ResponseEntity<ProductResponse> create(
@RequestBody
CreateProductRequest request
) {
Product product =
createProduct.execute(
request.name(),
request.priceCents()
);
ProductResponse response =
ProductResponse.from(
product
);
return ResponseEntity
.status(
HttpStatus.CREATED
)
.body(
response
);
}
Conceptually:
JSON
↓
CreateProductRequest
↓
ProductHandler
↓
CreateProductUseCase
↓
Product
↓
ProductResponse
↓
JSON
Keep Business Workflow Out of the Handler
Avoid:
@PostMapping
public ProductResponse create(
@RequestBody
CreateProductRequest request
) {
if (
request.priceCents() < 0
) {
throw new IllegalArgumentException();
}
Product product =
Product.create(
ProductId.newId(),
request.name(),
request.priceCents()
);
productRepository.save(
product
);
return ProductResponse.from(
product
);
}
Now the Handler owns:
Domain construction
business validation
ID generation
persistence
That is application workflow.
The correct direction is:
ProductHandler
↓
CreateProductUseCase
↓
Product + ProductRepository
Thin Does Not Mean One Line
A good Handler may still legitimately:
read path variables
read query parameters
deserialize request DTOs
obtain authenticated identity
convert UUID strings
construct application input
invoke a UseCase
map output to response DTO
set HTTP status
set headers
That can require multiple lines.
"Thin Handler" means:
The Handler does not own the business workflow.
It does not mean:
The method must contain only one statement.
ResponseEntity
Spring's:
ResponseEntity<T>
is useful when the Handler needs explicit control over:
HTTP status
headers
response body
For example:
return ResponseEntity
.status(
HttpStatus.CREATED
)
.body(
response
);
This communicates:
201 Created
explicitly.
Location Header
For newly created resources, the HTTP response can include a:
Location
header.
For example:
HTTP/1.1 201 Created
Location: /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5
Spring supports this through:
ResponseEntity.created(...)
Conceptually:
URI location =
URI.create(
"/api/v1/products/" +
product.id()
.value()
);
return ResponseEntity
.created(
location
)
.body(
response
);
The Handler owns HTTP metadata.
The UseCase does not know about:
201
Location
URI
Prefer URI-Building Utilities When Useful
For simple teaching examples:
URI.create(...)
keeps the code easy to read.
Larger applications may use Spring URI-building utilities to avoid fragile string concatenation.
The important architectural rule is:
URI construction
→ HTTP boundary responsibility
not Domain or UseCase responsibility.
Capability-Focused Handlers
We do not require one Spring Controller class for every endpoint.
For example, Product can use one:
ProductHandler
containing:
GET collection
GET one
POST create
PATCH update
POST deactivate
while each operation still delegates to a dedicated UseCase.
This is a pragmatic structure.
Keep Operation-Specific UseCases
Even if one Handler groups Product routes, keep application operations separate:
BrowseProductsUseCase
GetProductUseCase
CreateProductUseCase
UpdateProductUseCase
DeactivateProductUseCase
Avoid replacing them with one generic:
ProductService
Our application convention remains:
Handler
↓
UseCase
↓
Repository
and:
Service
→ external/third-party integration
such as:
PaymentService
ProductHandler Structure
Conceptually:
package io.tarkev.ordermanagement.product.api;
@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {
private final BrowseProductsUseCase browseProducts;
private final GetProductUseCase getProduct;
private final CreateProductUseCase createProduct;
private final UpdateProductUseCase updateProduct;
private final DeactivateProductUseCase deactivateProduct;
public ProductHandler(
BrowseProductsUseCase browseProducts,
GetProductUseCase getProduct,
CreateProductUseCase createProduct,
UpdateProductUseCase updateProduct,
DeactivateProductUseCase deactivateProduct
) {
this.browseProducts =
browseProducts;
this.getProduct =
getProduct;
this.createProduct =
createProduct;
this.updateProduct =
updateProduct;
this.deactivateProduct =
deactivateProduct;
}
}
This class owns:
Product HTTP mappings
Each UseCase owns one application workflow.
Product Browse Mapping
Our Product collection is paginated.
So instead of returning an unbounded:
List<ProductResponse>
the Handler should eventually work with our pagination contract.
Conceptually:
@GetMapping
public ProductPageResponse browse(
@RequestParam(
defaultValue = "0"
)
int page,
@RequestParam(
defaultValue = "20"
)
int size
) {
return ProductPageResponse.from(
browseProducts.execute(
page,
size
)
);
}
The exact page DTO is handled in the pagination lesson.
The important point here is:
@RequestParam
→ transport input
while:
BrowseProductsUseCase
→ application query
Product Detail Mapping
Conceptually:
@GetMapping("/{productId}")
public ProductResponse get(
@PathVariable
String productId
) {
Product product =
getProduct.execute(
toProductId(
productId
)
);
return ProductResponse.from(
product
);
}
The Handler owns:
String path variable
→ ProductId
The UseCase owns Product retrieval semantics.
Product Update
Our Product PATCH request may contain:
name
priceCents
with:
omitted/null
→ unchanged
A conceptual DTO:
public record UpdateProductRequest(
String name,
Long priceCents
) {
}
Why use:
Long
instead of:
long
here?
Because our PATCH contract needs to distinguish:
field absent/null
→ no change
from:
priceCents = 0
→ explicitly set zero price
A primitive long could not represent that distinction after deserialization.
Updating Product in the Handler
Conceptually:
@PatchMapping("/{productId}")
public ProductResponse update(
@PathVariable
String productId,
@RequestBody
UpdateProductRequest request
) {
Product product =
updateProduct.execute(
toProductId(
productId
),
request.name(),
request.priceCents()
);
return ProductResponse.from(
product
);
}
The Handler does not perform:
if name supplied
→ product.rename()
if price supplied
→ product.changePrice()
That application workflow belongs in:
UpdateProductUseCase
Deactivate Product
Canonical mapping:
@PostMapping(
"/{productId}/deactivate"
)
public ProductResponse deactivate(
@PathVariable
String productId
) {
Product product =
deactivateProduct.execute(
toProductId(
productId
)
);
return ProductResponse.from(
product
);
}
The Handler does not write:
product.setActive(
false
);
It does not even load the Product itself.
It delegates the business operation to:
DeactivateProductUseCase
OrderHandler
Our Order API contains:
POST /api/v1/orders
GET /api/v1/orders
GET /api/v1/orders/{orderId}
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/orders/{orderId}/pay
A natural HTTP boundary is:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderHandler {
}
Again, one Handler may group several cohesive Order routes.
Create Order Request
The request DTO contains only Customer-controlled intent.
public record CreateOrderRequest(
List<CreateOrderItemRequest> items
) {
}
with:
public record CreateOrderItemRequest(
String productId,
int quantity
) {
}
Notice what is absent:
customerId
OrderId
status
unitPriceCents
totalCents
createdAt
Those values remain server-controlled.
Customer Identity Does Not Come From the Body
Eventually, Spring Security will provide the authenticated Customer identity.
Conceptually:
CustomerId customerId =
currentUser.customerId();
Then:
createOrder.execute(
customerId,
command
);
We do not accept:
{
"customerId": "another-customer"
}
for Customer-owned Order creation.
The exact Spring Security implementation is introduced later.
The architectural rule is already fixed:
authenticated identity
→ CustomerId
Mapping Create Order Items
The Handler may convert request items into application input.
For example:
List<CreateOrderCommand.Item> items =
request.items()
.stream()
.map(item ->
new CreateOrderCommand.Item(
toProductId(
item.productId()
),
item.quantity()
)
)
.toList();
CreateOrderCommand command =
new CreateOrderCommand(
items
);
Then:
Order order =
createOrder.execute(
customerId,
command
);
The UseCase receives typed Product IDs rather than raw JSON strings.
Do We Always Need a Command?
No.
We could design a UseCase signature that takes a suitable application input directly.
The important distinction is:
HTTP DTO
≠ automatically
application input
Create a Command when it improves:
clarity
transport independence
application vocabulary
Do not add a new type merely because every UseCase is expected to have one.
Create Order Response
Successful creation returns:
201 Created
and may include:
Location
Conceptually:
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody
CreateOrderRequest request
) {
CustomerId customerId =
currentUser.customerId();
Order order =
createOrder.execute(
customerId,
toCommand(
request
)
);
OrderResponse response =
OrderResponse.from(
order
);
URI location =
URI.create(
"/api/v1/orders/" +
order.id()
.value()
);
return ResponseEntity
.created(
location
)
.body(
response
);
}
The Handler owns:
201
Location
OrderResponse
The UseCase knows nothing about HTTP.
Order History
Customer Order history uses authenticated identity.
Conceptually:
@GetMapping
public OrderPageResponse getOrders(
@RequestParam(
defaultValue = "0"
)
int page,
@RequestParam(
defaultValue = "20"
)
int size
) {
CustomerId customerId =
currentUser.customerId();
return OrderPageResponse.from(
getOrderHistory.execute(
customerId,
page,
size
)
);
}
The Handler does not accept:
customerId
as a Customer-controlled query parameter.
Get One Order
Conceptually:
@GetMapping("/{orderId}")
public OrderResponse get(
@PathVariable
String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
getOrder.execute(
toOrderId(
orderId
),
customerId
);
return OrderResponse.from(
order
);
}
The Handler does not:
load arbitrary Order
compare CustomerId manually
choose 403 vs 404
The Customer ownership contract belongs to the application operation.
Our canonical behavior is:
missing Order
or
non-owned Order
→ ORDER_NOT_FOUND
Cancel Order
Canonical mapping:
@PostMapping(
"/{orderId}/cancel"
)
public OrderResponse cancel(
@PathVariable
String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
cancelOrder.execute(
toOrderId(
orderId
),
customerId
);
return OrderResponse.from(
order
);
}
Notice what is absent:
Repository lookup
Order-status check
row locking
Inventory restoration
transaction management
Those belong to:
CancelOrderUseCase
Order
Repositories
Pay Order
Conceptually:
@PostMapping(
"/{orderId}/pay"
)
public OrderResponse pay(
@PathVariable
String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
payOrder.execute(
toOrderId(
orderId
),
customerId
);
return OrderResponse.from(
order
);
}
Future Payment-provider requirements may introduce additional request input.
We do not invent that DTO now.
The endpoint shape remains:
POST /api/v1/orders/{orderId}/pay
OrderHandler Should Not Know Payment Protocol
Avoid code such as:
@PostMapping("/{orderId}/pay")
public ...
{
ProviderPaymentRequest paymentRequest =
...;
providerClient.capture(
paymentRequest
);
...
}
That would push provider integration into the HTTP boundary.
The correct architecture is:
OrderHandler
↓
PayOrderUseCase
↓
PaymentService
↓
Provider implementation
Provider request/response DTOs stay behind PaymentService.
InventoryHandler
Canonical Inventory routes are:
GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
So:
@RestController
@RequestMapping("/api/v1/inventory")
public class InventoryHandler {
}
is a natural transport boundary.
SetInventoryRequest
The Admin mutation requires only:
availableQuantity
So:
public record SetInventoryRequest(
int availableQuantity
) {
}
is enough.
The target Product identity already appears in the URI.
Set Inventory Mapping
Conceptually:
@PutMapping("/{productId}")
public InventoryResponse setInventory(
@PathVariable
String productId,
@RequestBody
SetInventoryRequest request
) {
Inventory inventory =
setInventory.execute(
toProductId(
productId
),
request.availableQuantity()
);
return InventoryResponse.from(
inventory
);
}
Responsibility remains:
Handler
→ maps transport
SetInventoryUseCase
→ coordinates application operation
Inventory
→ protects quantity invariant
Identity Belongs in the URI
For:
PUT /api/v1/inventory/{productId}
the URI already answers:
Which Inventory state?
So the body should contain:
{
"availableQuantity": 20
}
not:
{
"productId": "...",
"availableQuantity": 20
}
unless a real contract requires duplication.
Avoid Conflicting IDs
Imagine:
PUT /api/v1/inventory/59ba787c-...
with:
{
"productId": "364ec915-...",
"availableQuantity": 20
}
Which Product does the request target?
Good API design avoids creating that ambiguity.
Use:
URI
→ resource identity
and:
body
→ requested mutable input
Same Principle for Product PATCH
Prefer:
PATCH /api/v1/products/{productId}
with:
{
"priceCents": 12000
}
rather than:
{
"id": "...",
"priceCents": 12000
}
The URI already identifies the Product.
Controllers Should Not Call Repositories Directly
Avoid:
@RestController
public class OrderHandler {
private final OrderRepository orderRepository;
@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(...) {
Order order =
orderRepository
.findById(...);
order.cancel();
orderRepository.save(
order
);
return ...;
}
}
Cancellation actually requires:
authenticated ownership
ownership-aware locking
Order lifecycle
Inventory restoration
one local transaction
The Handler has begun implementing the workflow.
Correct:
OrderHandler
↓
CancelOrderUseCase
↓
OrderRepository
+
InventoryRepository
+
Order
What About Simple Reads?
Could:
GET /api/v1/products/{productId}
call a Repository directly because it is only a read?
Technically, it could.
But our Product read may involve:
Customer visibility
Product + Inventory query semantics
not-found rules
authorization context
So keeping:
GetProductUseCase
preserves a clear application boundary.
Even when a UseCase is small, it still represents application intent.
Avoid Ceremony for Its Own Sake
At the same time, do not respond to a simple query by introducing:
Handler
→ Facade
→ Manager
→ Service
→ UseCase
→ QueryService
→ Repository
Our architecture remains deliberately simple:
Handler
→ UseCase
→ Repository
A UseCase may sometimes contain one meaningful query call.
That is fine.
@RequestParam
Spring binds query parameters using:
@RequestParam
Our pagination endpoints will use:
page
size
For example:
@GetMapping
public ProductPageResponse browse(
@RequestParam(
defaultValue = "0"
)
int page,
@RequestParam(
defaultValue = "20"
)
int size
) {
...
}
The API contract later enforces:
page >= 0
1 <= size <= 100
The defaults and limits are deliberate API decisions.
@RequestHeader
Spring can bind headers with:
@RequestHeader
For example:
@RequestHeader("X-Request-Id")
String requestId
may be useful for a specific transport concern.
But infrastructure-level concerns such as:
authentication
correlation
tracing
should not be manually reimplemented independently inside every Handler when framework-level infrastructure can apply them consistently.
Do Not Parse Authorization Headers Manually
Avoid:
@RequestHeader("Authorization")
String authorization
followed by:
String token =
authorization.substring(...);
inside Product or Order Handlers.
Authentication belongs to:
Spring Security
The Handler should work with the authenticated application identity after the security boundary has processed the request.
@ResponseStatus
For a simple fixed response status, Spring supports:
@ResponseStatus(
HttpStatus.CREATED
)
But:
ResponseEntity
is usually more flexible when we need:
Location header
explicit status
response body
For Product and Order creation, ResponseEntity.created(...) is a natural fit.
Response DTOs
A Product response may be:
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 is transport representation.
The Domain does not need Jackson annotations simply to define the public API.
Why IDs Become Strings in Responses
Domain:
ProductId(UUID)
HTTP:
"59ba787c-6d83-4a21-9bae-19c3d0469bd5"
The API contract represents UUID values as strings.
Therefore response mapping explicitly converts:
product.id()
.value()
.toString()
This keeps transport formatting outside the Domain.
OrderResponse
Conceptually:
public record OrderResponse(
String id,
String status,
Instant createdAt,
List<OrderItemResponse> items,
long totalCents
) {
}
Notice that we do not expose:
CustomerId
to the Customer simply because Order stores it internally.
Ownership is authenticated context, not necessary Customer-facing response data.
OrderItemResponse
For example:
public record OrderItemResponse(
String productId,
int quantity,
long unitPriceCents,
long totalCents
) {
}
This presents historical Order information without loading current Product price.
Response Mapping Can Stay Simple
For simple mappings, either of these approaches is reasonable:
ProductResponse.from(
product
);
or:
private ProductResponse toResponse(
Product product
) {
...
}
inside the Handler.
We do not need a mapping framework simply because mapping exists.
Introduce extra mapping abstraction only when complexity or reuse justifies it.
Domain Must Not Depend on Response DTOs
Avoid:
public class Product {
public ProductResponse toResponse() {
...
}
}
Now:
Domain
depends on:
HTTP transport
The dependency direction should instead be:
ProductResponse
↓ reads
Product
not:
Product
↓ depends on
ProductResponse
Do Not Return JPA Entities
Later our persistence layer may contain ORM-specific objects.
Do not write:
return productEntity;
from the Handler.
That may expose:
persistence fields
lazy relationships
internal mapping structure
ORM serialization behaviour
The public API must remain intentional.
Do Not Automatically Return Domain Entities Either
Even a clean Domain object should not necessarily become the public JSON contract.
Direct serialization couples:
Domain structure
to:
external API structure
A later internal Domain refactor could accidentally become a breaking API change.
Explicit response DTOs create a stable boundary.
Controller Bean Scope
Spring Controllers are normally singleton Beans.
That means one:
OrderHandler
instance may serve many requests concurrently.
Therefore Handler fields should contain stable dependencies such as:
private final GetOrderUseCase getOrder;
not request-specific state.
Never Store the Current Customer in a Handler Field
Bad:
@RestController
public class OrderHandler {
private CustomerId currentCustomer;
}
Two concurrent requests could overwrite the same mutable field.
Request-specific values must remain:
method parameters
local variables
For example:
CustomerId customerId =
currentUser.customerId();
inside the request method.
Do Not Store Request DTOs in Bean Fields
Avoid:
private CreateOrderRequest currentRequest;
inside a singleton Handler.
Each request DTO belongs to one request execution.
It should flow through:
method parameter
→ local mapping
→ UseCase call
and then disappear.
Transaction Boundaries Do Not Belong on Handlers
Avoid making:
@PostMapping
@Transactional
public ...
the normal way to define a business transaction.
For example, Create Order coordinates:
Inventory consumption
Order persistence
That transaction belongs to:
CreateOrderUseCase
not to the fact that the operation happened to arrive through HTTP.
Why Transactions Belong to UseCases
Imagine another entry point later invokes:
CreateOrderUseCase
If transaction semantics exist only on:
OrderHandler
the other caller loses the consistency guarantee.
The transaction is part of the application operation.
Therefore:
UseCase
→ transaction boundary
is the stronger design.
Handlers Should Not Catch Every Exception
Avoid:
try {
...
} catch (Exception ex) {
return ResponseEntity
.status(
HttpStatus.INTERNAL_SERVER_ERROR
)
.body(...);
}
inside every Handler.
This leads to:
duplicated error translation
inconsistent response formats
business errors becoming 500
large repetitive Controllers
Error mapping should be centralized.
Expected Failures Need Consistent Translation
Suppose:
OrderNotFound
occurs.
One Handler must not return:
404
while another returns:
400
and another accidentally returns:
500
Our canonical mapping already defines:
ORDER_NOT_FOUND
→ 404
Later we will implement centralized exception handling to produce the consistent RFC 7807 response.
Unexpected Failures Should Propagate to Central Handling
Avoid:
catch (Exception ex) {
return null;
}
or:
catch (Exception ex) {
return ResponseEntity.ok(
errorBody
);
}
Unexpected failures should reach centralized handling and produce proper:
5xx
semantics.
Controllers Should Not Construct Infrastructure
Avoid:
new JdbcOrderRepository(...);
inside a request method.
Spring composition creates and injects infrastructure dependencies.
Likewise, do not manually construct:
CreateOrderUseCase useCase =
new CreateOrderUseCase(...);
for every HTTP request.
Handlers should receive stable UseCase dependencies through constructor injection.
Capability-First Package Structure
Our canonical codebase structure uses capability-first organization.
For Order:
order/
├── api/
│ ├── OrderHandler.java
│ ├── CreateOrderRequest.java
│ ├── CreateOrderItemRequest.java
│ ├── OrderResponse.java
│ └── OrderItemResponse.java
├── application/
├── domain/
└── persistence/
For Product:
product/
├── api/
├── application/
├── domain/
└── persistence/
For Inventory:
inventory/
├── api/
├── application/
├── domain/
└── persistence/
Transport types stay near the capability's API boundary.
Why api/ Instead of a Global controller/
Avoid organizing the entire application as:
controller/
service/
repository/
dto/
with every business capability scattered across global technical layers.
Capability-first packaging keeps:
Product HTTP
Product application operations
Product Domain
Product persistence
closer together.
This improves discoverability as the codebase grows.
Handler Naming Inside api/
A Product HTTP class can be:
product/api/ProductHandler.java
with:
@RestController
public class ProductHandler {
}
The package already communicates:
API boundary
The class name communicates:
Handler
This fits our architectural vocabulary cleanly.
Service Remains Reserved for External Integrations
Do not introduce:
private final OrderService orderService;
for internal application workflow.
Our pattern remains:
OrderHandler
↓
PayOrderUseCase
↓
PaymentService
where:
PayOrderUseCase
→ internal application workflow
and:
PaymentService
→ external Payment capability
Routing Should Stay Clear
Suppose an Order Handler contains:
@GetMapping("/{orderId}")
and later someone introduces:
@GetMapping("/history")
Spring can often distinguish static and path-variable routes.
But avoid unclear route design that relies on clever pattern interactions.
Our current API remains simple:
GET /api/v1/orders
GET /api/v1/orders/{orderId}
so no /history route is needed for Customer history.
Path Variable Names Should Be Explicit
Prefer:
@GetMapping("/{orderId}")
public OrderResponse get(
@PathVariable String orderId
) {
}
over:
@GetMapping("/{id}")
public OrderResponse get(
@PathVariable String id
) {
}
when explicit naming improves readability.
Likewise:
productId
orderId
immediately communicates the identity being handled.
Do Not Use HttpServletRequest by Default
Avoid:
public ResponseEntity<?> handle(
HttpServletRequest request
) {
...
}
and then manually parsing:
URI
query parameters
headers
JSON
Spring MVC already provides typed binding through:
@PathVariable
@RequestParam
@RequestBody
@RequestHeader
Use lower-level servlet APIs only when a real transport requirement needs them.
Avoid Map<String, Object> Request Bodies
Bad:
@PostMapping
public ...
create(
@RequestBody
Map<String, Object> body
) {
}
for a stable API contract.
Typed DTOs such as:
CreateProductRequest
UpdateProductRequest
SetInventoryRequest
CreateOrderRequest
provide:
compile-time structure
clear validation targets
better documentation
explicit client contract
Typed Responses Too
Likewise, avoid returning:
Map<String, Object>
for normal API responses.
Use explicit models such as:
ProductResponse
InventoryResponse
OrderResponse
The API becomes easier to understand, document, test, and evolve.
Handler Tests
Handler tests should focus on transport concerns such as:
correct URI mapping
HTTP method
request deserialization
path/query conversion
validation behavior
HTTP status
response JSON
authentication integration
Problem Details mapping
They should not become the only place where business rules are tested.
UseCase Tests
UseCase tests focus on:
Repository coordination
Customer ownership
cross-Domain workflow
transactional behaviour
expected application failures
No HTTP is required.
Domain Tests
Domain tests focus on rules such as:
Product price invariants
Inventory quantity invariants
OrderItem invariants
Order lifecycle
No Spring MVC is required.
Each layer tests the responsibilities it owns.
Do Not Re-Test the Entire Domain Through Every Handler
For example:
PAID Order cannot be cancelled
should be strongly tested in:
Order Domain tests
Then a Handler/API test can verify that:
OrderNotCancellable
becomes:
409
ORDER_NOT_CANCELLABLE
This produces more focused and faster tests.
A Conceptual ProductHandler
Putting the concepts together:
package io.tarkev.ordermanagement.product.api;
@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {
private final BrowseProductsUseCase browseProducts;
private final GetProductUseCase getProduct;
private final CreateProductUseCase createProduct;
private final UpdateProductUseCase updateProduct;
private final DeactivateProductUseCase deactivateProduct;
public ProductHandler(
BrowseProductsUseCase browseProducts,
GetProductUseCase getProduct,
CreateProductUseCase createProduct,
UpdateProductUseCase updateProduct,
DeactivateProductUseCase deactivateProduct
) {
this.browseProducts =
browseProducts;
this.getProduct =
getProduct;
this.createProduct =
createProduct;
this.updateProduct =
updateProduct;
this.deactivateProduct =
deactivateProduct;
}
@GetMapping
public ProductPageResponse browse(
@RequestParam(
defaultValue = "0"
)
int page,
@RequestParam(
defaultValue = "20"
)
int size
) {
return ProductPageResponse.from(
browseProducts.execute(
page,
size
)
);
}
@GetMapping("/{productId}")
public ProductResponse get(
@PathVariable
String productId
) {
Product product =
getProduct.execute(
toProductId(
productId
)
);
return ProductResponse.from(
product
);
}
@PostMapping
public ResponseEntity<ProductResponse> create(
@RequestBody
CreateProductRequest request
) {
Product product =
createProduct.execute(
request.name(),
request.priceCents()
);
ProductResponse response =
ProductResponse.from(
product
);
URI location =
URI.create(
"/api/v1/products/" +
product.id()
.value()
);
return ResponseEntity
.created(
location
)
.body(
response
);
}
@PatchMapping("/{productId}")
public ProductResponse update(
@PathVariable
String productId,
@RequestBody
UpdateProductRequest request
) {
Product product =
updateProduct.execute(
toProductId(
productId
),
request.name(),
request.priceCents()
);
return ProductResponse.from(
product
);
}
@PostMapping(
"/{productId}/deactivate"
)
public ProductResponse deactivate(
@PathVariable
String productId
) {
Product product =
deactivateProduct.execute(
toProductId(
productId
)
);
return ProductResponse.from(
product
);
}
private ProductId toProductId(
String rawProductId
) {
return new ProductId(
UUID.fromString(
rawProductId
)
);
}
}
This example is intentionally architectural.
Detailed:
validation
pagination response contracts
error mapping
Spring Security
persistence
are covered in dedicated lessons.
Are Five UseCase Dependencies Too Many?
Not automatically.
ProductHandler represents one cohesive HTTP capability.
Its constructor makes the operations it exposes visible:
browse
get
create
update
deactivate
If the class later becomes difficult to understand because of:
size
cohesion
change frequency
very different endpoint concerns
we can split it.
Do not split purely because:
five constructor parameters looks like a lot
Do Not Solve Constructor Size With ProductService
Avoid replacing:
BrowseProductsUseCase
GetProductUseCase
CreateProductUseCase
UpdateProductUseCase
DeactivateProductUseCase
with:
ProductService
containing:
browse()
get()
create()
update()
deactivate()
That merely hides several application responsibilities behind one generic object.
Operation-specific UseCases remain clearer.
Handler Code Should Be Boring
A healthy Handler often follows a repetitive pattern:
receive
convert
delegate
map
respond
That is good.
HTTP boundary code should be predictable.
The interesting business complexity belongs deeper in:
UseCases
Domain
Repositories
external Services
not in increasingly clever Controller methods.
Common Mistake — Business Logic in Controller
Avoid performing:
load Products
check Inventory
calculate Order total
change Order status
save Order
inside:
@PostMapping
Move workflow to the UseCase.
Common Mistake — Repository in a Mutating Handler
A Handler directly calling a Repository for a mutation often bypasses:
Domain rules
cross-capability coordination
transaction ownership
authorization semantics
Use a UseCase.
Common Mistake — Request Body Is the Domain Entity
Avoid:
public ProductResponse create(
@RequestBody
Product product
) {
}
Now the transport contract becomes the Domain structure and the client may gain access to server-owned fields.
Use explicit request DTOs.
Common Mistake — Field Injection
Constructor injection keeps dependencies visible and testable.
Common Mistake — Request State in Handler Fields
Controllers are shared Beans.
Keep request-specific state method-local.
Common Mistake — Manual Authorization Parsing
Do not parse Bearer tokens inside every Handler.
Use Spring Security.
Common Mistake — Generic Maps
Typed request and response DTOs make the API contract intentional.
Common Mistake — Every Handler Catches Exceptions
Use centralized error translation for consistent Problem Details responses.
Common Mistake — @Transactional on Every HTTP Mutation
Transactions belong to the application operation, not the transport.
Common Mistake — Internal OrderService
Our internal path is:
Handler
→ UseCase
→ Repository
Service remains reserved for external providers such as:
PaymentService
Controller Review Checklist
When reviewing a Handler, ask:
Does the mapping match
the approved API contract?
Does it primarily handle HTTP concerns?
Are UUID strings converted intentionally?
Does authenticated CustomerId
come from the security boundary?
Are server-owned values absent
from request DTOs?
Does the Handler invoke a UseCase?
Is Repository access leaking upward?
Is Domain logic being reimplemented here?
Are response DTOs explicit?
Is request state method-local?
Does transaction ownership remain
with the UseCase?
Does external provider protocol
stay outside the Handler?
Can error mapping be centralized?
Handler Responsibility
For our application:
Handler
owns:
HTTP route
HTTP method
path/query/body binding
authenticated request context
transport → application mapping
UseCase invocation
application result → response mapping
HTTP status
HTTP headers
It does not own:
business workflow
Domain invariants
database implementation
transaction rules
external Payment-provider protocol
Architecture After Adding HTTP
Our application now looks like:
HTTP Client
↓
Spring MVC
↓
Handler
↓
UseCase
↓
Domain
↓
Repository
External Payment remains another edge:
UseCase
↓
PaymentService
↓
External Provider
This keeps:
HTTP protocol
on one side and:
external provider protocol
on another.
The application workflow remains between those boundaries.
Engineering Principle
The core principle is:
A Spring Controller is an HTTP Handler: receive transport input, delegate to a UseCase, and translate the result back into HTTP.
Another:
Controllers should understand HTTP deeply enough to expose a good API contract without becoming the owner of the business workflow.
And:
Spring annotations connect HTTP to our architecture; they do not replace the architecture.
