Series overview
Part 4 of 1331% complete
2026-07-25•5 min read

The Order Management API

By the end of this chapter the Order Management API runs against the Dockerised PostgreSQL from chapter 02, serves all four endpoints, and — just as important — is a fair baseline: paginated search, real indexes, no intentionally planted slowness. Performance exercises fail when the baseline is a straw man; this one is built the way you would build it for real, which is what makes the bottlenecks later in the series instructive.

The data-access decision: Spring Data JPA

The series uses Spring Data JPA with Hibernate, and the choice deserves an honest paragraph rather than a default.

JPA trades explicitness for leverage. You get dirty checking, transactional consistency, and derived queries — and you pay for them in places a benchmark will find: the persistence context holds managed entities per request (memory and identity-map overhead), lazy associations can turn one query into N+1, and a derived findAll() can silently return a million rows. JdbcClient or jOOQ give you exactly the SQL you write, with none of that machinery — the performance profile is more predictable because there is nothing hidden to mis-predict.

Why JPA anyway: it is what most Spring Boot services actually run, and its failure modes under load are precisely what this series teaches you to see. Every JPA-specific pitfall here has a metric signature — hikaricp_connections_pending, spring_data_repository_invocations_seconds counts, EXPLAIN output — and the method for finding it transfers to any stack. Where JdbcClient would change the answer, the text says so.

The schema

db/init/01-schema.sql runs once when the PostgreSQL volume is empty (chapter 02 wired the mount). It owns the schema; Hibernate only validates against it.

db/init/01-schema.sql
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(200) NOT NULL,
email VARCHAR(320) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers (id),
status VARCHAR(20) NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
sku VARCHAR(40) NOT NULL,
quantity INT NOT NULL CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL
);
-- The search endpoint's access paths. Chosen now, measured later.
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);
CREATE INDEX idx_order_items_order ON order_items (order_id);

Index rationale, briefly: idx_orders_customer_status covers the dominant query — “orders for this customer, optionally filtered by status” — where customer_id equality leads and status filters within it. idx_orders_status_created covers “all orders in a status, newest first”, the pagination pattern. idx_order_items_order lets the items join avoid scanning the whole items table. Whether they are sufficient is a chapter 09–10 question, answered with EXPLAIN, not assumed here.

Apply it:

Terminal window
docker compose down -v && docker compose up -d postgres

Domain model

src/main/java/in/o612/eng/orders/order/OrderStatus.java
package in.o612.eng.orders.order;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
public enum OrderStatus {
PLACED, PAID, SHIPPED, DELIVERED, CANCELLED;
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
PLACED, EnumSet.of(PAID, CANCELLED),
PAID, EnumSet.of(SHIPPED, CANCELLED),
SHIPPED, EnumSet.of(DELIVERED),
DELIVERED, EnumSet.noneOf(OrderStatus.class),
CANCELLED, EnumSet.noneOf(OrderStatus.class));
public boolean canTransitionTo(OrderStatus target) {
return ALLOWED.get(this).contains(target);
}
}
src/main/java/in/o612/eng/orders/order/Order.java
package in.o612.eng.orders.order;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "customer_id", nullable = false)
private Long customerId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.PLACED;
@Column(name = "total_amount", nullable = false)
private BigDecimal totalAmount = BigDecimal.ZERO;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
orphanRemoval = true, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
protected Order() { }
public Order(Long customerId) {
this.customerId = customerId;
}
public void addItem(OrderItem item) {
item.setOrder(this);
items.add(item);
totalAmount = totalAmount.add(item.getUnitPrice()
.multiply(BigDecimal.valueOf(item.getQuantity())));
}
public void transitionTo(OrderStatus target) {
if (!status.canTransitionTo(target)) {
throw new IllegalOrderStateException(
"Cannot move order " + id + " from " + status + " to " + target);
}
status = target;
}
@PrePersist
void onCreate() {
createdAt = updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
public Long getId() { return id; }
public Long getCustomerId() { return customerId; }
public OrderStatus getStatus() { return status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public List<OrderItem> getItems() { return items; }
}

Two decisions to notice:

  • customerId is a plain column, not a @ManyToOne association. No endpoint ever returns customer details, so mapping the association would only add a proxy, a join temptation, and an accidental lazy-load path — cost with no payoff. The foreign key still enforces integrity in the schema.
  • items is LAZY and stays lazy in the mapping. Loading it is controlled per-query (below), which is the whole point: the caller decides whether to pay for items.
src/main/java/in/o612/eng/orders/order/OrderItem.java
package in.o612.eng.orders.order;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(nullable = false, length = 40)
private String sku;
@Column(nullable = false)
private int quantity;
@Column(name = "unit_price", nullable = false)
private BigDecimal unitPrice;
protected OrderItem() { }
public OrderItem(String sku, int quantity, BigDecimal unitPrice) {
this.sku = sku;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
void setOrder(Order order) { this.order = order; }
public Long getId() { return id; }
public String getSku() { return sku; }
public int getQuantity() { return quantity; }
public BigDecimal getUnitPrice() { return unitPrice; }
}
src/main/java/in/o612/eng/orders/order/IllegalOrderStateException.java
package in.o612.eng.orders.order;
public class IllegalOrderStateException extends RuntimeException {
public IllegalOrderStateException(String message) {
super(message);
}
}

DTOs and the response boundary

Entities never cross the controller boundary. Besides decoupling, this is a performance choice: serialising a managed entity walks every mapped association, and under Hibernate that can mean lazy-load queries fired during Jackson serialisation — work you cannot see in repository metrics.

src/main/java/in/o612/eng/orders/web/OrderDtos.java
package in.o612.eng.orders.web;
import in.o612.eng.orders.order.Order;
import in.o612.eng.orders.order.OrderItem;
import in.o612.eng.orders.order.OrderStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.data.domain.Page;
public final class OrderDtos {
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty List<@Valid ItemRequest> items) { }
public record ItemRequest(
@NotBlank String sku,
@Min(1) int quantity,
@NotNull @DecimalMin("0.01") BigDecimal unitPrice) { }
public record UpdateStatusRequest(@NotNull OrderStatus status) { }
public record OrderItemResponse(String sku, int quantity, BigDecimal unitPrice) {
static OrderItemResponse from(OrderItem item) {
return new OrderItemResponse(item.getSku(), item.getQuantity(), item.getUnitPrice());
}
}
public record OrderResponse(Long id, Long customerId, OrderStatus status,
BigDecimal totalAmount, List<OrderItemResponse> items,
Instant createdAt, Instant updatedAt) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(), order.getCustomerId(), order.getStatus(),
order.getTotalAmount(),
order.getItems().stream().map(OrderItemResponse::from).toList(),
order.getCreatedAt(), order.getUpdatedAt());
}
}
public record PageResponse<T>(List<T> content, int page, int size,
long totalElements, int totalPages) {
public static <T> PageResponse<T> from(Page<T> p) {
return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(),
p.getTotalElements(), p.getTotalPages());
}
}
private OrderDtos() { }
}

