Lesson 41 of 159
Written lesson · 17 min

Controllers in Spring Boot

Spring MVC handlers that map HTTP to use cases and keep business workflow out.

Preview

You are viewing a free preview lesson.

In the previous lesson, we designed the v1 endpoints for Products, Inventory and Orders. Now we need to implement them in Spring Boot. In Spring MVC, the HTTP entry boundary is usually a class annotated with @RestController. Our architecture terminology stays the same (Handler → UseCase → Repository), so in our application a Spring @RestController is the HTTP Handler. The goal of this lesson is:

Use Spring MVC to receive HTTP requests, map transport data, invoke use cases, and return HTTP responses without moving business workflows into Controllers.

HTTP is now needed, so we add what 3.01 deferred:

<!-- file: build.gradle (merge) -->
groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
}

It brings Spring MVC, Jackson 3 and an embedded Tomcat: ./gradlew bootRun now keeps listening on port 8080 instead of exiting.

What Is a Spring MVC Controller?

A Spring MVC Controller is an HTTP entry point: Spring MVC routes a request to the handler, and the handler calls a use case. The handler understands transport concerns: the HTTP method, the URI, path variables, query parameters, the request body, the authenticated request context, the HTTP status, headers and the response body. It should not become the place that owns the Order lifecycle, Inventory concurrency, database queries, transactions or the Payment-provider protocol. Those belong deeper in the application.

@RestController Is Our Handler

@RestController tells Spring that a class receives HTTP requests and that its returned values should normally be serialized into HTTP response bodies; for JSON, Jackson (version 3 in Spring Boot 4) does the serialization. It is also a component annotation, so Spring creates the handler as a Bean in the ApplicationContext and supplies its dependencies through the constructor:

java
package com.tarkev.ordermanagement.product.api;

import com.tarkev.ordermanagement.product.application.CreateProductUseCase;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ProductHandler {

    private final CreateProductUseCase createProduct;

    public ProductHandler(CreateProductUseCase createProduct) {
        this.createProduct = createProduct;
    }
}

Spring documentation says Controller; our architecture says Handler. These are not competing concepts: ProductHandler is technically a Spring Controller and architecturally our HTTP Handler. For course consistency, we name the classes ProductHandler, InventoryHandler and OrderHandler rather than making Controller a separate architectural layer.

Constructor injection, not field injection, as established in Module 3: the dependencies are explicit and final, and a request method never constructs new CreateOrderUseCase(...) itself.

Mapping Routes

@RequestMapping("/api/v1/products") on the class defines a common capability-level URI prefix, and mappings inside the class are relative to it. A method annotated @GetMapping then maps GET /api/v1/products, while @GetMapping("/{productId}") maps GET /api/v1/products/{productId}. Spring provides one annotation per HTTP method: @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping. Our API needs the first four, because Product deletion is not part of our v1 Domain.

The Java method name is internal. For a method browse(...) annotated @GetMapping, the public contract is GET /api/v1/products, not browse(). We could rename the method to listProducts(...) without changing the API. Do not confuse Java method naming with HTTP contract design.

Path Variables

Spring binds the dynamic part of the URI with @PathVariable:

<!-- fragment -->
java
@GetMapping("/{productId}")
public ProductResponse get(@PathVariable String productId) {

A request for GET /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5 gives the handler the string "59ba787c-6d83-4a21-9bae-19c3d0469bd5". Spring matches {productId} to the parameter name, which it can read because the Spring Boot Gradle plugin compiles with -parameters; without that flag, we would write @PathVariable("productId").

Prefer explicit names such as {productId} and {orderId} over a generic {id}. Keep routes clear, too: Spring can tell a static @GetMapping("/history") from @GetMapping("/{orderId}"), but we avoid designs that rely on clever pattern interactions, and our API needs no /history route.

Convert Transport Types at the Boundary

HTTP represents our UUID IDs as strings, while the domain uses ProductId(UUID) and OrderId(UUID). The handler converts the string to a UUID and then to a ProductId, and calls getProduct.execute(toProductId(productId)):

<!-- fragment -->
java
private ProductId toProductId(String rawProductId) {
    return new ProductId(UUID.fromString(rawProductId));
}

Application code works with ProductId, not raw strings, which gives us type safety, clear domain vocabulary and one conversion boundary. Malformed UUID syntax is a request problem: UUID.fromString throws IllegalArgumentException, and the validation and error lessons turn exactly this parse failure into 400 / INVALID_REQUEST, not a 500.

Request Bodies and Request DTOs

For Product creation, the client sends:

http
POST /api/v1/products
Content-Type: application/json

{
  "name": "Mechanical Keyboard",
  "priceCents": 10000
}

Spring deserializes the JSON into the parameter annotated with @RequestBody, whose type is a request DTO:

java
package com.tarkev.ordermanagement.product.api;

public record CreateProductRequest(String name, long priceCents) {
}

This is transport data, not the Product domain entity, and it represents exactly what the client is allowed to send. It has no id or active field: the application generates the ProductId, and every new Product starts active. This preserves our trust boundary. Avoid @RequestBody Map<String, Object> or a domain entity as the body, and domain or JPA objects as responses; the next lesson designs all our request and response models in detail.

Keep Business Workflow Out of the Handler

Avoid this:

<!-- fragment -->
java
@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 and persistence. That is application workflow, and it belongs in CreateProductUseCase, which uses Product and ProductRepository. The handler maps the request to use case input, calls the use case and maps the result:

Drawing diagram…

Thin does not mean one line, though: reading input, obtaining the identity, converting IDs, calling the use case and mapping the response can take several lines. "Thin handler" means:

The handler does not own the business workflow.

ResponseEntity, 201 Created and Location

ResponseEntity<T> gives the handler explicit control over the HTTP status, headers and body. ResponseEntity.status(HttpStatus.CREATED).body(response) communicates 201 Created; for a new resource, ResponseEntity.created(location) also sets the Location header:

http
HTTP/1.1 201 Created
Location: /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5

Here is the first working version of ProductHandler, with the response DTO it returns. It calls CreateProductUseCase with its command record CreateProductCommand(name, priceCents), both from 4.04:

java
package com.tarkev.ordermanagement.product.api;

import com.tarkev.ordermanagement.product.application.CreateProductCommand;
import com.tarkev.ordermanagement.product.application.CreateProductUseCase;
import com.tarkev.ordermanagement.product.domain.Product;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/products")
public class ProductHandler {

    private final CreateProductUseCase createProduct;

    public ProductHandler(CreateProductUseCase createProduct) {
        this.createProduct = createProduct;
    }

    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @RequestBody CreateProductRequest request) {
        Product product = createProduct.execute(new CreateProductCommand(
                request.name(), request.priceCents()));
        URI location = URI.create("/api/v1/products/" + product.id().value());
        return ResponseEntity.created(location)
                .body(ProductResponse.from(product));
    }
}
java
package com.tarkev.ordermanagement.product.api;

import com.tarkev.ordermanagement.product.domain.Product;

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());
    }
}

The handler owns the HTTP metadata; the use case knows nothing about 201, Location or URI. URI.create(...) keeps the example easy to read; Spring's ServletUriComponentsBuilder avoids fragile string concatenation. Either way, URI construction is an HTTP boundary responsibility. For a fixed status, @ResponseStatus(HttpStatus.CREATED) on the method also works, but ResponseEntity is more flexible when status, Location and body go together.

One Handler per Capability, One UseCase per Operation

We do not require one Controller class for every endpoint. As 3.07's OrderHandler already showed, one handler serves one resource: with Spring MVC, one ProductHandler holds the collection GET, the single GET, the create POST, the PATCH update and the deactivate POST. Each operation still delegates to a dedicated use case: BrowseProductsUseCase, GetProductUseCase, CreateProductUseCase, UpdateProductUseCase and DeactivateProductUseCase. The handler owns the Product HTTP mappings; each use case owns one application workflow.

Are five use case dependencies too many? Not automatically: the constructor makes the exposed operations visible. If the class becomes hard to understand because of its size, cohesion or very different endpoint concerns, we split it, but not merely because of five constructor parameters. Nor do we replace the five use cases with one generic ProductService; Service stays reserved for external integrations such as PaymentService (2.04).

The Complete ProductHandler

Here is the target shape of ProductHandler. Its other use cases and the page DTO arrive in later lessons, so we read it as a sketch:

<!-- fragment -->
java
@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;

    // constructor injection of all five UseCases, create(...) as above

    @GetMapping
    public PageResponse<ProductBrowseResponse> browse(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return PageResponse.from(
                browseProducts.execute(new PageQuery(page, size)),
                ProductBrowseResponse::from);
    }

    @GetMapping("/{productId}")
    public ProductBrowseResponse get(@PathVariable String productId) {
        return ProductBrowseResponse.from(
                getProduct.execute(toProductId(productId)));
    }

    @PatchMapping("/{productId}")
    public ProductResponse update(@PathVariable String productId,
            @RequestBody UpdateProductRequest request) {
        Product product = updateProduct.execute(new UpdateProductCommand(
                toProductId(productId), request.name(), request.priceCents()));
        return ProductResponse.from(product);
    }

    @PostMapping("/{productId}/deactivate")
    public ProductResponse deactivate(@PathVariable String productId) {
        return ProductResponse.from(
                deactivateProduct.execute(toProductId(productId)));
    }

    private ProductId toProductId(String rawProductId) {
        return new ProductId(UUID.fromString(rawProductId));
    }
}

Our Product collection is paginated, so browse does not return an unbounded List<ProductResponse>. @RequestParam binds the page and size query parameters as transport input, while BrowseProductsUseCase is the application query. The defaults and the limits the API later enforces (page >= 0, 1 <= size <= 100) are deliberate API decisions; the page types PageQuery and PageResponse belong to the pagination lesson (5.08). For get, the handler owns the conversion to ProductId, and the use case owns Product retrieval semantics.

The PATCH request may contain name and priceCents, where an omitted or null field means "unchanged":

java
package com.tarkev.ordermanagement.product.api;

public record UpdateProductRequest(String name, Long priceCents) {
}

Long instead of long is deliberate: the contract must distinguish an absent field (no change) from priceCents = 0 (an explicit zero price), and a primitive long has no "absent" value: under Jackson 3, a missing or null price for it fails deserialization. The handler does not decide "if a name was supplied, call product.rename(); if a price was supplied, call product.changePrice()"; that workflow belongs in UpdateProductUseCase. Likewise, deactivate does not write product.setActive(false) or even load the Product; it delegates the business operation to DeactivateProductUseCase.

The methods are repetitive: receive, convert, delegate, map, respond. That is good: HTTP boundary code should be boring and predictable, and business complexity belongs below it, not in clever Controller methods.

OrderHandler and the Create Order Request

OrderHandler, mapped to /api/v1/orders, groups the cohesive Order routes. The create request DTO contains only Customer-controlled intent:

java
package com.tarkev.ordermanagement.order.api;

import java.util.List;

public record CreateOrderRequest(List<CreateOrderItemRequest> items) {
}
java
package com.tarkev.ordermanagement.order.api;

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

The server-controlled values from 5.01 (customerId, the Order ID, status, prices, total, createdAt) are absent. Spring Security will provide the authenticated Customer identity in Module 8; until then, we write it as CustomerId customerId = currentUser.customerId().

The handler combines the authenticated CustomerId with the request items into application input, so the use case receives typed IDs rather than raw JSON strings:

<!-- fragment -->
java
private CreateOrderCommand toCommand(
        CustomerId customerId, CreateOrderRequest request) {
    List<CreateOrderCommand.Item> items = request.items().stream()
            .map(item -> new CreateOrderCommand.Item(
                    toProductId(item.productId()), item.quantity()))
            .toList();
    return new CreateOrderCommand(customerId, items);
}

Do we always need a Command? No. A UseCase signature may take a suitable application input directly. What matters is that an HTTP DTO is not automatically application input. Create a Command when it improves clarity, transport independence or application vocabulary, not because every use case is expected to have one.

Order Mappings

The Order methods follow the same shape:

<!-- fragment -->
java
@PostMapping
public ResponseEntity<OrderResponse> create(
        @RequestBody CreateOrderRequest request) {
    CustomerId customerId = currentUser.customerId();
    Order order = createOrder.execute(toCommand(customerId, request));
    URI location = URI.create("/api/v1/orders/" + order.id().value());
    return ResponseEntity.created(location).body(OrderResponse.from(order));
}

@GetMapping
public PageResponse<OrderSummaryResponse> getOrders(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    CustomerId customerId = currentUser.customerId();
    return PageResponse.from(
            getOrderHistory.execute(customerId, new PageQuery(page, size)),
            OrderHandler::toSummaryResponse);
}

@GetMapping("/{orderId}")
public OrderResponse get(@PathVariable String orderId) {
    CustomerId customerId = currentUser.customerId();
    return OrderResponse.from(getOrder.execute(toOrderId(orderId), customerId));
}

@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(@PathVariable String orderId) {
    CustomerId customerId = currentUser.customerId();
    Order order = cancelOrder.execute(
            new CancelOrderCommand(customerId, toOrderId(orderId)));
    return OrderResponse.from(order);
}

Creation returns 201 Created with a Location, and the use case knows nothing about HTTP. Order history uses the authenticated identity, never a customerId query parameter. For get, the handler does not load an arbitrary Order, compare CustomerIds or choose between 403 and 404: the ownership contract belongs to the application operation. For cancel, the lookup, status check, Inventory restoration and transaction are absent; they belong to CancelOrderUseCase, Order and the repositories.

pay follows the same shape, with no request DTO until the Payment contract needs one. The handler never builds a provider request itself: the path is OrderHandler → PayOrderUseCase → PaymentService.

InventoryHandler: Identity Belongs in the URI

InventoryHandler is mapped to /api/v1/inventory. The Admin mutation needs only the new quantity, because the target Product identity is already in the URI:

java
package com.tarkev.ordermanagement.inventory.api;

public record SetInventoryRequest(int availableQuantity) {
}
<!-- fragment -->
java
@PutMapping("/{productId}")
public InventoryResponse setInventory(@PathVariable String productId,
        @RequestBody SetInventoryRequest request) {
    Inventory inventory = setInventory.execute(new SetInventoryCommand(
            toProductId(productId), request.availableQuantity()));
    return InventoryResponse.from(inventory);
}

The handler maps transport, SetInventoryUseCase coordinates the operation and Inventory protects the quantity invariant. The body is {"availableQuantity": 20} and does not repeat the productId unless a real contract requires it. Imagine PUT /api/v1/inventory/59ba787c-... with a body containing "productId": "364ec915-...": which Product does the request target? The URI carries the resource identity; the body carries the requested mutable input. The same holds for PATCH /api/v1/products/{productId} with {"priceCents": 12000}, not a body that repeats "id".

Handlers Do Not Call Repositories Directly

Avoid:

<!-- fragment -->
java
@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(@PathVariable String orderId) {
    Order order = orderRepository.findById(...);
    order.cancel();
    orderRepository.save(order);
    return ...;
}

This handler has begun implementing the cancellation workflow. Instead, OrderHandler calls CancelOrderUseCase, which coordinates OrderRepository, InventoryRepository and Order. A Handler that calls a repository for a mutation bypasses domain rules, cross-capability coordination, transaction ownership and authorization semantics.

What about simple reads? GET /api/v1/products/{productId} could technically call a repository, but the read may involve Customer visibility, Product and Inventory query semantics, not-found rules and the authorization context. GetProductUseCase keeps a clear application boundary; even a small use case represents application intent. At the same time, we add no Facade, Manager or QueryService to a simple query: a use case with one meaningful query call is fine.

Headers and Lower-Level Request Access

@RequestHeader("X-Request-Id") String requestId binds a header for a specific transport concern. But authentication, correlation and tracing belong to framework-level infrastructure that applies them consistently, not to every handler. In particular, do not bind @RequestHeader("Authorization") and call authorization.substring(...) inside Product or Order handlers: authentication belongs to Spring Security, and the handler works with the authenticated identity after the security boundary has processed the request.

Likewise, do not take an HttpServletRequest by default and parse the URI, query parameters, headers and JSON by hand. @PathVariable, @RequestParam, @RequestBody and @RequestHeader already provide typed binding; use servlet APIs only when a real transport requirement needs them.

Handlers Are Shared: No Request State in Fields

Spring Controllers are normally singleton Beans, so one OrderHandler instance may serve many requests concurrently. Its fields hold stable dependencies such as private final GetOrderUseCase getOrder, never request-specific state:

<!-- fragment -->
java
@RestController
public class OrderHandler {

    private CustomerId currentCustomer;
    private CreateOrderRequest currentRequest;
}

Two concurrent requests could overwrite these fields. Request-specific values, such as the CustomerId or the request DTO, stay in method parameters and local variables: they flow into the use case call and then disappear.

Transactions and Errors Stay Out of the Handler

Avoid @Transactional on a @PostMapping method as the way to define a business transaction. Create Order's transaction belongs to CreateOrderUseCase; if it lived only on OrderHandler, another entry point calling the use case would lose the consistency guarantee (Module 7 owns transaction boundaries).

Avoid a try { ... } catch (Exception ex) in every handler, too. It duplicates error translation and drifts into inconsistent formats; when OrderNotFound occurs, every handler must produce the same 404. Lesson 5.07 implements that translation once, centrally.

Handler classes and their DTOs live in each capability's api/ package (order/api/OrderHandler.java), following the structure from 3.07.

Testing Each Layer

Handler tests check the transport: URI mapping, request deserialization, conversion, validation, status, response JSON and error mapping. UseCase and domain tests check workflow and invariants without HTTP (Module 10). Do not re-test the domain through every handler. "A PAID Order cannot be cancelled" belongs in the Order domain tests; a handler test only verifies that OrderNotCancellable becomes 409 ORDER_NOT_CANCELLABLE.

Practice Exercise 1: Call Your First Endpoint

Solutions: lesson 5.13.

Start the application with ./gradlew bootRun --args='--spring.profiles.active=local' and, in a second terminal, send requests with curl -i so that you see the status and headers. A body is sent with -H 'Content-Type: application/json' -d '<json>'. Predict each status first, then run:

  1. POST localhost:8080/api/v1/products with {"name": "Mechanical Keyboard", "priceCents": 8990}. Which headers and body fields come back?
  2. GET the URL from the Location header of the first response.
  3. GET localhost:8080/api/v1/products.
  4. POST with {"name": "Desk Lamp"}, without a price.
  5. POST with {"name": "Keyboard", "priceCents": 89.9}.
  6. POST with {"name": " ", "priceCents": 1000}.

Some answers will not match the contract yet. Note which ones; 5.05 to 5.07 return to them.

Practice Exercise 2: Review a Handler

Find everything that does not belong in this handler, and say where each responsibility lives in our design:

<!-- fragment -->
java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderHandler {

    private final OrderRepository orderRepository;
    private CustomerId customerId;

    public OrderHandler(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @PostMapping("/{orderId}/cancel")
    @Transactional
    public ResponseEntity<?> cancel(@PathVariable String orderId,
            @RequestHeader("Authorization") String authorization) {
        customerId = new CustomerId(authorization.substring(7));
        try {
            Order order = orderRepository
                    .findById(new OrderId(UUID.fromString(orderId)))
                    .orElseThrow();
            order.cancel();
            orderRepository.save(order);
            return ResponseEntity.ok(order);
        } catch (Exception exception) {
            return ResponseEntity.badRequest().body(exception.getMessage());
        }
    }
}

Knowledge Check

Question 1

Why must a value such as the current CustomerId never be stored in a handler field?

Question 2

You rename ProductHandler.create(...) to createProduct(...). What changes for clients?

Question 3

Why does the handler, not CreateProductUseCase, choose 201 Created and build the Location URI?

Question 4

With CreateProductRequest(String name, long priceCents), what happens when a client omits the price, and why is that not good enough?

Question 5

GET /api/v1/products/{productId} could call the repository directly. Why does it get a GetProductUseCase?

Knowledge Check Answers

Answer 1

A handler is a singleton Bean that serves many requests at the same time. Two concurrent requests would overwrite each other's field, so request values stay in parameters and local variables.

Answer 2

Nothing. The contract is POST /api/v1/products from the mapping annotations; the Java method name is internal.

Answer 3

Status codes, headers and URIs are HTTP concerns, and the handler is the HTTP boundary. The use case returns the created Product and stays usable from any entry point.

Answer 4

Jackson 3 refuses to put a missing value into a primitive, so the request fails before the handler runs, as an unreadable body. The client learns only "bad request", not that priceCents is missing; 5.06 changes the field to Long to get a field error.

Answer 5

The read has application semantics: Customer visibility, the not-found rule and later the caller's authorization. A small use case keeps them in the application layer instead of in the handler or the repository.

Engineering Principle

A Spring Controller is an HTTP Handler: receive transport input, delegate to a use case, and translate the result back into HTTP.

Summary

  • A @RestController is our HTTP Handler: a singleton Bean with constructor-injected use cases and method-local request state.
  • @RequestMapping, the method mapping annotations, @PathVariable, @RequestParam, @RequestBody and @RequestHeader bind HTTP to typed Java; Java method names are not the contract.
  • The handler converts transport values such as UUID strings into domain types and builds application input such as CreateOrderCommand when it helps.
  • Request DTOs carry only client-controlled intent; the URI carries resource identity, and the authenticated identity supplies the CustomerId.
  • ResponseEntity controls status, headers and body; creation returns 201 Created with a Location.
  • One handler per capability delegates to operation-specific use cases and does not call repositories, own transactions, catch every exception or speak the provider protocol.
  • Handler tests check transport, use case tests check workflow and domain tests check invariants.

In the next lesson, we design the request and response models these handlers use.

Think first

Coming soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.