Skip to content
Latentism

Building a Production-Ready REST API with Spring Boot

A practical Spring Boot REST API walkthrough: request validation, consistent error responses, a PostgreSQL schema, and health checks for deployment.

3 min read

Request flow in a Spring Boot application: client, controller, service, repository and PostgreSQL

Getting a Spring Boot endpoint to return JSON takes five minutes. Getting it ready for real traffic takes a few more decisions. This post walks through the ones that matter most, using a small orders API as the example.

Project structure

Keep each layer responsible for one thing. Controllers deal with HTTP, services hold the business rules, and repositories talk to the database.

The four layers of the application and what each one is responsible for: controller, service, repository and database

Validating requests

Use a record for the request body and put the rules on its components. Invalid input is rejected before your service code runs.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public record CreateOrderRequest(
        @NotBlank @Size(max = 64) String customerId,
        @NotBlank @Size(max = 64) String sku,
        @Positive int quantity
) {}

The controller stays thin. @Valid triggers the validation and the method only translates between HTTP and the service.

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService orders;

    OrderController(OrderService orders) {
        this.orders = orders;
    }

    @PostMapping
    ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        OrderResponse created = orders.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }

    @GetMapping("/{id}")
    OrderResponse get(@PathVariable UUID id) {
        return orders.get(id);
    }
}

Consistent error responses

Clients should get the same error shape from every endpoint. Spring supports the RFC 9457 problem details format out of the box through ProblemDetail.

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException ex) {
        ProblemDetail problem =
                ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Order not found");
        return problem;
    }
}

A request for an order that does not exist now returns:

{
  "type": "about:blank",
  "title": "Order not found",
  "status": 404,
  "detail": "No order with id 7f9c2b1e-4c1a-4a53-9a55-0d7f3a2e9b10",
  "instance": "/api/orders/7f9c2b1e-4c1a-4a53-9a55-0d7f3a2e9b10"
}

The database schema

Let a migration tool such as Flyway own the schema, and keep Hibernate in validate mode so the application never changes tables on its own.

CREATE TABLE orders (
    id          UUID PRIMARY KEY,
    customer_id VARCHAR(64) NOT NULL,
    sku         VARCHAR(64) NOT NULL,
    quantity    INTEGER     NOT NULL CHECK (quantity > 0),
    status      VARCHAR(16) NOT NULL DEFAULT 'PLACED',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_orders_customer_created
    ON orders (customer_id, created_at DESC);

The index matches the most common query, “latest orders for a customer”, so it can be answered without sorting.

Configuration

spring:
  application:
    name: orders-api
  datasource:
    url: jdbc:postgresql://localhost:5432/orders
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 10
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  mvc:
    problemdetails:
      enabled: true

server:
  shutdown: graceful

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      probes:
        enabled: true

Three settings deserve a comment:

  1. open-in-view: false stops lazy loading from running queries while the response is being serialised.
  2. shutdown: graceful lets in-flight requests finish during a deployment.
  3. Credentials come from environment variables, never from the file.

Health checks

With probes enabled, the actuator exposes separate liveness and readiness endpoints that a load balancer or Kubernetes can call.

curl -s http://localhost:8080/actuator/health/readiness
{ "status": "UP" }

Create an order to check the whole path, from validation to the database:

curl -s -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId": "c-1042", "sku": "LAPTOP-15", "quantity": 1}'

Checklist

  • Request bodies are validated with @Valid.
  • Every error uses the problem details format.
  • The schema is owned by migrations and Hibernate only validates it.
  • Secrets come from the environment.
  • Liveness and readiness probes are exposed, and shutdown is graceful.