The previous article built a sales service: products, customers, orders, and the rules that make an order worth something. It stopped when the endpoints worked. This one is the pass that happens after that — the one that decides whether the code survives contact with a second developer.
Three things make up that pass, and none of them are decoration. A test suite built at three levels, so a broken rule fails in the cheapest place that can see it. Reporting written twice, once over entities in the JVM and once as a database aggregate, and compared honestly. And a walk back through the finished code naming where each idea from this course actually ended up — including the ones that did not.
![]()
Everything below was compiled and run on OpenJDK 21.0.6 (arm64) with Spring Boot 4.1.1, Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, Jackson 3.1.5, H2 2.4.240, JUnit Jupiter 6.0.3 and Mockito 5.23.0. Wall-clock numbers have been stripped from every transcript on purpose, and the reason is given where it matters.
The system in one block
You do not need the previous article open to read this one. The whole domain is four entities and five rules:
// Product id, sku (unique), name, priceCents, stock
// Customer id, name, email (unique)
// Order id, customer, createdAt, status, items (cascade)
// OrderItem id, order, product, quantity, unitPriceCents (copied at order time)
// POST /api/orders from a customer id plus a {sku, quantity} list
// GET /api/orders/{id}
// POST /api/orders/{id}/pay
// POST /api/orders/{id}/cancel
// plus CRUD for products and customers
// 1. an unknown sku is 404
// 2. insufficient stock is 409
// 3. paying decrements every line's stock in one transaction
// 4. paying is rejected unless the order is NEW (409)
// 5. cancelling a PAID order puts the stock backThat is the entire specification. The build article shipped the straightforward version of it: entities with getters and setters, the stock arithmetic written out inside OrderService, and two exception classes — NotFoundException and ConflictException — covering every failure. Three of those decisions change in this pass, and each change is argued for below rather than asserted: the invariants move into the entities, the status transitions move into the enum, and the two exception classes become a small hierarchy under one abstract root so the mapping to HTTP can be written once.
The rest of this article is what happens to that specification once you take quality seriously.
Where the course actually shows up in the code
A retrospective that lists vocabulary — "we used encapsulation, polymorphism and generics" — is worthless, because every project can claim that. The useful version names the file and the method.

