Command Palette

Search for a command to run...

[Advanced Java] Putting the Java Course Together: Reports, Error Mapping and a Three-Level Test Suite

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.

Six subjects — OOP, Collections, Stream, Exception, Database, Tests — converging into one project

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:

Java
// 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 back

That 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.

Six package rows of the project, each annotated with the course idea its real type and method names are an instance of

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:

Java
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:

Java
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:

Java
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 { }          // 409

Each 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:

Java
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:

Java
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:

Java
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:

Java
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 courseUsed hereWhy
Threads, ExecutorService, CompletableFutureNoEvery 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 NIONoNothing is read from or written to disk. The database is the only store.
Serialization, custom ObjectOutputStream workNoJSON in, JSON out, handled by Jackson.
Singleton, Factory, Builder, ObserverBarelySpring 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 entitiesNoProduct, Customer and Order share nothing. The only inheritance in the project is the exception hierarchy, where it buys a single @ExceptionHandler.
Comparable on entitiesNoThe 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:

Java
@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:

JSON
{"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.

Two panels: streams over entities issuing 36 statements for 135 rows, against one JPQL aggregate returning 5 projection rows

Revenue by customer, twice

The stream version reads the way you would explain the report out loud:

Java
@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:

Java
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:

Java
assertEquals(lazy, viaSql, "both versions must produce identical rows");

Against the running application, the two endpoints agree byte for byte:

JSON
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:

SQL
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:

SQL
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.name

The other two reports come out the same way:

SQL
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.status

Rows 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:

Java
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:

Text
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=3

As a table:

ReportApproachStatementsEntity rows into the JVMResult rows
Revenue by customerstream, lazy items361355
Revenue by customerstream, join fetch11655
Revenue by customerJPQL aggregate105
Top productsstream391388
Top productsJPQL aggregate108
Orders by statusstream1403
Orders by statusJPQL aggregate103

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.

Three test levels sized by test count, each annotated with its bean count and what it can see

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:

Java
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:

Java
@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:

Java
@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:

Java
@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:

Java
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:

Text
FOOTPRINT WebMvcTest    beans=128 dataSource=0 emf=0
FOOTPRINT SpringBootTest beans=237 dataSource=1 emf=1

A 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:

Java
@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:

Java
@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:

Java
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:

Text
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:

Java
// 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 OrderService returns whatever the test built — an Order whose Customer is 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.

Text
-------------------------------------------------------
 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: 0

Surefire'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.

Related Posts

[Advanced Java] Working with JSON and XML in Java: Gson, Jackson and the Defaults That Bite

JSON binding in Java with Gson 2.10.1 and Jackson 2.17.3: round trips, nested objects and collections, renaming and ignoring fields, the null-handling and unknown-key defaults that differ, TypeToken and TypeReference for generics, java.time support, streaming large documents with JsonReader and JsonParser, the traps that fail somewhere other than the mistake, and what XML looks like on a JDK that no longer ships JAXB.

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.

[Advanced Java] JDBC in Java: Connections, PreparedStatement and Transactions

JDBC in Java on OpenJDK 21 against a real SQLite database: the driver architecture behind DriverManager, try-with-resources over Connection, Statement and ResultSet, a live SQL injection demonstration that PreparedStatement stops, the ResultSet cursor, transactions with commit, rollback and Savepoint, batch updates, getGeneratedKeys and how to read a SQLException.

[Advanced Java] Lambda Expressions in Java: Syntax, Target Typing and Method References

Lambda expressions in Java on OpenJDK 21: every syntactic form including var parameters, target typing demonstrated by assigning one text to three interfaces, effectively-final capture with the real javac errors, what this means inside a lambda body, the four kinds of method reference, and why a bound reference evaluates its receiver eagerly.