OrderSummary — the projection for search results — lives in its own file, deliberately. JPQL constructor expressions need the class name Hibernate can resolve; a nested record would require the awkward binary name OrderDtos$OrderSummary in the query string. Top-level keeps the JPQL readable:

src/main/java/in/o612/eng/orders/web/OrderSummary.java
package in.o612.eng.orders.web;
import in.o612.eng.orders.order.OrderStatus;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderSummary(Long id, Long customerId, OrderStatus status,
BigDecimal totalAmount, Instant createdAt) { }

It exists because the search endpoint must not return items — both for payload size and because the query should never touch order_items. PageResponse is a hand-rolled page envelope rather than serialising Spring Data’s Page directly — it keeps the response contract stable regardless of framework-internal serialization choices.

Repository

src/main/java/in/o612/eng/orders/order/OrderRepository.java
package in.o612.eng.orders.order;
import in.o612.eng.orders.web.OrderSummary;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "items")
@Query("select o from Order o where o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
@Query("""
select new in.o612.eng.orders.web.OrderSummary(
o.id, o.customerId, o.status, o.totalAmount, o.createdAt)
from Order o
where (:customerId is null or o.customerId = :customerId)
and (:status is null or o.status = :status)
""")
Page<OrderSummary> search(@Param("customerId") Long customerId,
@Param("status") OrderStatus status,
Pageable pageable);
}
  • findByIdWithItems fetches the order and its items in one SQL statement. findById alone would leave items a lazy collection; with open-in-view: false (chapter 03), touching it during mapping would throw LazyInitializationException — or, in the more dangerous configuration, silently issue one extra query per order. The @EntityGraph makes the fetch plan explicit and therefore measurable.
  • search is a single query with optional filters and server-side pagination (Pageable). It returns a projection — four scalar columns plus the constructor — so Hibernate never instantiates Order entities for list traffic. This is the fast path by design; chapter 10 shows what the slow version costs.

Service

src/main/java/in/o612/eng/orders/order/OrderService.java
package in.o612.eng.orders.order;
import in.o612.eng.orders.web.OrderDtos.*;
import io.micrometer.core.instrument.Timer;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orders;
private final OrderMetrics metrics;
public OrderService(OrderRepository orders, OrderMetrics metrics) {
this.orders = orders;
this.metrics = metrics;
}
@Transactional
public OrderResponse create(CreateOrderRequest request) {
Timer.Sample sample = metrics.startCreationTimer();
try {
Order order = new Order(request.customerId());
request.items().forEach(i ->
order.addItem(new OrderItem(i.sku(), i.quantity(), i.unitPrice())));
return OrderResponse.from(orders.save(order));
} finally {
metrics.recordCreation(sample);
}
}
@Transactional(readOnly = true)
public OrderResponse getById(long id) {
Order order = orders.findByIdWithItems(id)
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderResponse.from(order);
}
@Transactional(readOnly = true)
public Page<OrderSummary> search(Long customerId, OrderStatus status, Pageable pageable) {
return orders.search(customerId, status, pageable);
}
@Transactional
public OrderResponse updateStatus(long id, OrderStatus target) {
Order order = orders.findByIdWithItems(id)
.orElseThrow(() -> new OrderNotFoundException(id));
order.transitionTo(target); // dirty checking writes the UPDATE at commit
metrics.countStatusTransition();
return OrderResponse.from(order);
}
}
src/main/java/in/o612/eng/orders/order/OrderNotFoundException.java
package in.o612.eng.orders.order;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(long id) {
super("Order " + id + " not found");
}
}