Encapsulation, invariants and an enum that carries behaviour
Product has no setters for the field that matters. Stock changes through two methods, and the invariant that stock never goes negative lives inside the class rather than in whichever service happens to call it:
public boolean canFulfil(int quantity) {
return quantity > 0 && stock >= quantity;
}
public void decreaseStock(int quantity) {
if (!canFulfil(quantity)) {
throw new IllegalStateException("cannot take " + quantity + " from stock " + stock);
}
this.stock -= quantity;
}That is encapsulation doing real work: OrderService.pay can be wrong about the order of its checks and the stock still cannot go negative. The unit test that proves it needs no framework at all.
Order.getItems() returns Collections.unmodifiableList(items). Callers read the line items; only addItem may add one, and only addItem knows that the unit price has to be copied from the product at that moment. That copy is why a later reprice cannot silently rewrite a placed order.
OrderStatus is where the state machine lives, and it is an enum with constant bodies rather than a bare list of names:
public enum OrderStatus {
NEW {
@Override public boolean canPay() { return true; }
@Override public boolean canCancel() { return true; }
},
PAID {
@Override public boolean canPay() { return false; }
@Override public boolean canCancel() { return true; }
},
CANCELLED {
@Override public boolean canPay() { return false; }
@Override public boolean canCancel() { return false; }
};
public abstract boolean canPay();
public abstract boolean canCancel();
public boolean releasesStockOnCancel() {
return this == PAID;
}
}The payoff is that OrderService contains no switch over status. It asks order.getStatus().canPay() and throws if the answer is no. Adding a REFUNDED state means adding a constant with two methods, and the compiler points at every abstract method you forgot — which is exactly the thing a switch cannot do for you.
The business failures are a small hierarchy under one abstract root:
public abstract class SalesException extends RuntimeException { }
public class NotFoundException extends SalesException { } // 404
public class InsufficientStockException extends SalesException { } // 409
public class IllegalOrderStateException extends SalesException { } // 409
public class DuplicateKeyException extends SalesException { } // 409Each subclass carries the data the caller needs — InsufficientStockException holds the sku, the requested quantity and the available quantity, not just a formatted string — because a message is for a human and the fields are for the JSON body.
Generics, Optional and the collections chosen on purpose
ProductRepository extends JpaRepository<Product, Long> is generics earning their keep in one line: findById returns Optional<Product> and save returns Product, with no cast anywhere. The derived query findBySku returns Optional<Product> too, and the service converts absence into a domain failure at the boundary rather than passing a null inward:
Product product = products.findBySku(entry.getKey())
.orElseThrow(() -> new NotFoundException("product", entry.getKey()));Two collection choices in this codebase are deliberate rather than reflexive. OrderService.mergeLines builds a LinkedHashMap<String, Integer> so that two lines for the same sku collapse into one and the response keeps the order the client sent:
public static Map<String, Integer> mergeLines(List<CreateOrderRequest.Line> lines) {
Map<String, Integer> merged = new LinkedHashMap<>();
for (CreateOrderRequest.Line line : lines) {
if (line.quantity() <= 0) {
throw new IllegalArgumentException("quantity must be > 0 for sku " + line.sku());
}
merged.merge(line.sku(), line.quantity(), Integer::sum);
}
return merged;
}A HashMap would work and would scramble the line order; a TreeMap would sort by sku, which nobody asked for. And ReportService counts orders into an EnumMap because the key space is fixed, small and already ordered:
return orders.findAll().stream()
.collect(Collectors.groupingBy(
Order::getStatus,
() -> new EnumMap<>(OrderStatus.class),
Collectors.counting()));The comparators in the reports are static fields, not lambdas rebuilt per call, and they are chained so that ties break predictably rather than by whatever order the map happened to iterate in:
private static final Comparator<CustomerRevenue> BY_REVENUE_DESC =
Comparator.comparingLong(CustomerRevenue::revenueCents).reversed()
.thenComparing(CustomerRevenue::customerName);Without the thenComparing, two customers with identical revenue would swap places between runs and a test asserting on the list would be flaky.
What the course taught that this project does not use
This is the more honest half of a retrospective. A small CRUD service does not need everything a course covers, and pretending otherwise produces the kind of code review where nobody can find the business logic.
| Covered by the course | Used here | Why |
|---|---|---|
Threads, ExecutorService, CompletableFuture | No | Every request is a short transaction. The servlet container already gives you concurrency; adding a second thread pool inside a request adds failure modes and no throughput. |
| File I/O and NIO | No | Nothing is read from or written to disk. The database is the only store. |
Serialization, custom ObjectOutputStream work | No | JSON in, JSON out, handled by Jackson. |
| Singleton, Factory, Builder, Observer | Barely | Spring beans are singletons managed by the container, so writing the pattern by hand would duplicate it. There are four entities and no branching construction, so a Builder would be ceremony. |
| Inheritance between entities | No | Product, Customer and Order share nothing. The only inheritance in the project is the exception hierarchy, where it buys a single @ExceptionHandler. |
Comparable on entities | No | The reports need several orderings, so Comparator is right and a natural ordering would be arbitrary. |
The pattern is consistent: the ideas that survived into this codebase are the ones that removed a repetition or protected an invariant. The ones that did not survive are the ones that would have added a layer for its own sake.
One advice, one place where exceptions become status codes
The exception hierarchy exists so the mapping to HTTP can be written once, in a @RestControllerAdvice, instead of being scattered as ResponseEntity.status(...) calls through four controllers:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiError> notFound(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError("NOT_FOUND", e.getMessage(),
Map.of("resource", e.getResource(), "key", e.getKey())));
}
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<ApiError> stock(InsufficientStockException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("INSUFFICIENT_STOCK", e.getMessage(),
Map.of("sku", e.getSku(), "requested", e.getRequested(),
"available", e.getAvailable())));
}
/** Anything new under SalesException still gets a JSON body, never a stack trace. */
@ExceptionHandler(SalesException.class)
public ResponseEntity<ApiError> fallback(SalesException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiError.of("BAD_REQUEST", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> invalid(MethodArgumentNotValidException e) {
Map<String, Object> fields = new LinkedHashMap<>();
e.getBindingResult().getFieldErrors()
.forEach(f -> fields.put(f.getField(), f.getDefaultMessage()));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ApiError("VALIDATION_FAILED", "request body is not valid", fields));
}
}The handler on the abstract SalesException is the part worth copying. Spring picks the most specific @ExceptionHandler for the thrown type, so a subclass added next month lands on the fallback and produces a JSON body with a code, rather than a 500 with a stack trace in the response. Four real responses from the running application:
{"code":"NOT_FOUND","message":"product not found: SKU-NOPE",
"details":{"resource":"product","key":"SKU-NOPE"}}
{"code":"INSUFFICIENT_STOCK","message":"insufficient stock for SKU-MONITOR: requested 99, available 17",
"details":{"sku":"SKU-MONITOR","requested":99,"available":17}}
{"code":"ILLEGAL_STATE","message":"cannot pay an order in status PAID",
"details":{"status":"PAID","attempted":"pay"}}
{"code":"VALIDATION_FAILED","message":"request body is not valid",
"details":{"lines":"must not be empty"}}Those came back with 404, 409, 409 and 400 respectively. The code field is what a client switches on; the message is for the log; details is what a form needs to highlight the right input.
Reporting twice: group in the JVM or group in the database
The service needs three read-only reports: revenue by customer, top products by units sold, and orders grouped by status. Each one can be written with the Stream API over loaded entities, or as a JPQL aggregate the database answers. Both are correct. They are not the same decision.

Revenue by customer, twice
The stream version reads the way you would explain the report out loud:
@Transactional(readOnly = true)
public List<CustomerRevenue> revenueByCustomerInMemory() {
return groupRevenue(orders.findAll());
}
private List<CustomerRevenue> groupRevenue(List<Order> all) {
Map<Long, List<Order>> byCustomer = all.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.collect(Collectors.groupingBy(o -> o.getCustomer().getId()));
return byCustomer.entrySet().stream()
.map(e -> new CustomerRevenue(
e.getKey(),
e.getValue().get(0).getCustomer().getName(),
e.getValue().stream().mapToLong(Order::totalCents).sum(),
e.getValue().size()))
.sorted(BY_REVENUE_DESC)
.toList();
}Note the grouping key: o.getCustomer().getId(), not the Customer object. Entities here inherit identity equals, so grouping by the object works only as long as every order in the result came from the same persistence context. Grouping by the id is correct regardless, and it is not slower.
The query version pushes the same arithmetic into the database and returns a record with four scalars in it:
public record CustomerRevenue(Long customerId, String customerName,
long revenueCents, long orderCount) { }
@Query("""
select new com.example.sales.report.CustomerRevenue(
c.id, c.name, sum(i.quantity * i.unitPriceCents), count(distinct o.id))
from Order o
join o.customer c
join o.items i
where o.status = com.example.sales.domain.OrderStatus.PAID
group by c.id, c.name
order by sum(i.quantity * i.unitPriceCents) desc, c.name asc
""")
List<CustomerRevenue> revenueByCustomer();The count(distinct o.id) is not optional. Joining o.items multiplies each order row by its line count, so a plain count(o) would report three orders where there is one order with three lines. The sum is unaffected, because each line is still counted exactly once.
Both versions return the same list. An integration test asserts it directly rather than trusting the reading:
assertEquals(lazy, viaSql, "both versions must produce identical rows");Against the running application, the two endpoints agree byte for byte:
GET /api/reports/revenue
[{"customerId":2,"customerName":"Binh Le","revenueCents":62300,"orderCount":1},
{"customerId":1,"customerName":"Ann Tran","revenueCents":50200,"orderCount":2}]
GET /api/reports/revenue?mode=stream
[{"customerId":2,"customerName":"Binh Le","revenueCents":62300,"orderCount":1},
{"customerId":1,"customerName":"Ann Tran","revenueCents":50200,"orderCount":2}]The SQL each version issues
Turning on logging.level.org.hibernate.SQL=DEBUG shows what the two versions actually cost. The stream version issues one query for the orders, then one per lazy collection it touches, then one per customer proxy it dereferences:
select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.status from orders o1_0
select i1_0.order_id,i1_0.id,i1_0.product_id,i1_0.quantity,i1_0.unit_price_cents
from order_items i1_0 where i1_0.order_id=?
select c1_0.id,c1_0.email,c1_0.name from customers c1_0 where c1_0.id=?The aggregate is one statement, and it is the statement you would have written by hand:
select c1_0.id,c1_0.name,sum((i1_0.quantity*i1_0.unit_price_cents)),count(distinct o1_0.id)
from orders o1_0
join customers c1_0 on c1_0.id=o1_0.customer_id
join order_items i1_0 on o1_0.id=i1_0.order_id
where o1_0.status='PAID'
group by c1_0.id,c1_0.name
order by sum((i1_0.quantity*i1_0.unit_price_cents)) desc,c1_0.nameThe other two reports come out the same way:
select p1_0.sku,p1_0.name,sum(oi1_0.quantity)
from order_items oi1_0
join products p1_0 on p1_0.id=oi1_0.product_id
join orders o1_0 on o1_0.id=oi1_0.order_id
where o1_0.status='PAID'
group by p1_0.sku,p1_0.name
order by sum(oi1_0.quantity) desc,p1_0.sku
select o1_0.status,count(o1_0.id) from orders o1_0 group by o1_0.status order by o1_0.statusRows transferred, not milliseconds
This is the comparison worth having, and it is the one most articles get wrong by reaching for a stopwatch. A timing measures the machine that ran it: the page cache, the JIT, whatever else was busy. A row count measures the design, and it is identical on your laptop and on the production box.
Hibernate will count for you. Set spring.jpa.properties.hibernate.generate_statistics=true, clear the counters, run the report, and read them back:
private <T> T count(String label, Supplier<T> report, int resultRows) {
stats().clear();
T result = report.get();
System.out.printf("ROWS %-34s statements=%-4d entityRows=%-5d collectionFetches=%-5d resultRows=%d%n",
label, stats().getPrepareStatementCount(), stats().getEntityLoadCount(),
stats().getCollectionFetchCount(), resultRows);
return result;
}Over a seeded dataset of 40 orders, 120 order items, 5 customers and 8 products — 30 orders PAID, 5 CANCELLED, 5 NEW — this is what the suite printed:
ROWS revenue / stream + lazy items statements=36 entityRows=135 collectionFetches=30 resultRows=5
ROWS revenue / stream + fetch join statements=1 entityRows=165 collectionFetches=0 resultRows=5
ROWS revenue / JPQL aggregate statements=1 entityRows=0 collectionFetches=0 resultRows=5
ROWS top products / stream statements=39 entityRows=138 collectionFetches=30 resultRows=8
ROWS top products / JPQL aggregate statements=1 entityRows=0 collectionFetches=0 resultRows=8
ROWS by status / stream statements=1 entityRows=40 collectionFetches=0 resultRows=3
ROWS by status / JPQL aggregate statements=1 entityRows=0 collectionFetches=0 resultRows=3As a table:
| Report | Approach | Statements | Entity rows into the JVM | Result rows |
|---|---|---|---|---|
| Revenue by customer | stream, lazy items | 36 | 135 | 5 |
| Revenue by customer | stream, join fetch | 1 | 165 | 5 |
| Revenue by customer | JPQL aggregate | 1 | 0 | 5 |
| Top products | stream | 39 | 138 | 8 |
| Top products | JPQL aggregate | 1 | 0 | 8 |
| Orders by status | stream | 1 | 40 | 3 |
| Orders by status | JPQL aggregate | 1 | 0 | 3 |
Four things fall out of that table, and only one of them is the obvious one.
The naive stream version has an N+1 problem, and the numbers name it: 1 query for the orders, 30 for the item collections of the paid orders, 5 for the customer proxies. Thirty-six statements to produce five rows.
Adding left join fetch o.items fixes the statement count and not the volume. One statement — but 165 entity rows materialised, which is more than the lazy version loaded, because now every order and every item comes back including the ones that will be filtered out. If your instinct was "add a fetch join and the problem is solved", the middle row of that table is the correction.
The aggregate transfers what the report is: five rows, no entities. Nothing is constructed that the caller will not read.
And the last two rows are the honest limit of the argument. Counting orders by status is one statement either way, and the difference is 40 rows against 3. On this dataset that is nothing. On a table with two million orders it is the difference between a report and an outage — and the code does not change shape as the table grows, so the decision has to be made while the table is small.
The rule that falls out is not "always use aggregates". It is: read-only reporting that reduces many rows to few belongs in the database; anything that needs the entity's own behaviour belongs in the JVM. Order.totalCents() is a method on the entity, and the stream version gets to call it. The aggregate version has to re-express that arithmetic in JPQL, and if the pricing rule ever grows a discount, those two expressions can drift apart. That is the real cost of the aggregate, and it is a maintenance cost, not a performance one.
Testing at three levels
Fifty-four tests over one service, in three groups that differ by what they are allowed to load.

Level 1: plain unit tests, no Spring at all
Twenty-six of the fifty-four tests start no context, open no connection and mock nothing. They construct an object and call a method:
class ProductStockTest {
private Product product(int stock) {
return new Product("SKU-1", "Widget", 1000, stock);
}
@ParameterizedTest
@CsvSource({ "10, 1, true", "10, 10, true", "10, 11, false",
"0, 1, false", "10, 0, false", "10, -1, false" })
void canFulfil_isTrueOnlyForAPositiveQuantityThatFits(int stock, int wanted, boolean expected) {
assertEquals(expected, product(stock).canFulfil(wanted));
}
@Test
@DisplayName("stock can never go negative, even if a caller ignores canFulfil")
void decreaseStock_rejectsOverdraft() {
Product p = product(3);
IllegalStateException e = assertThrows(IllegalStateException.class, () -> p.decreaseStock(4));
assertEquals("cannot take 4 from stock 3", e.getMessage());
assertEquals(3, p.getStock());
}
}The boundary cases in that @CsvSource are the ones that ship bugs: exactly enough stock, one too many, zero requested, a negative quantity. Writing them costs one line each because nothing has to be started first.
The pricing rule gets the same treatment, including the one that people get wrong:
@Test
@DisplayName("the unit price is copied at order time, so a later reprice cannot rewrite history")
void unitPriceIsFrozenAtOrderTime() {
Product p = new Product("A", "Widget", 1000, 10);
Order order = new Order(new Customer("Ann", "ann@example.com"), T0);
order.addItem(p, 2);
p.reprice(9999);
assertEquals(2000, order.totalCents());
assertEquals(1000, order.getItems().get(0).getUnitPriceCents());
}
@Test
void totalCents_survivesAQuantityThatWouldOverflowAnInt() {
Order order = new Order(new Customer("Ann", "ann@example.com"), T0);
order.addItem(new Product("A", "Bulk", 1_000_000, Integer.MAX_VALUE), 3000);
assertEquals(3_000_000_000L, order.totalCents());
}The second one is why lineTotalCents() casts to long before multiplying. 3000 * 1_000_000 overflows a signed 32-bit int; the test fails loudly if someone simplifies the cast away.
A static method on a Spring bean is still a plain method, so OrderService.mergeLines is tested here too, with no container:
@Test
void duplicateSkusAreSummed() {
Map<String, Integer> merged = OrderService.mergeLines(
List.of(new Line("A", 2), new Line("B", 1), new Line("A", 3)));
assertEquals(Map.of("A", 5, "B", 1), merged);
}
@Test
void insertionOrderIsPreserved() {
Map<String, Integer> merged = OrderService.mergeLines(
List.of(new Line("Z", 1), new Line("A", 1), new Line("M", 1)));
assertEquals(List.of("Z", "A", "M"), List.copyOf(merged.keySet()));
}Level 2: @WebMvcTest slices with @MockitoBean
Thirteen tests check the HTTP layer alone: status codes, JSON shape, validation, and the exception-to-status mapping. @WebMvcTest starts the controller, the message converters and the @RestControllerAdvice, and nothing else. In Spring Boot 4 the annotation lives in org.springframework.boot.webmvc.test.autoconfigure, and @MockBean is gone — the replacement is @MockitoBean from org.springframework.test.context.bean.override.mockito:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orderService;
private static final String BODY = """
{"customerId": 7, "lines": [{"sku": "SKU-WIDGET", "quantity": 3}]}
""";
@Test
void postOrder_returns201AndTheOrderShape() throws Exception {
given(orderService.create(any())).willReturn(TestOrders.sample());
mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("NEW"))
.andExpect(jsonPath("$.totalCents").value(3750))
.andExpect(jsonPath("$.items.length()").value(1))
.andExpect(jsonPath("$.items[0].unitPriceCents").value(1250))
.andExpect(jsonPath("$.items[0].lineTotalCents").value(3750));
}
@Test
void insufficientStock_becomes409WithTheNumbers() throws Exception {
given(orderService.create(any())).willThrow(new InsufficientStockException("SKU-WIDGET", 3, 1));
mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INSUFFICIENT_STOCK"))
.andExpect(jsonPath("$.details.requested").value(3))
.andExpect(jsonPath("$.details.available").value(1));
}
@Test
void emptyLines_becomes400FromBeanValidation() throws Exception {
mvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON)
.content("{\"customerId\": 7, \"lines\": []}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.details.lines").exists());
}
}The slice needs a fully-populated Order to serialise, and the entity ids are generated by the database that this slice does not have. ReflectionTestUtils fills them in:
static Order sample() {
Customer customer = new Customer("Ann Tran", "ann@example.com");
ReflectionTestUtils.setField(customer, "id", 7L);
Product widget = new Product("SKU-WIDGET", "Widget", 1250, 100);
ReflectionTestUtils.setField(widget, "id", 1L);
Order order = new Order(customer, T0);
ReflectionTestUtils.setField(order, "id", 42L);
order.addItem(widget, 3);
return order;
}Remember that helper. It is what makes the slice fast, and it is also what makes the slice blind — the object it builds is more complete than anything the real service hands back.
How much lighter is a slice? The suite prints it rather than asserting a feeling:
FOOTPRINT WebMvcTest beans=128 dataSource=0 emf=0
FOOTPRINT SpringBootTest beans=237 dataSource=1 emf=1A hundred and twenty-eight bean definitions against two hundred and thirty-seven, with no DataSource and no EntityManagerFactory in the slice at all. That is the argument for slices, stated in what gets loaded rather than in seconds — the seconds would be a property of the machine.
Level 3: @SpringBootTest against H2
Fifteen tests start the whole application against an in-memory H2 database, with no @Transactional on the test class, so every commit and every rollback is real. The round trip:
@Test
@DisplayName("create then pay then cancel: stock moves down at pay and back up at cancel")
void roundTrip() {
CreateOrderRequest request =
SalesFixture.order(customerId, new Line("SKU-WIDGET", 3), new Line("SKU-GIZMO", 2));
Order created = orderService.create(request);
assertEquals(OrderStatus.NEW, created.getStatus());
assertEquals(3 * 1250 + 2 * 499, created.totalCents());
assertEquals(10, stockOf("SKU-WIDGET"), "creating an order must not reserve stock");
Order paid = orderService.pay(created.getId());
assertEquals(OrderStatus.PAID, paid.getStatus());
assertEquals(7, stockOf("SKU-WIDGET"));
assertEquals(8, stockOf("SKU-GIZMO"));
Order cancelled = orderService.cancel(created.getId());
assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
assertEquals(10, stockOf("SKU-WIDGET"), "cancelling a PAID order restores stock");
assertEquals(10, stockOf("SKU-GIZMO"));
}And the test that only this level can write, because it needs a transaction that actually rolls back:
@Test
@DisplayName("a payment that fails on the second line rolls the first line's decrement back")
void payIsAllOrNothing() {
Order big = orderService.create(SalesFixture.order(customerId, new Line("SKU-GIZMO", 9)));
Order mixed = orderService.create(
SalesFixture.order(customerId, new Line("SKU-WIDGET", 4), new Line("SKU-GIZMO", 5)));
orderService.pay(big.getId());
assertEquals(1, stockOf("SKU-GIZMO"));
assertThrows(InsufficientStockException.class, () -> orderService.pay(mixed.getId()));
assertEquals(10, stockOf("SKU-WIDGET"), "the widget decrement must have been rolled back");
assertEquals(1, stockOf("SKU-GIZMO"));
assertEquals(OrderStatus.NEW, orders.findById(mixed.getId()).orElseThrow().getStatus());
}pay walks the lines in order. It takes four widgets successfully, then finds only one gizmo where it needs five, and throws. InsufficientStockException extends RuntimeException, so Spring rolls the transaction back and the widget stock is 10 again — not 6. Assert the first line's stock, not the failing one: the failing line was never decremented, so asserting on it proves nothing.
Three more tests drive the same lifecycle over HTTP with @SpringBootTest plus @AutoConfigureMockMvc, so the request goes through the real controller, the real service, the real repository and H2:
mvc.perform(post("/api/orders/" + id + "/pay"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
assertEquals(7, products.findBySku("SKU-WIDGET").orElseThrow().getStock());
mvc.perform(post("/api/orders/" + id + "/pay"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ILLEGAL_STATE"));The bug only the top level caught
The @WebMvcTest for pay passed. Every unit test passed. The @SpringBootTest over HTTP failed on the same endpoint:
jakarta.servlet.ServletException: Request processing failed:
org.hibernate.LazyInitializationException: Could not initialize proxy
[com.example.sales.domain.Customer#1] - no session
Caused by: org.hibernate.LazyInitializationException: Could not initialize proxy
[com.example.sales.domain.Customer#1] - no session
at com.example.sales.domain.Customer$HibernateProxy.getName(Unknown Source)
at com.example.sales.web.dto.OrderResponse.of(OrderResponse.java:19)
at com.example.sales.web.OrderController.pay(OrderController.java:39)At this point in the work, findByIdWithItems fetched the items and left customer as a lazy proxy. OrderService.pay loaded the order through it, and the controller then called OrderResponse.of(order) after the transaction had closed, with spring.jpa.open-in-view=false. Calling getName() on a proxy with no session throws. The fix was one clause in one query:
// before
@Query("select o from Order o left join fetch o.items where o.id = :id")
// after
@Query("select o from Order o join fetch o.customer left join fetch o.items where o.id = :id")One clause per association the response actually reads, in other words. A response that also reads item.getProduct() needs left join fetch i.product as well, which is why the finished query in the build article carries all three clauses — it was written after this bug had already been paid for.
⚠️ The slice could not have caught this, and no amount of extra slice tests would have.
@MockitoBean OrderServicereturns whatever the test built — anOrderwhoseCustomeris a real object, never a proxy. The mock is more cooperative than reality, which is exactly what makes it fast and exactly what makes it blind.
That is the honest version of the testing pyramid. The shape is a good default: unit tests are cheap to write, they run without a container, and they catch the arithmetic and boundary bugs that make up most defects. But the pyramid is a heuristic, not a law. This project's defect was a wiring bug — a query, a fetch strategy and a serialisation boundary interacting — and wiring bugs are invisible to every level that mocks the wiring away. One integration test that catches a wiring bug is worth ten unit tests that cannot.
The practical consequence is a rule about where to spend, not how many to write: add a unit test for every rule with a boundary, and add an integration test for every place two layers meet — a transaction boundary, a lazy association crossing into serialisation, a status code produced by a real exception rather than a stubbed one.
Running the whole suite
./mvnw test. Surefire's default include patterns pick up *Test, which is why the integration classes are named ...IntegrationTest rather than ...IT — an *IT class is Failsafe's, and it would be silently skipped by mvn test. That silent skip is worth checking for on any project you inherit: run the suite, then count the classes.
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.example.sales.web.OrderControllerTest
FOOTPRINT WebMvcTest beans=128 dataSource=0 emf=0
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.web.OrderControllerTest
Running com.example.sales.web.ProductControllerTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.web.ProductControllerTest
Running com.example.sales.integration.OrderLifecycleIntegrationTest
FOOTPRINT SpringBootTest beans=237 dataSource=1 emf=1
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.OrderLifecycleIntegrationTest
Running com.example.sales.integration.OrderApiIntegrationTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.OrderApiIntegrationTest
Running com.example.sales.integration.ReportComparisonIntegrationTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.integration.ReportComparisonIntegrationTest
Running com.example.sales.service.MergeLinesTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.service.MergeLinesTest
Running com.example.sales.domain.ProductStockTest
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.ProductStockTest
Running com.example.sales.domain.OrderStatusTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.OrderStatusTest
Running com.example.sales.domain.OrderPricingTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.sales.domain.OrderPricingTest
Results:
Tests run: 54, Failures: 0, Errors: 0, Skipped: 0Surefire's Time elapsed column and Spring Boot's Started ... in N seconds lines have been removed from that transcript deliberately. They are properties of the machine that ran them, and reprinting them in an article invites a comparison against your machine that means nothing.
What I would change if this grew
Everything in this section is design judgement, not something measured. It is what I would look at first, in the order the problems usually arrive — and the honest answer to most of it is "not yet".
Pagination on every list endpoint. GET /api/products returns products.findAll(). That is fine for a demo and wrong for a catalogue, and the fix is not free: once the endpoint returns a page, the client has to handle a page, so retrofitting it is an API break. Spring Data hands you Pageable and Page<Product> for almost nothing, so this is the one item on the list I would do immediately rather than "when it grows".
An outbox, or an explicit reservation, for stock. Right now stock moves inside the same transaction that pays the order. That is correct and it is the reason payIsAllOrNothing passes. It stops being enough the moment stock has to be reflected somewhere outside this database — a warehouse system, a search index, another service. The usual shape is an outbox table written in the same transaction and drained afterwards, so the stock change and the message about it cannot disagree. Two customers paying for the last unit at the same instant is a separate problem, solved by optimistic locking with @Version on Product, and I would add that before the outbox.
A real database. H2 in memory is a test fixture, not a deployment. ddl-auto=create-drop is fine for a suite that seeds itself and wrong everywhere else; a production schema wants Flyway or Liquibase and ddl-auto=validate. Switching also changes behaviour you may be relying on without knowing: H2's defaults for identity generation, string comparison and case sensitivity are not Postgres's.
Caching, last and reluctantly. The product catalogue is read constantly and written rarely, which is the textbook case for a cache. It is also the fastest way to serve stale stock counts to a customer who is about to be told their order failed. If it comes to that, cache the catalogue metadata — name, price — and never the stock number.
Notice what is not on that list: rewriting the streams, tuning the JVM, or replacing anything with something faster. The reporting comparison earlier is the only place performance came up, and the answer there was a design decision about where to compute, not a tuning knob.
FAQ
Should I use @MockBean or @MockitoBean in Spring Boot 4?
@MockitoBean. @MockBean was removed in Spring Boot 4, and the replacement lives in Spring Framework itself at org.springframework.test.context.bean.override.mockito.MockitoBean. The two behave the same way for the common case: a mock replaces a bean of that type in the test context.
Why is my @SpringBootTest class not running under mvn test?
Almost certainly the class name. Surefire's default includes are *Test, Test*, *Tests and *TestCase; a class named OrderLifecycleIT matches none of them and is skipped without a warning. Either rename it to end in Test, or configure Failsafe and run mvn verify.
Is groupingBy in Java slower than GROUP BY in SQL?
That is the wrong question, and it is why this article counts rows instead. The stream version transferred 135 entity rows across 36 statements to produce 5 result rows; the aggregate transferred 5 rows in 1 statement. The difference is what crosses the connection and what the JVM has to allocate — and it grows with your table, whatever hardware you run.
Does a join fetch fix an N+1 problem?
It fixes the statement count and not the data volume. In the measurement above, left join fetch o.items took 36 statements down to 1 and took entity rows up from 135 to 165, because the single query returns every order and every item including the ones the filter later discards. It is the right fix when you need the entities; it is not a substitute for an aggregate when you only need a summary.
Why does my report throw LazyInitializationException only in production?
Most likely spring.jpa.open-in-view, which is true by default and keeps the persistence context open for the whole request. That hides the bug locally and holds a database connection for the duration of every request. Setting it to false — as this project does — makes the failure appear immediately, in a test, where you want it.
How many tests should each level have?
There is no correct ratio, and any number quoted as one is invented. A useful rule instead: one unit test per rule that has a boundary, and one integration test per place two layers meet. This project landed at 26 / 13 / 15 because it has a lot of arithmetic rules, one HTTP surface and three real transaction boundaries.
Do I need @Transactional on my test class?
Not for tests about transactions. @Transactional on the test rolls everything back at the end, which is convenient for cleanup and fatal for any test asserting that a commit or a rollback actually happened — the test's own transaction swallows the behaviour you are trying to observe. Clean up in @BeforeEach instead.
Should the JPQL aggregate return a record or an interface projection?
A record, unless you need the query to be derived rather than written. A constructor expression, select new com.example.sales.report.CustomerRevenue(...), gives you a compile-checked type with named components and works with a plain record. Interface projections are convenient for derived queries but push the mismatch to runtime.
Conclusion
The three parts of this article are the same idea seen three times. The test suite is worth having because it is built where the bugs live rather than where the tests are easy — and the one real defect here was a wiring bug that only the level with a database could see. The reporting comparison is worth having because it is measured in rows and statements, which are properties of the design, rather than in milliseconds, which are properties of whatever else your laptop was doing. And the retrospective is worth having because it names files and methods, and because it is as specific about what the project deliberately does not use as about what it does.
That is the code. The next and final article closes the course with the things that sit above it: best practices, performance, and how to talk about all of this in an interview.