Building REST APIs
Controllers in Spring Boot
আপনি একটি free preview lesson দেখছেন।
এখন পর্যন্ত আমরা API contract design করেছি।
For example:
GET /products
GET /products/{productId}
POST /products
PATCH /products/{productId}
POST /products/{productId}/deactivate
GET /inventory
GET /inventory/{productId}
PUT /inventory/{productId}
POST /orders
GET /orders
GET /orders/{orderId}
POST /orders/{orderId}/cancel
POST /orders/{orderId}/pay
এখন Spring Boot application-এ এই HTTP endpoints implement করতে হবে।
Spring MVC-তে এই boundary সাধারণত Controller দিয়ে তৈরি করা হয়।
আমাদের architecture terminology:
Handler
↓
UseCase
↓
Repository
Spring-এর @RestController class-ই HTTP Handler হিসেবে কাজ করতে পারে।
অর্থাৎ:
Spring Controller
=
HTTP Handler
এই lesson-এর goal:
Spring MVC ব্যবহার করে HTTP request গ্রহণ করা, transport data map করা, UseCase invoke করা, এবং response return করা—without putting business workflows inside Controllers.
What Is a Spring MVC Controller?
Spring MVC Controller হলো HTTP entry point।
Conceptually:
HTTP Request
↓
Spring MVC
↓
Controller / Handler
↓
UseCase
Controller understands things such as:
HTTP method
URI
path parameters
query parameters
request body
HTTP response
It should not become the place that understands:
Inventory transaction rules
Order lifecycle implementation
database queries
Payment Provider protocol
@RestController
A typical Spring REST Controller:
@RestController
public class ProductHandler {
}
@RestController tells Spring:
This class handles HTTP requests and its return values should normally be written into the HTTP response body.
It effectively combines controller behaviour with response-body serialization support।
Controller Is a Spring Bean
Because @RestController is a Spring-managed component, Spring creates it as a Bean।
So dependencies should be supplied through constructor injection।
Example:
@RestController
public class CreateProductHandler {
private final CreateProductUseCase useCase;
public CreateProductHandler(
CreateProductUseCase useCase
) {
this.useCase = useCase;
}
}
Spring resolves:
CreateProductUseCase
from the Application Context।
No Field Injection
Avoid:
@RestController
public class CreateProductHandler {
@Autowired
private CreateProductUseCase useCase;
}
Constructor injection keeps dependency explicit।
It also makes Handler easier to instantiate in tests।
@RequestMapping
A Controller can define a common path prefix:
@RestController
@RequestMapping("/products")
public class ProductHandler {
}
Then individual methods can map:
GET /
GET /{productId}
POST /
relative to:
/products
HTTP Method Mapping Annotations
Spring provides convenient annotations:
@GetMapping
@PostMapping
@PutMapping
@PatchMapping
@DeleteMapping
For example:
@GetMapping
public ... getProducts() {
}
inside:
@RequestMapping("/products")
maps:
GET /products
Mapping a Product Endpoint
Conceptually:
@RestController
@RequestMapping("/products")
public class ProductHandler {
@GetMapping
public List<ProductResponse> getProducts() {
...
}
}
Spring receives:
GET /products
and routes it to:
getProducts()
Method Names Are Internal
Important:
getProducts()
is just a Java method name।
The public API contract is:
GET /products
We could rename the Java method:
browse()
without changing the public API, as long as mapping stays the same।
Don't confuse Java method names with API design।
Path Variables
For:
GET /products/{productId}
Spring can bind the dynamic path part using:
@PathVariable
Example:
@GetMapping("/{productId}")
public ProductResponse getProduct(
@PathVariable String productId
) {
...
}
Request:
GET /products/P-100
produces:
productId = "P-100"
inside the Handler।
Convert Transport Types at the Boundary
The path variable arrives as transport data:
String
Our application may prefer:
ProductId
So Handler can convert:
ProductId id =
new ProductId(productId);
Then call:
useCase.execute(id);
This keeps raw transport representation from leaking unnecessarily into application logic।
Request Bodies
For:
POST /products
with:
{
"name": "Keyboard",
"price": 100.00
}
Spring can deserialize JSON into a request DTO using:
@RequestBody
Example:
@PostMapping
public ProductResponse createProduct(
@RequestBody CreateProductRequest request
) {
...
}
Request DTO
Conceptually:
public record CreateProductRequest(
String name,
BigDecimal price
) {
}
This is transport data։
It is not Product domain state yet।
Handler Maps Request to Application Input
A straightforward flow:
@PostMapping
public ProductResponse createProduct(
@RequestBody CreateProductRequest request
) {
Product product =
createProductUseCase.execute(
request.name(),
request.price()
);
return ProductResponse.from(product);
}
Conceptually:
JSON
↓
CreateProductRequest
↓
Handler
↓
UseCase
↓
Product
Do Not Put Product Validation Workflow in Controller
Bad:
@PostMapping
public ProductResponse createProduct(
@RequestBody CreateProductRequest request
) {
if (request.price().signum() < 0) {
throw ...
}
Product product =
new Product(...);
repository.save(product);
return ...
}
Now Controller owns:
domain construction
business validation
persistence
Wrong responsibility।
Better:
Controller
↓
CreateProductUseCase
Product/domain/Repository responsibilities stay inside their correct layers।
Thin Does Not Mean One Line
A good Handler may still:
read path variables
read request body
obtain authenticated identity
map DTOs
call UseCase
build HTTP response
That's legitimate work।
"Thin Controller" means:
It should not own the business workflow.
It does not mean:
Every Controller method must contain exactly one line.
ResponseEntity
Spring allows returning:
ResponseEntity<T>
when we need explicit control over:
HTTP status
headers
response body
Example:
return ResponseEntity
.status(HttpStatus.CREATED)
.body(response);
This is useful for:
201 Created
responses।
Creating Product With 201 Created
Conceptually:
@PostMapping
public ResponseEntity<ProductResponse> create(
@RequestBody CreateProductRequest request
) {
Product product =
createProductUseCase.execute(
request.name(),
request.price()
);
ProductResponse response =
ProductResponse.from(product);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(response);
}
This makes success semantics explicit।
Location Header
For newly created resource:
Location: /products/P-100
can be useful।
Conceptually:
URI location =
URI.create(
"/products/" +
product.id().value()
);
Then:
return ResponseEntity
.created(location)
.body(response);
ResponseEntity.created(...) produces:
201 Created
Location header
Avoid Building URIs Carelessly
String concatenation is fine for simple examples, but larger APIs can use Spring URI-building utilities when useful।
The important lesson now is:
Handler owns HTTP response metadata
not domain or UseCase।
Browse Product Handler
Conceptually:
@RestController
@RequestMapping("/products")
public class ProductHandler {
private final BrowseProductsUseCase browseProductsUseCase;
private final GetProductUseCase getProductUseCase;
private final CreateProductUseCase createProductUseCase;
public ProductHandler(
BrowseProductsUseCase browseProductsUseCase,
GetProductUseCase getProductUseCase,
CreateProductUseCase createProductUseCase
) {
this.browseProductsUseCase =
browseProductsUseCase;
this.getProductUseCase =
getProductUseCase;
this.createProductUseCase =
createProductUseCase;
}
}
A Controller may group related Product endpoints।
That's reasonable।
One Controller Per Capability vs One Per Endpoint
We do not require:
CreateProductHandler.java
GetProductHandler.java
BrowseProductsHandler.java
UpdateProductHandler.java
DeactivateProductHandler.java
as separate Spring Controllers if one capability-focused Controller is clearer।
For example:
ProductHandler
can expose multiple Product HTTP mappings while delegating each operation to separate UseCases।
Preserve UseCase Granularity
Even if one Controller groups endpoints:
ProductHandler
we still prefer operation-specific UseCases:
CreateProductUseCase
UpdateProductUseCase
DeactivateProductUseCase
BrowseProductsUseCase
rather than one giant:
ProductService
with all Product logic।
Remember our project convention:
Service
→ external/third-party only
Example Product Handler Structure
Conceptually:
@RestController
@RequestMapping("/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 HTTP mappings।
Each UseCase owns its workflow।
Product Browse Mapping
@GetMapping
public List<ProductResponse> browse() {
return browseProducts
.execute()
.stream()
.map(ProductResponse::from)
.toList();
}
This is conceptually fine।
Later pagination will change the return shape।
For now, focus on Handler responsibility।
Product Detail Mapping
@GetMapping("/{productId}")
public ProductResponse get(
@PathVariable String productId
) {
Product product =
getProduct.execute(
new ProductId(productId)
);
return ProductResponse.from(product);
}
Transport:
String path variable
becomes:
ProductId
at the boundary।
Updating Product
Conceptually:
@PatchMapping("/{productId}")
public ProductResponse update(
@PathVariable String productId,
@RequestBody UpdateProductRequest request
) {
Product product =
updateProduct.execute(
new ProductId(productId),
request.name(),
request.price()
);
return ProductResponse.from(product);
}
Exact update request semantics will be refined later।
The pattern matters:
HTTP
→ DTO
→ UseCase
→ Domain result
→ Response DTO
Deactivating Product
@PostMapping(
"/{productId}/deactivate"
)
public ProductResponse deactivate(
@PathVariable String productId
) {
Product product =
deactivateProduct.execute(
new ProductId(productId)
);
return ProductResponse.from(product);
}
The Controller does not:
product.setActive(false);
and does not call repository directly।
Order Handler
Order capability has endpoints:
POST /orders
GET /orders
GET /orders/{orderId}
POST /orders/{orderId}/cancel
POST /orders/{orderId}/pay
A capability-focused:
@RestController
@RequestMapping("/orders")
public class OrderHandler {
}
is a natural structure।
Create Order Request
Conceptually:
public record CreateOrderRequest(
List<CreateOrderItemRequest> items
) {
}
with:
public record CreateOrderItemRequest(
String productId,
int quantity
) {
}
Remember: no:
customerId
price
total
status
from client।
Authenticated Customer Identity
For Order creation, Handler eventually needs:
CustomerId
from authenticated context।
Conceptually:
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody CreateOrderRequest request
) {
CustomerId customerId =
currentUser.customerId();
...
}
The exact Spring Security integration comes in Module 8।
For now, the important design rule is:
customer identity
does not come from request body
Mapping Request Items
Handler may convert request DTOs into application input:
List<CreateOrderCommand.Item> items =
request.items()
.stream()
.map(item ->
new CreateOrderCommand.Item(
new ProductId(
item.productId()
),
item.quantity()
)
)
.toList();
Then:
CreateOrderCommand command =
new CreateOrderCommand(items);
And call:
Order order =
createOrderUseCase.execute(
customerId,
command
);
Do We Need a Command?
Not always।
The Handler could also call:
createOrderUseCase.execute(
customerId,
request.items()
);
if transport types and application input are already appropriate।
Use a Command when it improves separation or clarity।
Do not create it merely because every UseCase "must" have one।
Returning 201 Created
Conceptually:
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody CreateOrderRequest request
) {
CustomerId customerId =
currentUser.customerId();
Order order =
createOrderUseCase.execute(
customerId,
toCommand(request)
);
OrderResponse response =
OrderResponse.from(order);
URI location =
URI.create(
"/orders/" +
order.id().value()
);
return ResponseEntity
.created(location)
.body(response);
}
Handler owns:
201
Location
response DTO
UseCase knows nothing about them।
Getting Order History
Conceptually:
@GetMapping
public List<OrderResponse> getOrders() {
CustomerId customerId =
currentUser.customerId();
return getOrderHistoryUseCase
.execute(customerId)
.stream()
.map(OrderResponse::from)
.toList();
}
Later pagination will change this shape।
But ownership still comes from authenticated identity।
Getting One Order
@GetMapping("/{orderId}")
public OrderResponse get(
@PathVariable String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
getOrderUseCase.execute(
new OrderId(orderId),
customerId
);
return OrderResponse.from(order);
}
The Controller does not manually compare:
order.customerId()
unless the UseCase contract specifically places ownership logic there।
Ownership is an application workflow concern।
Cancel Order
@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(
@PathVariable String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
cancelOrderUseCase.execute(
new OrderId(orderId),
customerId
);
return OrderResponse.from(order);
}
Notice what is missing:
repository lookup
status check
Inventory restoration
transaction management
Those belong to UseCase/domain/persistence।
Pay Order
Conceptually:
@PostMapping("/{orderId}/pay")
public OrderResponse pay(
@PathVariable String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
payOrderUseCase.execute(
new OrderId(orderId),
customerId
);
return OrderResponse.from(order);
}
Later payment input may require a request DTO depending on the provider contract।
We do not invent it yet।
Inventory Handler
Current endpoints:
GET /inventory
GET /inventory/{productId}
PUT /inventory/{productId}
Conceptually:
@RestController
@RequestMapping("/inventory")
public class InventoryHandler {
}
Set Inventory Request
public record SetInventoryRequest(
int availableQuantity
) {
}
Handler:
@PutMapping("/{productId}")
public InventoryResponse setInventory(
@PathVariable String productId,
@RequestBody SetInventoryRequest request
) {
Inventory inventory =
setInventoryUseCase.execute(
new ProductId(productId),
request.availableQuantity()
);
return InventoryResponse.from(
inventory
);
}
Again:
Handler maps
UseCase coordinates
Inventory protects invariant
Controllers Should Not Call Repositories Directly
Avoid:
@RestController
public class OrderHandler {
private final OrderRepository repository;
@PostMapping("/{id}/cancel")
public void cancel(...) {
Order order =
repository.findById(...);
order.cancel();
repository.save(order);
}
}
Why is this problematic?
Cancellation also requires:
ownership
Inventory restoration
transaction boundary
The Controller has started implementing the workflow।
Correct:
Handler
↓
CancelOrderUseCase
↓
Repositories + Domain
Even Simple Reads Need Deliberation
What about:
GET /products/{id}
Could Handler call Repository directly because it's just a read?
Technically possible।
But keeping:
GetProductUseCase
can still be useful if the operation includes:
customer visibility rules
Product + Inventory composition
error semantics
Don't optimize away the application boundary before knowing query requirements।
But Avoid Empty Ceremony Too
If a truly simple internal admin read is:
Handler
→ UseCase
→ Repository
and UseCase is one line, that's not automatically wrong।
But neither should we create ten abstraction layers around a trivial query।
The guiding architecture is:
Handler → UseCase → Repository
where UseCase represents meaningful application intent, even when implementation is small।
@RequestParam
Query parameters are bound using:
@RequestParam
For example, later pagination might look conceptually like:
@GetMapping
public ProductPageResponse browse(
@RequestParam int page,
@RequestParam int size
) {
...
}
Exact pagination contract is deferred to the pagination lesson।
Defaults
Spring can provide defaults:
@RequestParam(
defaultValue = "20"
)
int size
But defaults are part of the API contract।
Do not choose arbitrary values without deliberate design।
Pagination limits especially need server-controlled bounds later।
@RequestHeader
Headers can be bound using:
@RequestHeader
For example:
@RequestHeader("X-Request-Id")
String requestId
But we should not manually read headers in every Handler for concerns such as:
authentication
correlation
if framework-level infrastructure can handle them consistently।
Avoid Manually Parsing Authorization
Bad:
@RequestHeader("Authorization")
String authorization
then:
String token =
authorization.substring(...);
inside every Controller।
Authentication belongs in Spring Security configuration।
Controller receives authenticated application identity afterward।
@ResponseStatus
For simple fixed response status, Spring supports:
@ResponseStatus(HttpStatus.CREATED)
But ResponseEntity is often more flexible when we need:
Location header
different statuses
explicit response body control
Use whichever keeps the Handler clear।
Controller Return Values
Spring uses configured message converters, commonly Jackson for JSON, to serialize response DTOs।
Example:
public record ProductResponse(
String id,
String name,
BigDecimal price
) {
}
returned from Controller becomes JSON।
The domain object itself does not need to know this protocol detail।
Response DTO Mapping
A simple static factory can be enough:
public record ProductResponse(
String id,
String name,
BigDecimal price
) {
public static ProductResponse from(
Product product
) {
return new ProductResponse(
product.id().value(),
product.name(),
product.price()
);
}
}
This mapping is transport-oriented।
For simple mapping, no separate mapper framework is required।
Where Should from() Live?
ProductResponse.from(product) is convenient because mapping belongs to the response type।
Alternative:
private ProductResponse toResponse(
Product product
) {
}
inside Handler।
Both are reasonable।
Do not create a global mapper hierarchy unless mapping complexity justifies it।
Keep Domain Free From DTO Mapping
Avoid:
public ProductResponse toResponse() {
}
inside Product।
Then domain depends on transport DTOs।
Direction should be:
Handler/DTO
↓ reads
Domain
not:
Domain
↓ depends on
HTTP DTO
Controller Bean Scope
Spring Controllers are normally singleton Beans।
That means one instance can handle many requests over its lifetime।
Therefore avoid request-specific mutable fields।
Bad:
@RestController
public class OrderHandler {
private CustomerId currentCustomer;
}
Multiple concurrent requests could overwrite shared state।
Keep Request State Method-Local
Good:
public OrderResponse get(...) {
CustomerId customerId =
currentUser.customerId();
...
}
Variables exist only for the current call stack/request।
Controller dependencies can remain immutable fields:
private final GetOrderUseCase getOrderUseCase;
Don't Store Request DTOs in Fields
Bad:
private CreateOrderRequest request;
inside Controller।
Each HTTP request should pass data through method parameters/local variables।
This is basic thread-safety and Bean-lifecycle discipline।
Handler Should Not Own Transaction Boundaries
Avoid:
@PostMapping("/orders")
@Transactional
public ...
as the default architecture if the transaction represents application workflow।
The transaction boundary belongs around:
CreateOrderUseCase
because that operation coordinates:
Inventory changes
Order persistence
HTTP should not define domain transaction ownership।
Why Transaction on UseCase Is Better
The same UseCase could later be invoked from a different transport।
If transaction is tied only to HTTP Handler:
other caller
→ loses transaction semantics
Putting transaction around the application operation keeps consistency with the UseCase itself।
Controller Should Not Catch Every Exception
Bad:
try {
...
} catch (Exception ex) {
return ResponseEntity
.status(500)
.body(...);
}
inside every Handler।
This creates:
duplicate error mapping
inconsistent responses
business errors becoming 500
Later we will use centralized exception/error handling।
Handler Should Not Hide Unexpected Failures
Another bad pattern:
catch (Exception ex) {
return null;
}
or:
return ResponseEntity.ok(
new ErrorResponse(...)
);
Unexpected failures should propagate to centralized handling and proper 5xx semantics।
Business Failures Also Should Not Be Handled Ad Hoc Everywhere
Suppose:
OrderNotFoundException
One Controller returns:
404
another:
400
another:
500
This is inconsistent।
Later API error handling should centralize translation।
Controllers Should Not Construct Repositories
Bad:
new JdbcOrderRepository(...)
inside Handler।
Spring composition should inject application dependencies।
This keeps construction and infrastructure outside request handling।
Controllers Should Not Instantiate UseCases Manually
Bad:
CreateOrderUseCase useCase =
new CreateOrderUseCase(
repositoryA,
repositoryB,
repositoryC
);
inside request method।
Spring creates and wires the UseCase Bean once according to application composition।
Handler simply depends on it।
Handler Package Structure
Our capability-first package can evolve like:
order/
├── handler/
│ ├── OrderHandler.java
│ ├── CreateOrderRequest.java
│ ├── CreateOrderItemRequest.java
│ └── OrderResponse.java
├── usecase/
├── domain/
└── repository/
Product:
product/
├── handler/
├── usecase/
├── domain/
└── repository/
Transport types stay near their capability boundary।
Avoid Global controller/ Folder
Instead of:
controller/
├── ProductController.java
├── OrderController.java
└── InventoryController.java
with domain/usecase files somewhere entirely different, capability-first structure keeps related code closer।
This becomes valuable as the codebase grows।
Naming: Controller or Handler?
Spring ecosystem commonly uses:
Controller
Our architecture terminology uses:
Handler
Both can coexist।
For example:
@RestController
public class OrderHandler {
}
is perfectly valid।
Or:
@RestController
public class OrderController {
}
could still conceptually be a Handler।
For course consistency, we will prefer:
Handler
when naming our application boundary।
Do Not Use Internal Service Naming
Avoid:
private final OrderService orderService;
for internal application logic।
Our convention is:
Handler
↓
UseCase
↓
Repository
Service is reserved for third-party/external integrations such as:
PaymentService
Spring MVC Routing Conflicts
Be deliberate with paths।
Suppose:
@GetMapping("/{orderId}")
and another:
@GetMapping("/history")
Spring can distinguish static and variable patterns, but overly generic routing can become confusing։
Prefer clear resource paths and avoid ambiguous conventions।
Path Variable Names Should Match Meaning
Prefer:
@GetMapping("/{orderId}")
over:
@GetMapping("/{id}")
when explicit naming improves readability।
Handler signature:
@PathVariable String orderId
immediately communicates which identifier is being handled।
Use URI for Identity, Body for State/Input
For:
PUT /inventory/{productId}
Product identity belongs in the URI:
which Inventory?
Request body carries:
desired availableQuantity
Avoid unnecessarily duplicating:
{
"productId": "P-100",
"availableQuantity": 20
}
when URI already identifies P-100।
Duplicated identifiers can conflict।
Avoid Conflicting IDs
Imagine:
PUT /inventory/P-100
body:
{
"productId": "P-200",
"availableQuantity": 20
}
Which ID wins?
Better API design avoids creating this ambiguity։
Use path for target identity and body for mutable input।
Same Rule for Product Update
Prefer:
PATCH /products/P-100
body:
{
"price": 120.00
}
not:
{
"id": "P-100",
"price": 120.00
}
unless there is a concrete reason to duplicate the ID।
Handler Method Should Reflect Contract
A useful Handler method signature might be:
public ProductResponse update(
String productId,
UpdateProductRequest request
)
This communicates:
identity comes from path
changes come from body
No Direct HttpServletRequest by Default
Avoid:
public ResponseEntity<?> handle(
HttpServletRequest request
)
and manually parsing:
path
headers
JSON
Spring already provides typed binding through annotations।
Use lower-level servlet APIs only when a real transport requirement needs them।
No Generic Map<String, Object> Request Bodies
Bad:
@RequestBody
Map<String, Object> body
for a stable API contract।
Typed DTO:
CreateOrderRequest
provides:
compile-time clarity
validation location
documentation support
explicit contract
Typed Responses Too
Similarly, avoid returning:
Map<String, Object>
for normal stable response contracts।
Use:
OrderResponse
ProductResponse
InventoryResponse
to make API structure explicit।
Controller Should Not Return JPA Entities
Later persistence classes may include JPA mappings।
Do not simply:
return orderEntity;
from Handler։
That can leak:
persistence fields
lazy relationships
internal IDs
ORM serialization behaviour
Response DTOs keep the boundary intentional।
Controller Should Not Return Domain Entities Automatically Either
Even if domain and persistence types are clean, directly serializing them couples:
domain structure
to:
public JSON contract
For a professional API, explicit response DTOs are usually safer।
Handler Tests
Controller/Handler tests should focus on transport concerns such as:
correct route
request deserialization
status code
response JSON
validation
authentication integration
They should not be the only tests for domain behaviour।
UseCase Tests
UseCase tests focus on:
repository coordination
ownership
cross-domain workflow
expected business failures
No HTTP needed।
Domain Tests
Domain tests focus on:
Product price rules
Inventory quantity rules
Order lifecycle
Order item invariants
No Spring MVC needed।
Each layer tests its own responsibility।
Controller Tests Should Not Recreate the Whole World for Every Rule
For example, paid Order cancellation rule should be strongly covered in:
Order domain tests
Then Handler test can verify the resulting application failure maps correctly to the intended HTTP response।
This produces faster and more focused tests।
A Minimal End-to-End Product Handler
Conceptually:
@RestController
@RequestMapping("/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 List<ProductResponse> browse() {
return browseProducts
.execute()
.stream()
.map(ProductResponse::from)
.toList();
}
@GetMapping("/{productId}")
public ProductResponse get(
@PathVariable String productId
) {
Product product =
getProduct.execute(
new ProductId(productId)
);
return ProductResponse.from(product);
}
@PostMapping
public ResponseEntity<ProductResponse> create(
@RequestBody
CreateProductRequest request
) {
Product product =
createProduct.execute(
request.name(),
request.price()
);
ProductResponse response =
ProductResponse.from(product);
URI location =
URI.create(
"/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(
new ProductId(productId),
request.name(),
request.price()
);
return ProductResponse.from(product);
}
@PostMapping(
"/{productId}/deactivate"
)
public ProductResponse deactivate(
@PathVariable String productId
) {
Product product =
deactivateProduct.execute(
new ProductId(productId)
);
return ProductResponse.from(product);
}
}
This example is intentionally conceptual।
Validation, pagination, error handling, security, and final DTO semantics are introduced in their own lessons।
Is Five UseCases in One Constructor Too Many?
Potentially, but not automatically।
This Controller represents the Product HTTP capability and has multiple endpoints।
Its dependencies show which application operations it exposes।
If the Controller becomes too large later, we can split it into smaller capability-focused Handlers।
Do not split purely because a number looks large।
Look at:
cohesion
readability
change frequency
endpoint grouping
Don't Create a Generic ProductUseCase
Avoid solving constructor length with:
ProductUseCase
containing:
browse()
get()
create()
update()
deactivate()
That simply hides multiple application responsibilities behind one object։
Operation-specific UseCases remain clearer।
Handler Code Should Be Boring
A good Controller should often look repetitive:
receive
convert
delegate
map
respond
That's good।
HTTP boundaries should be predictable।
Business sophistication belongs deeper in the application։
Common Mistake 1 — Business Logic in Controller
Example:
load Product
check Inventory
calculate total
save Order
inside @PostMapping।
Move workflow to UseCase।
Common Mistake 2 — Repository Injected Into Mutating Handler
This often bypasses UseCase/domain coordination।
Common Mistake 3 — Request Body Is the Domain Entity
Clients gain control over server-owned fields and transport becomes coupled to domain structure।
Common Mistake 4 — Field Injection
Dependencies become hidden and testing becomes harder।
Use constructor injection।
Common Mistake 5 — Request State Stored in Controller Fields
Controllers are singleton Beans and handle concurrent requests।
Keep request state method-local।
Common Mistake 6 — Manual Authorization Header Parsing
Use Spring Security boundary rather than reproducing authentication logic in each Handler।
Common Mistake 7 — Map<String, Object> Everywhere
Typed DTOs make the contract explicit and easier to validate/document।
Common Mistake 8 — Controller Handles Every Exception
Centralized error translation will provide consistent API responses।
Common Mistake 9 — Transaction Boundary on HTTP Method by Default
Transaction belongs to the application operation/UseCase।
Common Mistake 10 — Internal OrderService
Our application uses:
Handler → UseCase → Repository
and reserves Service for external integrations।
Controller Review Checklist
When reviewing a Spring Controller, ask:
Does the mapping match the approved API contract?
Does the method mainly deal with HTTP concerns?
Are path/query/body values converted intentionally?
Does authenticated identity come from security context
rather than request-controlled ownership?
Is the Handler calling a UseCase?
Is Repository access leaking into the Handler?
Is domain logic being manually implemented here?
Are server-owned fields absent from request DTOs?
Are response DTOs explicit?
Is request-specific state method-local?
Does the Handler avoid transaction/persistence details?
Does it avoid provider-specific integration logic?
Can error mapping be centralized instead of duplicated?
Our Controller Responsibility
For this project:
Handler / Controller
owns:
HTTP route
HTTP method
path/query/body binding
authenticated request context
request → application mapping
application result → response mapping
HTTP response metadata
It does not own:
business workflow
domain invariants
database implementation
external provider protocol
Architecture After Adding HTTP
Our application now looks conceptually like:
HTTP Client
↓
Spring MVC
↓
Handler
↓
UseCase
↓
Domain
↓
Repository
For external integration:
UseCase
↓
PaymentService
↓
External Provider
This keeps HTTP on one edge and external provider protocol on another edge।
The business workflow remains between them।
Engineering Principle
The core principle:
A Spring Controller is an HTTP Handler: receive transport input, delegate to a UseCase, and translate the result back to HTTP.
Another:
Controllers should understand HTTP deeply enough to expose a good contract, but not deeply own the business workflow.
And:
Spring annotations connect the transport to our application architecture; they do not replace the architecture.
Summary
In this lesson, we learned that:
@RestControllerdefines a Spring-managed HTTP boundary.- Spring Controllers act as our HTTP Handlers.
@RequestMappingcan establish a capability-level URI prefix.@GetMapping,@PostMapping,@PutMapping, and@PatchMappingmap approved HTTP operations.@PathVariablebinds resource identifiers from the URI.@RequestBodybinds JSON to typed request DTOs.@RequestParamhandles query parameters such as future pagination/filtering inputs.- Transport values should be converted into application-friendly types at the boundary where useful.
- Controllers should use constructor injection.
- Controllers should delegate meaningful operations to UseCases rather than call Repositories directly.
- A Controller can group several cohesive endpoints while keeping operation-specific UseCases.
ResponseEntityis useful when explicit status codes or headers such asLocationare needed.201 Createdis appropriate for successful resource creation.- Request DTOs should contain client-controlled input, not Order ownership, status, authoritative price, or total.
- Authenticated
CustomerIdshould come from the security boundary rather than request bodies. - Controllers should not manually parse Authorization headers.
- Controller Beans should not store request-specific mutable fields.
- Transaction boundaries should normally belong to UseCases rather than HTTP methods.
- Controllers should not catch and translate every exception independently.
- Response DTOs keep public JSON contracts separate from domain and persistence models.
- Domain objects should not depend on response DTOs.
- Typed DTOs are preferable to generic Maps for stable API contracts.
- Controllers should remain predictable: receive, convert, delegate, map, respond.
- Our architecture remains Handler → UseCase → Repository, with
Servicereserved for external integrations.
Next lesson:
Request and Response Models
There we will design the actual DTOs for Product, Inventory, and Order APIs, decide what the client is allowed to send, what the server returns, and how to avoid exposing domain or persistence models directly through JSON.