Note readOnly = true on the two queries: it sets Hibernate’s flush mode to manual, skipping dirty checking on every entity in the persistence context — a real, measurable saving on read-heavy endpoints, and the kind of detail that is free to do right in a baseline.

Controller and error mapping

src/main/java/in/o612/eng/orders/web/OrderController.java
package in.o612.eng.orders.web;
import in.o612.eng.orders.order.*;
import in.o612.eng.orders.web.OrderDtos.*;
import jakarta.validation.Valid;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
OrderResponse created = service.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public OrderResponse getById(@PathVariable long id) {
return service.getById(id);
}
@GetMapping
public PageResponse<OrderSummary> search(@RequestParam(required = false) Long customerId,
@RequestParam(required = false) OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return PageResponse.from(service.search(customerId, status, pageable));
}
@PatchMapping("/{id}/status")
public OrderResponse updateStatus(@PathVariable long id,
@Valid @RequestBody UpdateStatusRequest request) {
return service.updateStatus(id, request.status());
}
}
src/main/java/in/o612/eng/orders/web/ApiExceptionHandler.java
package in.o612.eng.orders.web;
import in.o612.eng.orders.order.IllegalOrderStateException;
import in.o612.eng.orders.order.OrderNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order not found");
return problem;
}
@ExceptionHandler(IllegalOrderStateException.class)
ProblemDetail invalidTransition(IllegalOrderStateException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Illegal status transition");
return problem;
}
}

Bean Validation failures (@Valid) return 400 automatically via Spring MVC’s default error handling — no extra code needed.

One last config addition. Unbounded size parameters are an unbounded-result-set bug waiting for a query string, so cap the page size in application.yml (add under spring.data):

src/main/resources/application.yml
spring:
data:
web:
pageable:
max-page-size: 100

Why the benchmark traps are designed out

Four patterns distort load-test results so reliably that they are worth naming directly — a baseline containing them would measure the bug, not the service:

  • Unbounded result sets. A search endpoint without pagination returns whatever the filter matches. As the dataset grows, latency grows with it — the benchmark is really measuring table size. Pageable plus max-page-size bounds the work per request.
  • N+1 queries. Returning entities whose lazy associations get touched once per row turns one query into 1+N. At 20 rows per page it hides; at dataset scale it saturates the connection pool — which then masquerades as a pool-size problem. The projection in search and the @EntityGraph in findByIdWithItems are the baseline’s defences; chapter 10 breaks one deliberately to show the signature.
  • Missing indexes. Every table looks indexed on a thousand rows because sequential scans are cheap there. The two composite indexes exist so that the baseline is honest before data arrives; whether they suffice is verified with EXPLAIN under real volume, not assumed.
  • Entity-as-DTO mapping. Serialising entities couples payload size to mapping depth and hides lazy-loading inside the response writer. The DTO boundary makes serialisation cost proportional to the response you actually designed.

Milestone: exercise all four endpoints

Terminal window
./gradlew bootRun
Terminal window
# create an order (customer_id 1 exists only after chapter 05's seed —
# insert one manually for now)
docker compose exec postgres psql -U orders -d orders -c \
"INSERT INTO customers (name, email) VALUES ('Ada Lovelace', 'ada@example.com');"
curl -s -X POST http://localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":1,"items":[{"sku":"SKU-1","quantity":2,"unitPrice":19.99}]}'
curl -s http://localhost:8080/api/orders/1
curl -s "http://localhost:8080/api/orders?customerId=1&status=PLACED"
curl -s -X PATCH http://localhost:8080/api/orders/1/status \
-H 'Content-Type: application/json' \
-d '{"status":"PAID"}'

Expected: 201 with the created order body, a 200 fetch including the items array, a paginated JSON envelope (content, page, totalElements, …), and a 200 showing "status":"PAID". A second PATCH to CANCELLED from PAID works; DELIVERED → anything returns the 409 problem detail.

Check that instrumentation sees it: curl -s http://localhost:8080/actuator/prometheus | grep http_server_requests should now show uri="/api/orders" series, and orders_creation_seconds should appear after the first POST.

State of the lab: schema live, four endpoints working, business timers flowing. What is missing is the thing that makes all of this meaningful — realistic data volume. That is chapter 05.

Spring BootJavaPerformancePostgres

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind