Building REST APIs

Controllers in Spring Boot

ReadingPreview

আপনি একটি 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:

  • @RestController defines a Spring-managed HTTP boundary.
  • Spring Controllers act as our HTTP Handlers.
  • @RequestMapping can establish a capability-level URI prefix.
  • @GetMapping, @PostMapping, @PutMapping, and @PatchMapping map approved HTTP operations.
  • @PathVariable binds resource identifiers from the URI.
  • @RequestBody binds JSON to typed request DTOs.
  • @RequestParam handles 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.
  • ResponseEntity is useful when explicit status codes or headers such as Location are needed.
  • 201 Created is appropriate for successful resource creation.
  • Request DTOs should contain client-controlled input, not Order ownership, status, authoritative price, or total.
  • Authenticated CustomerId should 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 Service reserved 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.