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.
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:
docker compose down -v && docker compose up -d postgresDomain model
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); }}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:
customerIdis a plain column, not a@ManyToOneassociation. 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.itemsisLAZYand 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.
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; }}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.
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:
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
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);}findByIdWithItemsfetches the order and its items in one SQL statement.findByIdalone would leaveitemsa lazy collection; withopen-in-view: false(chapter 03), touching it during mapping would throwLazyInitializationException— or, in the more dangerous configuration, silently issue one extra query per order. The@EntityGraphmakes the fetch plan explicit and therefore measurable.searchis a single query with optional filters and server-side pagination (Pageable). It returns a projection — four scalar columns plus the constructor — so Hibernate never instantiatesOrderentities for list traffic. This is the fast path by design; chapter 10 shows what the slow version costs.
Service
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;
@Servicepublic 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); }}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
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()); }}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;
@RestControllerAdvicepublic 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):
spring: data: web: pageable: max-page-size: 100Why 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.
Pageableplusmax-page-sizebounds 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
searchand the@EntityGraphinfindByIdWithItemsare 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
EXPLAINunder 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
./gradlew bootRun# 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/1curl -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.