Building REST APIs

Controllers in Spring Boot

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

So far, we have designed our HTTP API contract.

Our current v1 endpoints are:

PRODUCTS

GET   /api/v1/products
GET   /api/v1/products/{productId}
POST  /api/v1/products
PATCH /api/v1/products/{productId}
POST  /api/v1/products/{productId}/deactivate
INVENTORY

GET /api/v1/inventory
GET /api/v1/inventory/{productId}
PUT /api/v1/inventory/{productId}
ORDERS

POST /api/v1/orders
GET /api/v1/orders
GET /api/v1/orders/{orderId}
POST /api/v1/orders/{orderId}/cancel
POST /api/v1/orders/{orderId}/pay

Now we need to implement those HTTP endpoints in Spring Boot.

In Spring MVC, the HTTP entry boundary is usually implemented using:

@RestController

Our architecture terminology remains:

Handler
    ↓
UseCase
    ↓
Repository

So in our application:

Spring @RestController
=
HTTP Handler

The goal of this lesson is:

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


What Is a Spring MVC Controller?

A Spring MVC Controller is an HTTP entry point.

Conceptually:

HTTP Request
    ↓
Spring MVC
    ↓
Handler
    ↓
UseCase

The Handler understands transport concerns such as:

HTTP method

URI

path variables

query parameters

request body

authenticated request context

HTTP status

headers

response body

It should not become the place that owns:

Order lifecycle

Inventory concurrency

database queries

transactions

Payment-provider protocol

Those responsibilities belong deeper in the application.


@RestController

A simple HTTP Handler might begin as:

package io.tarkev.ordermanagement.product.api;

import org.springframework.web.bind.annotation.RestController;

@RestController
public class ProductHandler {
}

@RestController tells Spring that:

this class receives HTTP requests

and that its returned values should normally be serialized into:

HTTP response bodies

For JSON APIs, Jackson commonly performs the serialization.


Spring Controller vs Architectural Handler

Spring documentation commonly uses the word:

Controller

Our application architecture uses:

Handler

These are not competing concepts.

We can write:

@RestController
public class ProductHandler {
}

The class is technically a Spring Controller.

Architecturally, it is our:

HTTP Handler

For course consistency, we will prefer names such as:

ProductHandler

InventoryHandler

OrderHandler

rather than making Controller a separate architectural layer.


Controllers Are Spring Beans

@RestController is a Spring component annotation.

Spring therefore creates the Handler as a Bean inside the:

ApplicationContext

Dependencies should be supplied using constructor injection.

For example:

@RestController
public class ProductHandler {

    private final CreateProductUseCase createProduct;

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

Spring resolves:

CreateProductUseCase

from the Application Context.


Avoid Field Injection

Avoid:

@RestController
public class ProductHandler {

    @Autowired
    private CreateProductUseCase createProduct;
}

Constructor injection is stronger because:

dependencies are explicit

the field can be final

construction requirements are visible

plain unit tests can instantiate the Handler

the object cannot exist without required dependencies

This is consistent with the dependency-injection principles we established earlier.


@RequestMapping

A Handler may define a common capability-level URI prefix.

For Product:

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

Now mappings inside the class are relative to:

/api/v1/products

For example:

@GetMapping

maps:

GET /api/v1/products

while:

@GetMapping("/{productId}")

maps:

GET /api/v1/products/{productId}

HTTP Method Mapping Annotations

Spring provides dedicated mapping annotations:

@GetMapping

@PostMapping

@PutMapping

@PatchMapping

@DeleteMapping

Our current API primarily needs:

@GetMapping

@PostMapping

@PutMapping

@PatchMapping

because Product deletion is not part of our v1 Domain.


Java Method Names Are Internal

Consider:

@GetMapping
public ProductPageResponse browse(...) {
    ...
}

The public API contract is:

GET /api/v1/products

not:

browse()

We could rename the Java method:

listProducts(...)

without changing the API, as long as the mapping remains unchanged.

Do not confuse:

Java method naming

with:

HTTP contract design

Path Variables

For:

GET /api/v1/products/{productId}

Spring binds the dynamic value using:

@PathVariable

For example:

@GetMapping("/{productId}")
public ProductResponse get(
        @PathVariable String productId
) {
    ...
}

A request such as:

GET /api/v1/products/59ba787c-6d83-4a21-9bae-19c3d0469bd5

produces the transport value:

"59ba787c-6d83-4a21-9bae-19c3d0469bd5"

inside the Handler.


Convert Transport Types at the Boundary

HTTP represents our UUID IDs as strings.

The Domain uses:

ProductId(UUID)

OrderId(UUID)

So the Handler boundary can convert:

String
    ↓
UUID
    ↓
ProductId

For example:

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

Then:

Product product =
        getProduct.execute(
                toProductId(
                        productId
                )
        );

Raw HTTP strings do not need to leak unnecessarily into application code.


Why Conversion Belongs Near the Boundary

The HTTP layer knows:

UUID came from a path string

The application should work with:

ProductId

instead.

This gives us:

type safety

clear Domain vocabulary

one transport conversion boundary

Malformed UUID syntax is an HTTP/request problem and will later map to our:

400 / INVALID_REQUEST

or validation-error contract.


Request Bodies

For Product creation:

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

with:

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

Spring can deserialize the JSON using:

@RequestBody

For example:

@PostMapping
public ResponseEntity<ProductResponse> create(
        @RequestBody
        CreateProductRequest request
) {
    ...
}

Request DTOs

A Product creation request may be:

package io.tarkev.ordermanagement.product.api;

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

This is:

transport data

It is not the Product Domain Entity.

The DTO represents exactly what the client is allowed to send.


Server-Owned Product Fields Stay Out of the Request

CreateProductRequest does not contain:

ProductId

active

because those values are server-controlled.

The client provides:

name

priceCents

The application decides:

ProductId
→ generated UUID

active
→ true

This preserves our trust boundary.


Handler Maps Request to UseCase Input

A straightforward Product creation flow is:

@PostMapping
public ResponseEntity<ProductResponse> create(
        @RequestBody
        CreateProductRequest request
) {
    Product product =
            createProduct.execute(
                    request.name(),
                    request.priceCents()
            );

    ProductResponse response =
            ProductResponse.from(
                    product
            );

    return ResponseEntity
            .status(
                    HttpStatus.CREATED
            )
            .body(
                    response
            );
}

Conceptually:

JSON
    ↓
CreateProductRequest
    ↓
ProductHandler
    ↓
CreateProductUseCase
    ↓
Product
    ↓
ProductResponse
    ↓
JSON

Keep Business Workflow Out of the Handler

Avoid:

@PostMapping
public ProductResponse create(
        @RequestBody
        CreateProductRequest request
) {
    if (
            request.priceCents() < 0
    ) {
        throw new IllegalArgumentException();
    }

    Product product =
            Product.create(
                    ProductId.newId(),
                    request.name(),
                    request.priceCents()
            );

    productRepository.save(
            product
    );

    return ProductResponse.from(
            product
    );
}

Now the Handler owns:

Domain construction

business validation

ID generation

persistence

That is application workflow.

The correct direction is:

ProductHandler
    ↓
CreateProductUseCase
    ↓
Product + ProductRepository

Thin Does Not Mean One Line

A good Handler may still legitimately:

read path variables

read query parameters

deserialize request DTOs

obtain authenticated identity

convert UUID strings

construct application input

invoke a UseCase

map output to response DTO

set HTTP status

set headers

That can require multiple lines.

"Thin Handler" means:

The Handler does not own the business workflow.

It does not mean:

The method must contain only one statement.


ResponseEntity

Spring's:

ResponseEntity<T>

is useful when the Handler needs explicit control over:

HTTP status

headers

response body

For example:

return ResponseEntity
        .status(
                HttpStatus.CREATED
        )
        .body(
                response
        );

This communicates:

201 Created

explicitly.


Location Header

For newly created resources, the HTTP response can include a:

Location

header.

For example:

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

Spring supports this through:

ResponseEntity.created(...)

Conceptually:

URI location =
        URI.create(
                "/api/v1/products/" +
                        product.id()
                                .value()
        );

return ResponseEntity
        .created(
                location
        )
        .body(
                response
        );

The Handler owns HTTP metadata.

The UseCase does not know about:

201

Location

URI

Prefer URI-Building Utilities When Useful

For simple teaching examples:

URI.create(...)

keeps the code easy to read.

Larger applications may use Spring URI-building utilities to avoid fragile string concatenation.

The important architectural rule is:

URI construction
→ HTTP boundary responsibility

not Domain or UseCase responsibility.


Capability-Focused Handlers

We do not require one Spring Controller class for every endpoint.

For example, Product can use one:

ProductHandler

containing:

GET collection

GET one

POST create

PATCH update

POST deactivate

while each operation still delegates to a dedicated UseCase.

This is a pragmatic structure.


Keep Operation-Specific UseCases

Even if one Handler groups Product routes, keep application operations separate:

BrowseProductsUseCase

GetProductUseCase

CreateProductUseCase

UpdateProductUseCase

DeactivateProductUseCase

Avoid replacing them with one generic:

ProductService

Our application convention remains:

Handler
    ↓
UseCase
    ↓
Repository

and:

Service
→ external/third-party integration

such as:

PaymentService

ProductHandler Structure

Conceptually:

package io.tarkev.ordermanagement.product.api;

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

    private final BrowseProductsUseCase browseProducts;
    private final GetProductUseCase getProduct;
    private final CreateProductUseCase createProduct;
    private final UpdateProductUseCase updateProduct;
    private final DeactivateProductUseCase deactivateProduct;

    public ProductHandler(
            BrowseProductsUseCase browseProducts,
            GetProductUseCase getProduct,
            CreateProductUseCase createProduct,
            UpdateProductUseCase updateProduct,
            DeactivateProductUseCase deactivateProduct
    ) {
        this.browseProducts =
                browseProducts;

        this.getProduct =
                getProduct;

        this.createProduct =
                createProduct;

        this.updateProduct =
                updateProduct;

        this.deactivateProduct =
                deactivateProduct;
    }
}

This class owns:

Product HTTP mappings

Each UseCase owns one application workflow.


Product Browse Mapping

Our Product collection is paginated.

So instead of returning an unbounded:

List<ProductResponse>

the Handler should eventually work with our pagination contract.

Conceptually:

@GetMapping
public ProductPageResponse browse(
        @RequestParam(
                defaultValue = "0"
        )
        int page,

        @RequestParam(
                defaultValue = "20"
        )
        int size
) {
    return ProductPageResponse.from(
            browseProducts.execute(
                    page,
                    size
            )
    );
}

The exact page DTO is handled in the pagination lesson.

The important point here is:

@RequestParam
→ transport input

while:

BrowseProductsUseCase
→ application query

Product Detail Mapping

Conceptually:

@GetMapping("/{productId}")
public ProductResponse get(
        @PathVariable
        String productId
) {
    Product product =
            getProduct.execute(
                    toProductId(
                            productId
                    )
            );

    return ProductResponse.from(
            product
    );
}

The Handler owns:

String path variable
→ ProductId

The UseCase owns Product retrieval semantics.


Product Update

Our Product PATCH request may contain:

name

priceCents

with:

omitted/null
→ unchanged

A conceptual DTO:

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

Why use:

Long

instead of:

long

here?

Because our PATCH contract needs to distinguish:

field absent/null
→ no change

from:

priceCents = 0
→ explicitly set zero price

A primitive long could not represent that distinction after deserialization.


Updating Product in the Handler

Conceptually:

@PatchMapping("/{productId}")
public ProductResponse update(
        @PathVariable
        String productId,

        @RequestBody
        UpdateProductRequest request
) {
    Product product =
            updateProduct.execute(
                    toProductId(
                            productId
                    ),
                    request.name(),
                    request.priceCents()
            );

    return ProductResponse.from(
            product
    );
}

The Handler does not perform:

if name supplied
→ product.rename()

if price supplied
→ product.changePrice()

That application workflow belongs in:

UpdateProductUseCase

Deactivate Product

Canonical mapping:

@PostMapping(
        "/{productId}/deactivate"
)
public ProductResponse deactivate(
        @PathVariable
        String productId
) {
    Product product =
            deactivateProduct.execute(
                    toProductId(
                            productId
                    )
            );

    return ProductResponse.from(
            product
    );
}

The Handler does not write:

product.setActive(
        false
);

It does not even load the Product itself.

It delegates the business operation to:

DeactivateProductUseCase

OrderHandler

Our Order API contains:

POST /api/v1/orders

GET /api/v1/orders

GET /api/v1/orders/{orderId}

POST /api/v1/orders/{orderId}/cancel

POST /api/v1/orders/{orderId}/pay

A natural HTTP boundary is:

@RestController
@RequestMapping("/api/v1/orders")
public class OrderHandler {
}

Again, one Handler may group several cohesive Order routes.


Create Order Request

The request DTO contains only Customer-controlled intent.

public record CreateOrderRequest(
        List<CreateOrderItemRequest> items
) {
}

with:

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

Notice what is absent:

customerId

OrderId

status

unitPriceCents

totalCents

createdAt

Those values remain server-controlled.


Customer Identity Does Not Come From the Body

Eventually, Spring Security will provide the authenticated Customer identity.

Conceptually:

CustomerId customerId =
        currentUser.customerId();

Then:

createOrder.execute(
        customerId,
        command
);

We do not accept:

{
  "customerId": "another-customer"
}

for Customer-owned Order creation.

The exact Spring Security implementation is introduced later.

The architectural rule is already fixed:

authenticated identity
→ CustomerId

Mapping Create Order Items

The Handler may convert request items into application input.

For example:

List<CreateOrderCommand.Item> items =
        request.items()
                .stream()
                .map(item ->
                        new CreateOrderCommand.Item(
                                toProductId(
                                        item.productId()
                                ),
                                item.quantity()
                        )
                )
                .toList();

CreateOrderCommand command =
        new CreateOrderCommand(
                items
        );

Then:

Order order =
        createOrder.execute(
                customerId,
                command
        );

The UseCase receives typed Product IDs rather than raw JSON strings.


Do We Always Need a Command?

No.

We could design a UseCase signature that takes a suitable application input directly.

The important distinction is:

HTTP DTO
≠ automatically
application input

Create a Command when it improves:

clarity

transport independence

application vocabulary

Do not add a new type merely because every UseCase is expected to have one.


Create Order Response

Successful creation returns:

201 Created

and may include:

Location

Conceptually:

@PostMapping
public ResponseEntity<OrderResponse> create(
        @RequestBody
        CreateOrderRequest request
) {
    CustomerId customerId =
            currentUser.customerId();

    Order order =
            createOrder.execute(
                    customerId,
                    toCommand(
                            request
                    )
            );

    OrderResponse response =
            OrderResponse.from(
                    order
            );

    URI location =
            URI.create(
                    "/api/v1/orders/" +
                            order.id()
                                    .value()
            );

    return ResponseEntity
            .created(
                    location
            )
            .body(
                    response
            );
}

The Handler owns:

201

Location

OrderResponse

The UseCase knows nothing about HTTP.


Order History

Customer Order history uses authenticated identity.

Conceptually:

@GetMapping
public OrderPageResponse getOrders(
        @RequestParam(
                defaultValue = "0"
        )
        int page,

        @RequestParam(
                defaultValue = "20"
        )
        int size
) {
    CustomerId customerId =
            currentUser.customerId();

    return OrderPageResponse.from(
            getOrderHistory.execute(
                    customerId,
                    page,
                    size
            )
    );
}

The Handler does not accept:

customerId

as a Customer-controlled query parameter.


Get One Order

Conceptually:

@GetMapping("/{orderId}")
public OrderResponse get(
        @PathVariable
        String orderId
) {
    CustomerId customerId =
            currentUser.customerId();

    Order order =
            getOrder.execute(
                    toOrderId(
                            orderId
                    ),
                    customerId
            );

    return OrderResponse.from(
            order
    );
}

The Handler does not:

load arbitrary Order

compare CustomerId manually

choose 403 vs 404

The Customer ownership contract belongs to the application operation.

Our canonical behavior is:

missing Order
or
non-owned Order

→ ORDER_NOT_FOUND

Cancel Order

Canonical mapping:

@PostMapping(
        "/{orderId}/cancel"
)
public OrderResponse cancel(
        @PathVariable
        String orderId
) {
    CustomerId customerId =
            currentUser.customerId();

    Order order =
            cancelOrder.execute(
                    toOrderId(
                            orderId
                    ),
                    customerId
            );

    return OrderResponse.from(
            order
    );
}

Notice what is absent:

Repository lookup

Order-status check

row locking

Inventory restoration

transaction management

Those belong to:

CancelOrderUseCase

Order

Repositories

Pay Order

Conceptually:

@PostMapping(
        "/{orderId}/pay"
)
public OrderResponse pay(
        @PathVariable
        String orderId
) {
    CustomerId customerId =
            currentUser.customerId();

    Order order =
            payOrder.execute(
                    toOrderId(
                            orderId
                    ),
                    customerId
            );

    return OrderResponse.from(
            order
    );
}

Future Payment-provider requirements may introduce additional request input.

We do not invent that DTO now.

The endpoint shape remains:

POST /api/v1/orders/{orderId}/pay

OrderHandler Should Not Know Payment Protocol

Avoid code such as:

@PostMapping("/{orderId}/pay")
public ...
{
    ProviderPaymentRequest paymentRequest =
            ...;

    providerClient.capture(
            paymentRequest
    );

    ...
}

That would push provider integration into the HTTP boundary.

The correct architecture is:

OrderHandler
    ↓
PayOrderUseCase
    ↓
PaymentService
    ↓
Provider implementation

Provider request/response DTOs stay behind PaymentService.


InventoryHandler

Canonical Inventory routes are:

GET /api/v1/inventory

GET /api/v1/inventory/{productId}

PUT /api/v1/inventory/{productId}

So:

@RestController
@RequestMapping("/api/v1/inventory")
public class InventoryHandler {
}

is a natural transport boundary.


SetInventoryRequest

The Admin mutation requires only:

availableQuantity

So:

public record SetInventoryRequest(
        int availableQuantity
) {
}

is enough.

The target Product identity already appears in the URI.


Set Inventory Mapping

Conceptually:

@PutMapping("/{productId}")
public InventoryResponse setInventory(
        @PathVariable
        String productId,

        @RequestBody
        SetInventoryRequest request
) {
    Inventory inventory =
            setInventory.execute(
                    toProductId(
                            productId
                    ),
                    request.availableQuantity()
            );

    return InventoryResponse.from(
            inventory
    );
}

Responsibility remains:

Handler
→ maps transport

SetInventoryUseCase
→ coordinates application operation

Inventory
→ protects quantity invariant

Identity Belongs in the URI

For:

PUT /api/v1/inventory/{productId}

the URI already answers:

Which Inventory state?

So the body should contain:

{
  "availableQuantity": 20
}

not:

{
  "productId": "...",
  "availableQuantity": 20
}

unless a real contract requires duplication.


Avoid Conflicting IDs

Imagine:

PUT /api/v1/inventory/59ba787c-...

with:

{
  "productId": "364ec915-...",
  "availableQuantity": 20
}

Which Product does the request target?

Good API design avoids creating that ambiguity.

Use:

URI
→ resource identity

and:

body
→ requested mutable input

Same Principle for Product PATCH

Prefer:

PATCH /api/v1/products/{productId}

with:

{
  "priceCents": 12000
}

rather than:

{
  "id": "...",
  "priceCents": 12000
}

The URI already identifies the Product.


Controllers Should Not Call Repositories Directly

Avoid:

@RestController
public class OrderHandler {

    private final OrderRepository orderRepository;

    @PostMapping("/{orderId}/cancel")
    public OrderResponse cancel(...) {
        Order order =
                orderRepository
                        .findById(...);

        order.cancel();

        orderRepository.save(
                order
        );

        return ...;
    }
}

Cancellation actually requires:

authenticated ownership

ownership-aware locking

Order lifecycle

Inventory restoration

one local transaction

The Handler has begun implementing the workflow.

Correct:

OrderHandler
    ↓
CancelOrderUseCase
    ↓
OrderRepository
    +
InventoryRepository
    +
Order

What About Simple Reads?

Could:

GET /api/v1/products/{productId}

call a Repository directly because it is only a read?

Technically, it could.

But our Product read may involve:

Customer visibility

Product + Inventory query semantics

not-found rules

authorization context

So keeping:

GetProductUseCase

preserves a clear application boundary.

Even when a UseCase is small, it still represents application intent.


Avoid Ceremony for Its Own Sake

At the same time, do not respond to a simple query by introducing:

Handler
→ Facade
→ Manager
→ Service
→ UseCase
→ QueryService
→ Repository

Our architecture remains deliberately simple:

Handler
→ UseCase
→ Repository

A UseCase may sometimes contain one meaningful query call.

That is fine.


@RequestParam

Spring binds query parameters using:

@RequestParam

Our pagination endpoints will use:

page

size

For example:

@GetMapping
public ProductPageResponse browse(
        @RequestParam(
                defaultValue = "0"
        )
        int page,

        @RequestParam(
                defaultValue = "20"
        )
        int size
) {
    ...
}

The API contract later enforces:

page >= 0

1 <= size <= 100

The defaults and limits are deliberate API decisions.


@RequestHeader

Spring can bind headers with:

@RequestHeader

For example:

@RequestHeader("X-Request-Id")
String requestId

may be useful for a specific transport concern.

But infrastructure-level concerns such as:

authentication

correlation

tracing

should not be manually reimplemented independently inside every Handler when framework-level infrastructure can apply them consistently.


Do Not Parse Authorization Headers Manually

Avoid:

@RequestHeader("Authorization")
String authorization

followed by:

String token =
        authorization.substring(...);

inside Product or Order Handlers.

Authentication belongs to:

Spring Security

The Handler should work with the authenticated application identity after the security boundary has processed the request.


@ResponseStatus

For a simple fixed response status, Spring supports:

@ResponseStatus(
        HttpStatus.CREATED
)

But:

ResponseEntity

is usually more flexible when we need:

Location header

explicit status

response body

For Product and Order creation, ResponseEntity.created(...) is a natural fit.


Response DTOs

A Product response may be:

public record ProductResponse(
        String id,
        String name,
        long priceCents,
        boolean active
) {

    public static ProductResponse from(
            Product product
    ) {
        return new ProductResponse(
                product.id()
                        .value()
                        .toString(),
                product.name(),
                product.priceCents(),
                product.isActive()
        );
    }
}

This is transport representation.

The Domain does not need Jackson annotations simply to define the public API.


Why IDs Become Strings in Responses

Domain:

ProductId(UUID)

HTTP:

"59ba787c-6d83-4a21-9bae-19c3d0469bd5"

The API contract represents UUID values as strings.

Therefore response mapping explicitly converts:

product.id()
        .value()
        .toString()

This keeps transport formatting outside the Domain.


OrderResponse

Conceptually:

public record OrderResponse(
        String id,
        String status,
        Instant createdAt,
        List<OrderItemResponse> items,
        long totalCents
) {
}

Notice that we do not expose:

CustomerId

to the Customer simply because Order stores it internally.

Ownership is authenticated context, not necessary Customer-facing response data.


OrderItemResponse

For example:

public record OrderItemResponse(
        String productId,
        int quantity,
        long unitPriceCents,
        long totalCents
) {
}

This presents historical Order information without loading current Product price.


Response Mapping Can Stay Simple

For simple mappings, either of these approaches is reasonable:

ProductResponse.from(
        product
);

or:

private ProductResponse toResponse(
        Product product
) {
    ...
}

inside the Handler.

We do not need a mapping framework simply because mapping exists.

Introduce extra mapping abstraction only when complexity or reuse justifies it.


Domain Must Not Depend on Response DTOs

Avoid:

public class Product {

    public ProductResponse toResponse() {
        ...
    }
}

Now:

Domain

depends on:

HTTP transport

The dependency direction should instead be:

ProductResponse
    ↓ reads
Product

not:

Product
    ↓ depends on
ProductResponse

Do Not Return JPA Entities

Later our persistence layer may contain ORM-specific objects.

Do not write:

return productEntity;

from the Handler.

That may expose:

persistence fields

lazy relationships

internal mapping structure

ORM serialization behaviour

The public API must remain intentional.


Do Not Automatically Return Domain Entities Either

Even a clean Domain object should not necessarily become the public JSON contract.

Direct serialization couples:

Domain structure

to:

external API structure

A later internal Domain refactor could accidentally become a breaking API change.

Explicit response DTOs create a stable boundary.


Controller Bean Scope

Spring Controllers are normally singleton Beans.

That means one:

OrderHandler

instance may serve many requests concurrently.

Therefore Handler fields should contain stable dependencies such as:

private final GetOrderUseCase getOrder;

not request-specific state.


Never Store the Current Customer in a Handler Field

Bad:

@RestController
public class OrderHandler {

    private CustomerId currentCustomer;
}

Two concurrent requests could overwrite the same mutable field.

Request-specific values must remain:

method parameters

local variables

For example:

CustomerId customerId =
        currentUser.customerId();

inside the request method.


Do Not Store Request DTOs in Bean Fields

Avoid:

private CreateOrderRequest currentRequest;

inside a singleton Handler.

Each request DTO belongs to one request execution.

It should flow through:

method parameter
→ local mapping
→ UseCase call

and then disappear.


Transaction Boundaries Do Not Belong on Handlers

Avoid making:

@PostMapping
@Transactional
public ...

the normal way to define a business transaction.

For example, Create Order coordinates:

Inventory consumption

Order persistence

That transaction belongs to:

CreateOrderUseCase

not to the fact that the operation happened to arrive through HTTP.


Why Transactions Belong to UseCases

Imagine another entry point later invokes:

CreateOrderUseCase

If transaction semantics exist only on:

OrderHandler

the other caller loses the consistency guarantee.

The transaction is part of the application operation.

Therefore:

UseCase
→ transaction boundary

is the stronger design.


Handlers Should Not Catch Every Exception

Avoid:

try {
    ...
} catch (Exception ex) {
    return ResponseEntity
            .status(
                    HttpStatus.INTERNAL_SERVER_ERROR
            )
            .body(...);
}

inside every Handler.

This leads to:

duplicated error translation

inconsistent response formats

business errors becoming 500

large repetitive Controllers

Error mapping should be centralized.


Expected Failures Need Consistent Translation

Suppose:

OrderNotFound

occurs.

One Handler must not return:

404

while another returns:

400

and another accidentally returns:

500

Our canonical mapping already defines:

ORDER_NOT_FOUND
→ 404

Later we will implement centralized exception handling to produce the consistent RFC 7807 response.


Unexpected Failures Should Propagate to Central Handling

Avoid:

catch (Exception ex) {
    return null;
}

or:

catch (Exception ex) {
    return ResponseEntity.ok(
            errorBody
    );
}

Unexpected failures should reach centralized handling and produce proper:

5xx

semantics.


Controllers Should Not Construct Infrastructure

Avoid:

new JdbcOrderRepository(...);

inside a request method.

Spring composition creates and injects infrastructure dependencies.

Likewise, do not manually construct:

CreateOrderUseCase useCase =
        new CreateOrderUseCase(...);

for every HTTP request.

Handlers should receive stable UseCase dependencies through constructor injection.


Capability-First Package Structure

Our canonical codebase structure uses capability-first organization.

For Order:

order/
├── api/
│   ├── OrderHandler.java
│   ├── CreateOrderRequest.java
│   ├── CreateOrderItemRequest.java
│   ├── OrderResponse.java
│   └── OrderItemResponse.java
├── application/
├── domain/
└── persistence/

For Product:

product/
├── api/
├── application/
├── domain/
└── persistence/

For Inventory:

inventory/
├── api/
├── application/
├── domain/
└── persistence/

Transport types stay near the capability's API boundary.


Why api/ Instead of a Global controller/

Avoid organizing the entire application as:

controller/
service/
repository/
dto/

with every business capability scattered across global technical layers.

Capability-first packaging keeps:

Product HTTP

Product application operations

Product Domain

Product persistence

closer together.

This improves discoverability as the codebase grows.


Handler Naming Inside api/

A Product HTTP class can be:

product/api/ProductHandler.java

with:

@RestController
public class ProductHandler {
}

The package already communicates:

API boundary

The class name communicates:

Handler

This fits our architectural vocabulary cleanly.


Service Remains Reserved for External Integrations

Do not introduce:

private final OrderService orderService;

for internal application workflow.

Our pattern remains:

OrderHandler
    ↓
PayOrderUseCase
    ↓
PaymentService

where:

PayOrderUseCase
→ internal application workflow

and:

PaymentService
→ external Payment capability

Routing Should Stay Clear

Suppose an Order Handler contains:

@GetMapping("/{orderId}")

and later someone introduces:

@GetMapping("/history")

Spring can often distinguish static and path-variable routes.

But avoid unclear route design that relies on clever pattern interactions.

Our current API remains simple:

GET /api/v1/orders

GET /api/v1/orders/{orderId}

so no /history route is needed for Customer history.


Path Variable Names Should Be Explicit

Prefer:

@GetMapping("/{orderId}")
public OrderResponse get(
        @PathVariable String orderId
) {
}

over:

@GetMapping("/{id}")
public OrderResponse get(
        @PathVariable String id
) {
}

when explicit naming improves readability.

Likewise:

productId

orderId

immediately communicates the identity being handled.


Do Not Use HttpServletRequest by Default

Avoid:

public ResponseEntity<?> handle(
        HttpServletRequest request
) {
    ...
}

and then manually parsing:

URI

query parameters

headers

JSON

Spring MVC already provides typed binding through:

@PathVariable

@RequestParam

@RequestBody

@RequestHeader

Use lower-level servlet APIs only when a real transport requirement needs them.


Avoid Map<String, Object> Request Bodies

Bad:

@PostMapping
public ...
create(
        @RequestBody
        Map<String, Object> body
) {
}

for a stable API contract.

Typed DTOs such as:

CreateProductRequest

UpdateProductRequest

SetInventoryRequest

CreateOrderRequest

provide:

compile-time structure

clear validation targets

better documentation

explicit client contract

Typed Responses Too

Likewise, avoid returning:

Map<String, Object>

for normal API responses.

Use explicit models such as:

ProductResponse

InventoryResponse

OrderResponse

The API becomes easier to understand, document, test, and evolve.


Handler Tests

Handler tests should focus on transport concerns such as:

correct URI mapping

HTTP method

request deserialization

path/query conversion

validation behavior

HTTP status

response JSON

authentication integration

Problem Details mapping

They should not become the only place where business rules are tested.


UseCase Tests

UseCase tests focus on:

Repository coordination

Customer ownership

cross-Domain workflow

transactional behaviour

expected application failures

No HTTP is required.


Domain Tests

Domain tests focus on rules such as:

Product price invariants

Inventory quantity invariants

OrderItem invariants

Order lifecycle

No Spring MVC is required.

Each layer tests the responsibilities it owns.


Do Not Re-Test the Entire Domain Through Every Handler

For example:

PAID Order cannot be cancelled

should be strongly tested in:

Order Domain tests

Then a Handler/API test can verify that:

OrderNotCancellable

becomes:

409
ORDER_NOT_CANCELLABLE

This produces more focused and faster tests.


A Conceptual ProductHandler

Putting the concepts together:

package io.tarkev.ordermanagement.product.api;

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

    private final BrowseProductsUseCase browseProducts;
    private final GetProductUseCase getProduct;
    private final CreateProductUseCase createProduct;
    private final UpdateProductUseCase updateProduct;
    private final DeactivateProductUseCase deactivateProduct;

    public ProductHandler(
            BrowseProductsUseCase browseProducts,
            GetProductUseCase getProduct,
            CreateProductUseCase createProduct,
            UpdateProductUseCase updateProduct,
            DeactivateProductUseCase deactivateProduct
    ) {
        this.browseProducts =
                browseProducts;

        this.getProduct =
                getProduct;

        this.createProduct =
                createProduct;

        this.updateProduct =
                updateProduct;

        this.deactivateProduct =
                deactivateProduct;
    }

    @GetMapping
    public ProductPageResponse browse(
            @RequestParam(
                    defaultValue = "0"
            )
            int page,

            @RequestParam(
                    defaultValue = "20"
            )
            int size
    ) {
        return ProductPageResponse.from(
                browseProducts.execute(
                        page,
                        size
                )
        );
    }

    @GetMapping("/{productId}")
    public ProductResponse get(
            @PathVariable
            String productId
    ) {
        Product product =
                getProduct.execute(
                        toProductId(
                                productId
                        )
                );

        return ProductResponse.from(
                product
        );
    }

    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @RequestBody
            CreateProductRequest request
    ) {
        Product product =
                createProduct.execute(
                        request.name(),
                        request.priceCents()
                );

        ProductResponse response =
                ProductResponse.from(
                        product
                );

        URI location =
                URI.create(
                        "/api/v1/products/" +
                                product.id()
                                        .value()
                );

        return ResponseEntity
                .created(
                        location
                )
                .body(
                        response
                );
    }

    @PatchMapping("/{productId}")
    public ProductResponse update(
            @PathVariable
            String productId,

            @RequestBody
            UpdateProductRequest request
    ) {
        Product product =
                updateProduct.execute(
                        toProductId(
                                productId
                        ),
                        request.name(),
                        request.priceCents()
                );

        return ProductResponse.from(
                product
        );
    }

    @PostMapping(
            "/{productId}/deactivate"
    )
    public ProductResponse deactivate(
            @PathVariable
            String productId
    ) {
        Product product =
                deactivateProduct.execute(
                        toProductId(
                                productId
                        )
                );

        return ProductResponse.from(
                product
        );
    }

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

This example is intentionally architectural.

Detailed:

validation

pagination response contracts

error mapping

Spring Security

persistence

are covered in dedicated lessons.


Are Five UseCase Dependencies Too Many?

Not automatically.

ProductHandler represents one cohesive HTTP capability.

Its constructor makes the operations it exposes visible:

browse

get

create

update

deactivate

If the class later becomes difficult to understand because of:

size

cohesion

change frequency

very different endpoint concerns

we can split it.

Do not split purely because:

five constructor parameters looks like a lot

Do Not Solve Constructor Size With ProductService

Avoid replacing:

BrowseProductsUseCase
GetProductUseCase
CreateProductUseCase
UpdateProductUseCase
DeactivateProductUseCase

with:

ProductService

containing:

browse()

get()

create()

update()

deactivate()

That merely hides several application responsibilities behind one generic object.

Operation-specific UseCases remain clearer.


Handler Code Should Be Boring

A healthy Handler often follows a repetitive pattern:

receive

convert

delegate

map

respond

That is good.

HTTP boundary code should be predictable.

The interesting business complexity belongs deeper in:

UseCases

Domain

Repositories

external Services

not in increasingly clever Controller methods.


Common Mistake — Business Logic in Controller

Avoid performing:

load Products

check Inventory

calculate Order total

change Order status

save Order

inside:

@PostMapping

Move workflow to the UseCase.


Common Mistake — Repository in a Mutating Handler

A Handler directly calling a Repository for a mutation often bypasses:

Domain rules

cross-capability coordination

transaction ownership

authorization semantics

Use a UseCase.


Common Mistake — Request Body Is the Domain Entity

Avoid:

public ProductResponse create(
        @RequestBody
        Product product
) {
}

Now the transport contract becomes the Domain structure and the client may gain access to server-owned fields.

Use explicit request DTOs.


Common Mistake — Field Injection

Constructor injection keeps dependencies visible and testable.


Common Mistake — Request State in Handler Fields

Controllers are shared Beans.

Keep request-specific state method-local.


Common Mistake — Manual Authorization Parsing

Do not parse Bearer tokens inside every Handler.

Use Spring Security.


Common Mistake — Generic Maps

Typed request and response DTOs make the API contract intentional.


Common Mistake — Every Handler Catches Exceptions

Use centralized error translation for consistent Problem Details responses.


Common Mistake — @Transactional on Every HTTP Mutation

Transactions belong to the application operation, not the transport.


Common Mistake — Internal OrderService

Our internal path is:

Handler
→ UseCase
→ Repository

Service remains reserved for external providers such as:

PaymentService

Controller Review Checklist

When reviewing a Handler, ask:

Does the mapping match
the approved API contract?

Does it primarily handle HTTP concerns?

Are UUID strings converted intentionally?

Does authenticated CustomerId
come from the security boundary?

Are server-owned values absent
from request DTOs?

Does the Handler invoke a UseCase?

Is Repository access leaking upward?

Is Domain logic being reimplemented here?

Are response DTOs explicit?

Is request state method-local?

Does transaction ownership remain
with the UseCase?

Does external provider protocol
stay outside the Handler?

Can error mapping be centralized?

Handler Responsibility

For our application:

Handler

owns:

HTTP route

HTTP method

path/query/body binding

authenticated request context

transport → application mapping

UseCase invocation

application result → response mapping

HTTP status

HTTP headers

It does not own:

business workflow

Domain invariants

database implementation

transaction rules

external Payment-provider protocol

Architecture After Adding HTTP

Our application now looks like:

HTTP Client
    ↓
Spring MVC
    ↓
Handler
    ↓
UseCase
    ↓
Domain
    ↓
Repository

External Payment remains another edge:

UseCase
    ↓
PaymentService
    ↓
External Provider

This keeps:

HTTP protocol

on one side and:

external provider protocol

on another.

The application workflow remains between those boundaries.


Engineering Principle

The core principle is:

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

Another:

Controllers should understand HTTP deeply enough to expose a good API contract without becoming the owner of the business workflow.

And:

Spring annotations connect HTTP to our architecture; they do not replace the architecture.