Command Palette

Search for a command to run...

[Advanced Java] Building a Real Project: a Sales REST API with Spring Boot

Seven parts of this course have each taught one thing at a time. This one does not. It builds a single application — a sales management REST API with products, customers and orders — and every decision in it is a decision the earlier parts already argued for.

This opens Part 8, the last part of the course: applying everything. The article is a construction log rather than a tutorial about a concept. The code compiles, the application starts, and every curl command and every response below was captured from it running on localhost:18096.

Customer, Order, OrderItem and Product chained together under one endpoint, POST /api/orders answering 201 Created

The order below is the order you would build it in: what the thing does, the domain model, the layout, entities, repositories, services, controllers, error handling, and then a long session against the running app.

What this project is, and what it does when it runs

A sales API with four entities and fourteen routes. Products and customers get plain CRUD. Orders get a creation endpoint that takes a customer id and a list of SKUs, plus two state transitions: pay and cancel. The rules are small enough to state in four lines and awkward enough to be worth writing carefully:

  • An order fails if any SKU is unknown (404) or if stock is insufficient (409).
  • Paying decrements stock for every item inside one transaction, and is rejected if the order is not NEW (409).
  • Cancelling a PAID order puts the stock back.
  • An order records the price it was placed at, so a later price change cannot alter it.

Here is the shape of it, before any of the code. Three requests: create an order, pay it, look at the stock afterwards. This is a real capture; every response below is shown with its status line, its Location header when it has one, and its Content-Type, with the remaining headers trimmed:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"customerId":1,"lines":[{"sku":"KB-01","quantity":2},{"sku":"MS-02","quantity":1}]}'
HTTP/1.1 201
Location: /api/orders/1
Content-Type: application/json
 
{
  "id": 1,
  "customerId": 1,
  "customerName": "Ada Lovelace",
  "status": "NEW",
  "createdAt": "2026-09-10T10:12:22.531605Z",
  "items": [
    {
      "sku": "KB-01",
      "name": "Mechanical Keyboard",
      "quantity": 2,
      "unitPriceCents": 8900,
      "lineTotalCents": 17800
    },
    {
      "sku": "MS-02",
      "name": "Wireless Mouse",
      "quantity": 1,
      "unitPriceCents": 3450,
      "lineTotalCents": 3450
    }
  ],
  "totalCents": 21250
}
Text
$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "customerId": 1,
  "customerName": "Ada Lovelace",
  "status": "PAID",
  "createdAt": "2026-09-10T10:12:22.531605Z",
  "items": [ ... ],
  "totalCents": 21250
}
Text
$ curl -s -i http://localhost:18096/api/products
HTTP/1.1 200
Content-Type: application/json
 
[
  {
    "id": 1,
    "sku": "KB-01",
    "name": "Mechanical Keyboard",
    "priceCents": 10900,
    "stock": 10
  },
  {
    "id": 2,
    "sku": "MS-02",
    "name": "Wireless Mouse",
    "priceCents": 3450,
    "stock": 4
  },
  {
    "id": 3,
    "sku": "HS-03",
    "name": "Headset",
    "priceCents": 12500,
    "stock": 3
  }
]

Stock went from 12 to 10 and from 5 to 4 because the order was paid, not because it was created. That distinction is the whole design, and the rest of this article is how it is enforced.

Everything here ran on OpenJDK 21.0.6 (arm64) with Spring Boot 4.1.1, Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, Hibernate Validator 9.1.3.Final, Jakarta Persistence 3.2.0, Jackson 3.1.5, embedded Tomcat 11.0.24 and H2 2.4.240, built by the Maven wrapper on Maven 3.9.16. Spring Boot's Started SalesApplication in ... seconds line and Hibernate's timing lines have been removed from every transcript: they measure a laptop, and nothing here is an argument about speed. Log lines are shown without their timestamp, PID and thread columns, Hibernate's formatted SQL and any over-long log line are rewrapped where they are quoted, and a long unchanged items array in a repeated response body is elided as [ ... ]. Nothing else is edited.

The domain model

Four entities, three relationships, and one field that behaves differently from the rest.

Four entity cards — Customer, Order, Product, OrderItem — with solid arrows marking the owning side that holds the foreign key and a dashed arrow marking the mappedBy inverse side

Read the arrows first. Three of the relationships are @ManyToOne, and each of those is the owning side: the table underneath it carries the foreign-key column. orders holds customer_id, order_items holds order_id and product_id. The single @OneToMany on Order.items is the inverse side. It owns nothing; it is a view of the same foreign key from the other direction, which is why it needs mappedBy = "order" to say which column it is reading.

Getting that backwards is the classic mapping bug: leave off mappedBy and JPA assumes the two sides are separate relationships, and invents a join table for the collection. The schema in the next section is the proof that this one did not.

The field worth arguing about is OrderItem.unitPriceCents. It duplicates Product.priceCents at the moment the order is placed, and after that the two are unrelated.

Project layout, and the one command that creates it

Generating the project on Spring Boot 4.1.1

No local Maven or Gradle install is needed — Spring Initializr ships a wrapper in the zip:

Bash
curl -sS -o d.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1.RELEASE&javaVersion=21&groupId=com.example&artifactId=sales&dependencies=web,data-jpa,h2,validation"
unzip -q d.zip -d sales
cd sales
./mvnw -B -DskipTests package
java -jar target/sales-0.0.1-SNAPSHOT.jar --server.port=18096 --spring.output.ansi.enabled=NEVER

Two things about that command are worth knowing before you run it.

The Initializr API identifies the version as 4.1.1.RELEASE, and that string goes straight into the generated POM's parent version — but the artifact published to Maven Central is plain 4.1.1. The build fails immediately with Could not find artifact org.springframework.boot:spring-boot-starter-parent:pom:4.1.1.RELEASE. The fix is one edit to the generated POM:

Bash
sed -i '' 's|<version>4.1.1.RELEASE</version>|<version>4.1.1</version>|' pom.xml

The second thing is that the web dependency id no longer resolves to spring-boot-starter-web. On Boot 4 it produces spring-boot-starter-webmvc, and the old artifact's POM now says it is deprecated in favour of it. The generated dependency block, verbatim:

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Four dependencies, and that is the entire build file apart from the parent and the plugin. Jackson arrives transitively with the web starter, and on Boot 4 it is Jackson 3 under the tools.jackson package rather than Jackson 2's com.fasterxml.jackson.

The whole of application.properties:

Properties
spring.application.name=sales
 
spring.datasource.url=jdbc:h2:mem:sales;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=false

show-sql and format_sql are on because this article quotes the SQL. open-in-view=false is on because it should be: with it enabled Boot keeps a persistence context open for the whole request, which papers over lazy-loading mistakes in the controller instead of surfacing them. Turning it off is what forces the repository query in the next section to fetch what the response actually needs.

The package layout

Tree
src/main/java/com/example/sales/
├── SalesApplication.java
├── domain/
│   ├── Customer.java
│   ├── Order.java
│   ├── OrderItem.java
│   ├── OrderStatus.java
│   └── Product.java
├── error/
│   ├── ConflictException.java
│   └── NotFoundException.java
├── repository/
│   ├── CustomerRepository.java
│   ├── OrderRepository.java
│   └── ProductRepository.java
├── service/
│   ├── CustomerService.java
│   ├── OrderService.java
│   └── ProductService.java
└── web/
    ├── ApiExceptionHandler.java
    ├── CustomerController.java
    ├── OrderController.java
    ├── ProductController.java
    └── dto/
        ├── CreateOrderRequest.java
        ├── CustomerRequest.java
        ├── CustomerResponse.java
        ├── OrderItemResponse.java
        ├── OrderResponse.java
        ├── ProductRequest.java
        └── ProductResponse.java

Twenty-five source files. The package names are the layers, so a misplaced import is visible in a diff: nothing in domain imports from web, nothing in service imports from web except the one request record it accepts, and nothing outside web mentions HTTP at all.

The entities

Product and Customer

Both are flat. The only thing worth pointing at is that constraints live in the mapping, not only in the service:

Java
@Entity
@Table(name = "products")
public class Product {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, unique = true, length = 40)
    private String sku;
 
    @Column(nullable = false, length = 120)
    private String name;
 
    @Column(name = "price_cents", nullable = false)
    private int priceCents;
 
    @Column(nullable = false)
    private int stock;
 
    protected Product() {
    }
 
    public Product(String sku, String name, int priceCents, int stock) {
        this.sku = sku;
        this.name = name;
        this.priceCents = priceCents;
        this.stock = stock;
    }
 
    // getters and setters
}

priceCents is an int of minor units, not a double and not a BigDecimal. Money in floating point is wrong for the same reason it is always wrong, and BigDecimal buys nothing here because the API has no fractional cents and no currency conversion. An integer count of cents is exact, sums exactly, and serialises as a JSON number with no formatting decisions to get wrong.

Customer is the same shape with name and a unique email.

Order and OrderItem

This is where the mapping matters:

Java
@Entity
@Table(name = "orders")
public class Order {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;
 
    @Column(name = "created_at", nullable = false)
    private Instant createdAt;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 16)
    private OrderStatus status;
 
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();
 
    public Order(Customer customer, Instant createdAt) {
        this.customer = customer;
        this.createdAt = createdAt;
        this.status = OrderStatus.NEW;
    }
 
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }
 
    public int totalCents() {
        return items.stream().mapToInt(OrderItem::lineTotalCents).sum();
    }
 
    // getters, and a setter for status only
}

Four decisions in that class, all of which show up later:

DecisionWhy
@Enumerated(EnumType.STRING)The default is ORDINAL, which stores 0, 1, 2. Insert a new constant in the middle of the enum and every existing row silently changes meaning
cascade = CascadeType.ALL on itemsSaving the order saves its lines. The order is the aggregate root; an OrderItem has no life of its own
addItem sets both sidesitems.add(item) alone leaves item.order null, and the insert fails on a not null column
totalCents() is derived, not storedA stored total is a second source of truth that can disagree with the lines

OrderItem carries the two foreign keys and the copied price:

Java
@Entity
@Table(name = "order_items")
public class OrderItem {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;
 
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;
 
    @Column(nullable = false)
    private int quantity;
 
    @Column(name = "unit_price_cents", nullable = false)
    private int unitPriceCents;
 
    public OrderItem(Product product, int quantity, int unitPriceCents) {
        this.product = product;
        this.quantity = quantity;
        this.unitPriceCents = unitPriceCents;
    }
 
    public int lineTotalCents() {
        return quantity * unitPriceCents;
    }
}

setOrder is package-private on purpose. The only correct way to attach a line to an order is order.addItem(item), which sets both sides; making the setter public invites the half-wired version.

Why unitPriceCents is copied instead of read

The obvious model is not to have the field at all. An OrderItem already points at a Product, and a Product already has a priceCents — so compute the line total as quantity * product.getPriceCents() and store nothing.

That model is wrong, and it is wrong in a way that only shows up in production, months later, when someone raises a price. Every historical order silently changes. A customer's confirmation email, the invoice they printed, and the number your API returns for the same order id stop agreeing. Nothing threw, nothing logged, and the database is now telling a different story than it did yesterday.

The rule underneath it: a record of something that happened must not depend on data that is still allowed to change. The price on an order line is not a fact about the product, it is a fact about the transaction. It gets copied once, at the moment OrderService.create runs, and is never read from the product again.

The cost is one denormalised column and the discipline not to "fix" it. The demonstration is in the worked session below — change a price, re-read the order, watch the total not move.

The schema Hibernate generated

With ddl-auto=create-drop and format_sql on, the tables Hibernate built at startup are in the log, verbatim:

SQL
create table customers (
    id bigint generated by default as identity,
    name varchar(120) not null,
    email varchar(160) not null unique,
    primary key (id)
)
 
create table order_items (
    quantity integer not null,
    unit_price_cents integer not null,
    id bigint generated by default as identity,
    order_id bigint not null,
    product_id bigint not null,
    primary key (id)
)
 
create table orders (
    created_at timestamp(6) with time zone not null,
    customer_id bigint not null,
    id bigint generated by default as identity,
    status enum ('CANCELLED','NEW','PAID') not null,
    primary key (id)
)
 
create table products (
    price_cents integer not null,
    stock integer not null,
    id bigint generated by default as identity,
    sku varchar(40) not null unique,
    name varchar(120) not null,
    primary key (id)
)

And the three foreign keys:

SQL
alter table if exists order_items
   add constraint FKbioxgbv59vetrxe0ejfubep1w
   foreign key (order_id)
   references orders
 
alter table if exists order_items
   add constraint FKocimc7dtr037rh4ls4l95nlfi
   foreign key (product_id)
   references products
 
alter table if exists orders
   add constraint FKpxtb8awmi0dk6smoh2vp1litg
   foreign key (customer_id)
   references customers

Three tables carry a foreign key and there is no join table anywhere — which is the confirmation that mappedBy did its job. Two more details are worth reading off this output: status became an H2 enum with the three constant names in it rather than an integer, because of EnumType.STRING; and created_at became timestamp(6) with time zone, because the field is an Instant rather than a LocalDateTime. A LocalDateTime would have produced a column with no zone at all, which is a bug waiting for the first server in a different region.

create-drop is right for a demo and wrong for anything else. Real deployments use validate plus a migration tool.

The repositories

Three interfaces and no implementations. Two of them are derived queries only; the third is the one that needs a query written out:

Java
public interface ProductRepository extends JpaRepository<Product, Long> {
 
    Optional<Product> findBySku(String sku);
 
    boolean existsBySku(String sku);
}
 
public interface CustomerRepository extends JpaRepository<Customer, Long> {
 
    boolean existsByEmail(String email);
}
 
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    @Query("""
            select o from Order o
              join fetch o.customer
              left join fetch o.items i
              left join fetch i.product
            where o.id = :id
            """)
    Optional<Order> findByIdWithItems(Long id);
}

findByIdWithItems is the one method here that is not obvious, and it is annotated for two separate reasons.

The first is that the name does not parse. Spring Data reads a derived method name left to right, and findByIdWithItems becomes "find by the property id.withItems", which does not exist. Leaving the @Query off fails at startup, not at call time:

Text
Caused by: org.springframework.data.repository.query.QueryCreationException:
Cannot create query for method [OrderRepository.findByIdWithItems(java.lang.Long)];
No property 'withItems' found for type 'Long'; Traversed path: Order.id
Caused by: org.springframework.data.core.PropertyReferenceException:
No property 'withItems' found for type 'Long'; Traversed path: Order.id

The second reason is the one that matters. Every relationship in this model is LAZY, and every order response needs the customer, the items and each item's product. Without the three join fetch clauses that is one query for the order plus one per association touched — and with open-in-view=false the lazy ones would not even load, they would throw once the transaction ended. With them, the whole aggregate arrives in a single statement:

SQL
select
    o1_0.id, o1_0.created_at, o1_0.customer_id,
    c1_0.id, c1_0.email, c1_0.name,
    i1_0.order_id, i1_0.id, i1_0.product_id,
    p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock,
    i1_0.quantity, i1_0.unit_price_cents,
    o1_0.status
from
    orders o1_0
join
    customers c1_0
        on c1_0.id=o1_0.customer_id
left join
    order_items i1_0
        on o1_0.id=i1_0.order_id
left join
    products p1_0
        on p1_0.id=i1_0.product_id
where
    o1_0.id=?

One statement, four tables, an inner join to the mandatory customer and outer joins to the optional lines. That query is what every GET /api/orders/{id}, every pay and every cancel starts with.

The services, where every rule lives

Every rule in this application is in a service method, and no rule is anywhere else. Controllers do not check stock. Repositories do not check status. That is the only way the rules stay testable without a web server.

Both business exceptions are three-line classes:

Java
public class NotFoundException extends RuntimeException {
    public NotFoundException(String message) { super(message); }
}
 
public class ConflictException extends RuntimeException {
    public ConflictException(String message) { super(message); }
}

They extend RuntimeException deliberately: they are not conditions a caller can recover from mid-call, and they must trigger a rollback, which unchecked exceptions do by default. Neither knows anything about HTTP — no status codes, no annotations. The translation to 404 and 409 happens in exactly one place, later.

Placing an order

Java
@Transactional
public Order create(CreateOrderRequest request) {
    Customer customer = customers.findById(request.customerId())
            .orElseThrow(() -> new NotFoundException(
                    "No customer with id " + request.customerId()));
 
    Order order = new Order(customer, clock.instant());
    for (Map.Entry<String, Integer> entry : mergeLines(request.lines()).entrySet()) {
        Product product = products.findBySku(entry.getKey())
                .orElseThrow(() -> new NotFoundException("No product with sku " + entry.getKey()));
        if (product.getStock() < entry.getValue()) {
            throw new ConflictException("Insufficient stock for " + product.getSku()
                    + ": requested " + entry.getValue() + ", available " + product.getStock());
        }
        order.addItem(new OrderItem(product, entry.getValue(), product.getPriceCents()));
    }
    return orders.save(order);
}

The loop walks a merged map rather than the raw request list, because a client is allowed to send the same SKU twice and an order with two lines for one product is a bug waiting to happen — two rows that must be summed everywhere, forever:

Java
/** Collapses repeated skus into one line, keeping 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;
}

LinkedHashMap rather than HashMap because the response should list the lines in the order the client sent them, and rather than TreeMap because nobody asked for them sorted by SKU. The quantity check duplicates the @Min(1) on the record: Bean Validation catches it first for an HTTP caller, and this catches it for any caller that is not one. The method is static because it needs nothing from the bean, which also makes it trivially testable.

Three more things to notice. product.getPriceCents() is read exactly once, here, and handed to the OrderItem constructor — that is the copy. Placing an order checks stock but does not reserve it, which is a real design choice with a real consequence: two customers can both place an order for the last unit, and the second one to pay will be refused. And clock.instant() rather than Instant.now(), because a Clock is a bean:

Java
@Bean
Clock clock() {
    return Clock.systemUTC();
}

That single bean is the difference between a createdAt you can assert on and one you cannot.

Paying an order

Java
@Transactional
public Order pay(Long orderId) {
    Order order = findById(orderId);
    if (order.getStatus() != OrderStatus.NEW) {
        throw new ConflictException("Order " + orderId + " is " + order.getStatus()
                + ", only a NEW order can be paid");
    }
    for (OrderItem item : order.getItems()) {
        Product product = item.getProduct();
        if (product.getStock() < item.getQuantity()) {
            throw new ConflictException("Insufficient stock for " + product.getSku()
                    + ": requested " + item.getQuantity() + ", available " + product.getStock());
        }
        product.setStock(product.getStock() - item.getQuantity());
    }
    order.setStatus(OrderStatus.PAID);
    return order;
}

There is no save() call in that method and no update statement written by hand. The products and the order were loaded inside the transaction, so they are managed; changing a field is enough, and the changes are written when the transaction commits.

Which is precisely what makes the method interesting when it fails. It checks and decrements one item at a time, so a request whose second line is out of stock has already decremented the first one before it throws.

Two side-by-side traces of the same seven-step pay transaction: one committing three UPDATE statements, one throwing on the second item and rolling back without issuing any UPDATE at all

Both columns are the same code on the same data. The left one commits. The right one throws a ConflictException on step five, and everything steps one to four did disappears — including the decrement of KB-01.

The last row of the failing column is the part people get wrong when they describe this. The stock is not written and then reverted. Nothing is written at all: Hibernate keeps the changed fields in the persistence context and flushes them at commit, so when the transaction is rolled back instead, no UPDATE was ever sent. The SQL log in the worked session shows exactly that — four statements on the successful call, one on the failing one.

Cancelling an order

Java
@Transactional
public Order cancel(Long orderId) {
    Order order = findById(orderId);
    if (order.getStatus() == OrderStatus.CANCELLED) {
        throw new ConflictException("Order " + orderId + " is already CANCELLED");
    }
    if (order.getStatus() == OrderStatus.PAID) {
        for (OrderItem item : order.getItems()) {
            Product product = item.getProduct();
            product.setStock(product.getStock() + item.getQuantity());
        }
    }
    order.setStatus(OrderStatus.CANCELLED);
    return order;
}

Cancelling is asymmetric on purpose. A NEW order never took stock, so cancelling it must not give any back; a PAID order did, so cancelling it must. Writing one branch for both statuses is how a cancel-and-recreate loop turns into free inventory.

The class is annotated @Transactional(readOnly = true) with the three mutating methods overriding it. Read methods get a read-only transaction and write methods opt in, rather than every method being writable because someone forgot.

The controllers, and the DTO boundary

DTOs in, DTOs out

No entity crosses the HTTP boundary in either direction. Requests are records with constraints on them:

Java
public record ProductRequest(
        @NotBlank String sku,
        @NotBlank String name,
        @NotNull @Min(1) Integer priceCents,
        @NotNull @Min(0) Integer stock) {
}
 
public record CreateOrderRequest(
        @NotNull Long customerId,
        @NotEmpty List<@Valid Line> lines) {
 
    public record Line(@NotBlank String sku, @NotNull @Min(1) Integer quantity) {
    }
}

The nested annotation position in CreateOrderRequest is worth a second look, because the intuitive spelling is wrong now. Writing @Valid List<Line> lines still validates the elements, but Hibernate Validator 9 logs three deprecation warnings the first time such a body is validated — one for each path by which the annotation is reachable:

Text
WARN  o.h.v.i.m.a.CascadingMetaDataBuilder : HV000271: Using `@Valid` on a container
(java.util.List) is deprecated. You should apply the annotation on the type argument(s).
Affected element: CreateOrderRequest#lines()

Moving the annotation inside the type argument — List<@Valid Line> — silences it and says what was actually meant: validate each element, not the list. The @NotEmpty stays on the field, because that one really is about the container.

Responses are records with a static factory that does the mapping:

Java
public record OrderResponse(
        Long id,
        Long customerId,
        String customerName,
        String status,
        Instant createdAt,
        List<OrderItemResponse> items,
        int totalCents) {
 
    public static OrderResponse of(Order order) {
        return new OrderResponse(
                order.getId(),
                order.getCustomer().getId(),
                order.getCustomer().getName(),
                order.getStatus().name(),
                order.getCreatedAt(),
                order.getItems().stream().map(OrderItemResponse::of).toList(),
                order.totalCents());
    }
}

totalCents is computed during mapping. It is in the JSON, so clients never add up the lines themselves, but it is nowhere in the database, so it cannot drift.

201 and its Location header

The controllers are thin enough to read in one pass:

Java
@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 body) {
        Order created = service.create(body);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.getId()))
                .body(OrderResponse.of(created));
    }
 
    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable Long id) {
        return OrderResponse.of(service.findById(id));
    }
 
    @PostMapping("/{id}/pay")
    public OrderResponse pay(@PathVariable Long id) {
        return OrderResponse.of(service.pay(id));
    }
 
    @PostMapping("/{id}/cancel")
    public OrderResponse cancel(@PathVariable Long id) {
        return OrderResponse.of(service.cancel(id));
    }
}

ResponseEntity.created(uri) is what produces both the 201 and the Location header in one call; returning the DTO directly would produce a 200 and no header. pay and cancel are POSTs on sub-resources rather than a PATCH that sets status, because they are not field edits — each one has a precondition and a side effect on a different table.

Fourteen routes, and six distinct outcomes:

The fourteen routes on the left, the Controller-Service-Repository-H2 pipeline in the middle, and six status-code chips on the right wired back to the layer that decides each one

Verb and pathSuccessFailures
POST /api/products201 + Location400 invalid body, 409 duplicate SKU
GET /api/products200
GET /api/products/{id}200404
PUT /api/products/{id}200400, 404, 409
DELETE /api/products/{id}204404, 409 still referenced
POST /api/customers201 + Location400, 409 duplicate email
GET /api/customers/{id}200404
POST /api/orders201 + Location400, 404 unknown customer or SKU, 409 stock
GET /api/orders/{id}200404
POST /api/orders/{id}/pay200404, 409 not NEW, 409 stock
POST /api/orders/{id}/cancel200404, 409 already CANCELLED

The customers resource has the same five routes as products, with email unique instead of sku.

Error handling: one advice, four exceptions

Every non-2xx response in this API comes out of one class:

Java
@RestControllerAdvice
public class ApiExceptionHandler {
 
    @ExceptionHandler(NotFoundException.class)
    ProblemDetail onNotFound(NotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        return problem;
    }
 
    @ExceptionHandler(ConflictException.class)
    ProblemDetail onConflict(ConflictException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Request conflicts with the current state");
        return problem;
    }
 
    @ExceptionHandler(DataIntegrityViolationException.class)
    ProblemDetail onIntegrityViolation(DataIntegrityViolationException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                "The row is still referenced by other rows and cannot be deleted");
        problem.setTitle("Request conflicts with the current state");
        return problem;
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail onInvalid(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
                .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Request body failed validation");
        problem.setTitle("Invalid request");
        problem.setProperty("errors", errors);
        return problem;
    }
}

Two of those handlers put ex.getMessage() in the response and two do not, and the difference is deliberate. NotFoundException and ConflictException are messages this application wrote for a client to read. DataIntegrityViolationException is a message the database wrote, and its getMessage() contains the constraint name, the table names and the SQL statement. That belongs in the log, not in a response body.

The 500 you get without it

Before that third handler existed, deleting a customer who has orders produced this — a real capture from the same application with the handler removed:

Text
$ curl -s -i -X DELETE http://localhost:18096/api/customers/1
HTTP/1.1 500
Content-Type: application/json
 
{"timestamp":"2026-09-10T10:17:21.541Z","status":500,"error":"Internal Server Error","path":"/api/customers/1"}

The client is told the server broke. The server did not break; the client asked for something the data model forbids. The log knows the difference:

Text
WARN  org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 23503, SQLState: 23503
WARN  org.hibernate.orm.jdbc.error : Referential integrity constraint violation:
"FKPXTB8AWMI0DK6SMOH2VP1LITG: PUBLIC.ORDERS FOREIGN KEY(CUSTOMER_ID)
REFERENCES PUBLIC.CUSTOMERS(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from customers where id=? [23503-240]

Adding the handler turns the same request into a 409 with a sentence the client can act on. The general rule that falls out of it: a 500 in your own API is a bug report addressed to you. Every one of them is either a case you have not classified yet or a genuine defect, and the way to find out which is to stop letting unclassified exceptions reach the client.

The worked session

Everything below is one continuous run against a freshly started application. Responses are verbatim; the status line, Location and Content-Type are shown and the remaining headers are trimmed.

Seeding

Text
$ curl -s -i -X POST http://localhost:18096/api/customers \
    -H 'Content-Type: application/json' \
    -d '{"name":"Ada Lovelace","email":"ada@example.com"}'
HTTP/1.1 201
Location: /api/customers/1
Content-Type: application/json
 
{
  "id": 1,
  "name": "Ada Lovelace",
  "email": "ada@example.com"
}
Text
$ curl -s -i -X POST http://localhost:18096/api/products \
    -H 'Content-Type: application/json' \
    -d '{"sku":"KB-01","name":"Mechanical Keyboard","priceCents":8900,"stock":12}'
HTTP/1.1 201
Location: /api/products/1
Content-Type: application/json
 
{
  "id": 1,
  "sku": "KB-01",
  "name": "Mechanical Keyboard",
  "priceCents": 8900,
  "stock": 12
}

A second customer (Grace Hopper, id 2) and two more products follow the same shape: MS-02 at 3450 with stock 5, and HS-03 at 12500 with stock 3.

Validation

An empty SKU, a zero price and a negative stock, all in one body:

Text
$ curl -s -i -X POST http://localhost:18096/api/products \
    -H 'Content-Type: application/json' \
    -d '{"sku":"","name":"Nameless","priceCents":0,"stock":-1}'
HTTP/1.1 400
Content-Type: application/problem+json
 
{
  "detail": "Request body failed validation",
  "instance": "/api/products",
  "status": 400,
  "title": "Invalid request",
  "errors": {
    "sku": "must not be blank",
    "stock": "must be greater than or equal to 0",
    "priceCents": "must be greater than or equal to 1"
  }
}

All three violations in one response, not the first one. The order of the keys is whatever order Bean Validation reported the violations in, and it is not stable between runs — do not build a client that depends on it. The nested case reports its path through the list:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"customerId":1,"lines":[{"sku":"KB-01","quantity":0}]}'
HTTP/1.1 400
Content-Type: application/problem+json
 
{
  "detail": "Request body failed validation",
  "instance": "/api/orders",
  "status": 400,
  "title": "Invalid request",
  "errors": {
    "lines[0].quantity": "must be greater than or equal to 1"
  }
}

lines[0].quantity is the field path Bean Validation produces for an element of a validated collection — which is only there because of the List<@Valid Line> spelling from earlier. And the uniqueness rule, which is a service check rather than a constraint annotation:

Text
$ curl -s -i -X POST http://localhost:18096/api/products \
    -H 'Content-Type: application/json' \
    -d '{"sku":"KB-01","name":"Mechanical Keyboard","priceCents":8900,"stock":1}'
HTTP/1.1 409
Content-Type: application/problem+json
 
{
  "detail": "SKU already exists: KB-01",
  "instance": "/api/products",
  "status": 409,
  "title": "Request conflicts with the current state"
}

400 for a body that is malformed on its face, 409 for a body that is well-formed but conflicts with what already exists. Both are the client's fault; only one of them can be decided without looking at the database.

Placing an order, and the two refusals

The successful order is the transcript at the top of this article: two keyboards and one mouse, 21250 cents, Location: /api/orders/1. Here is what the SQL log recorded while it ran:

SQL
select c1_0.id, c1_0.email, c1_0.name from customers c1_0 where c1_0.id=?
 
select p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock
from products p1_0 where p1_0.sku=?
 
select p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock
from products p1_0 where p1_0.sku=?
 
insert into orders (created_at, customer_id, status, id) values (?, ?, ?, default)
 
insert into order_items (order_id, product_id, quantity, unit_price_cents, id)
values (?, ?, ?, ?, default)
 
insert into order_items (order_id, product_id, quantity, unit_price_cents, id)
values (?, ?, ?, ?, default)

One lookup per SKU, then one insert per row. The two order_items inserts happened because of cascade = ALL; nothing called save on an OrderItem.

Both refusals produce the same shape with different codes:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"customerId":1,"lines":[{"sku":"XX-99","quantity":1}]}'
HTTP/1.1 404
Content-Type: application/problem+json
 
{
  "detail": "No product with sku XX-99",
  "instance": "/api/orders",
  "status": 404,
  "title": "Resource not found"
}
Text
$ curl -s -i -X POST http://localhost:18096/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"customerId":1,"lines":[{"sku":"HS-03","quantity":10}]}'
HTTP/1.1 409
Content-Type: application/problem+json
 
{
  "detail": "Insufficient stock for HS-03: requested 10, available 3",
  "instance": "/api/orders",
  "status": 409,
  "title": "Request conflicts with the current state"
}

One more shape worth exercising, because a client will eventually send it: the same SKU twice in one request.

Text
$ curl -s -i -X POST http://localhost:18096/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"customerId":2,"lines":[{"sku":"MS-02","quantity":1},{"sku":"KB-01","quantity":1},{"sku":"MS-02","quantity":2}]}'
HTTP/1.1 201
Location: /api/orders/4
Content-Type: application/json
 
{
  "id": 4,
  "customerId": 2,
  "customerName": "Grace Hopper",
  "status": "NEW",
  "createdAt": "2026-09-10T10:12:23.055038Z",
  "items": [
    {
      "sku": "MS-02",
      "name": "Wireless Mouse",
      "quantity": 3,
      "unitPriceCents": 3450,
      "lineTotalCents": 10350
    },
    {
      "sku": "KB-01",
      "name": "Mechanical Keyboard",
      "quantity": 1,
      "unitPriceCents": 10900,
      "lineTotalCents": 10900
    }
  ],
  "totalCents": 21250
}

Three request lines, two order items. mergeLines summed the two MS-02 lines into a quantity of 3 and kept MS-02 first because that is where the client first mentioned it — the LinkedHashMap earning its name over a HashMap.

Note that the 404 is on POST /api/orders and not on the SKU's own URL. The resource being created does not exist yet; what is missing is something the body referred to. The status describes the referent, and the detail says which one — without it, a client seeing 404 on a POST has no way to know whether the customer or the product was the problem.

A price change that does not change an order

Order 1 exists, with KB-01 at 8900. Raise the price by 2000:

Text
$ curl -s -i -X PUT http://localhost:18096/api/products/1 \
    -H 'Content-Type: application/json' \
    -d '{"sku":"KB-01","name":"Mechanical Keyboard","priceCents":10900,"stock":12}'
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "sku": "KB-01",
  "name": "Mechanical Keyboard",
  "priceCents": 10900,
  "stock": 12
}

The product is now 10900. Read the order back:

Text
$ curl -s -i http://localhost:18096/api/orders/1
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "customerId": 1,
  "customerName": "Ada Lovelace",
  "status": "NEW",
  "createdAt": "2026-09-10T10:12:22.531605Z",
  "items": [
    {
      "sku": "KB-01",
      "name": "Mechanical Keyboard",
      "quantity": 2,
      "unitPriceCents": 8900,
      "lineTotalCents": 17800
    },
    {
      "sku": "MS-02",
      "name": "Wireless Mouse",
      "quantity": 1,
      "unitPriceCents": 3450,
      "lineTotalCents": 3450
    }
  ],
  "totalCents": 21250
}

unitPriceCents is still 8900 and totalCents is still 21250, while the product it points at says 10900. That is the copied field earning its column. Note also what did follow the change: name in the order line is read live from the product, so a rename would show up there. Only the price is frozen, because only the price is money.

An order placed after the change picks up the new price — order 2 below is billed 10900 for the same SKU.

Paying, and paying twice

Text
$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "customerId": 1,
  "customerName": "Ada Lovelace",
  "status": "PAID",
  "createdAt": "2026-09-10T10:12:22.531605Z",
  "items": [ ... ],
  "totalCents": 21250
}

Four statements went out: the join-fetch select, then one update products per item and one update orders for the status.

SQL
update products set name=?, price_cents=?, sku=?, stock=? where id=?
 
update orders set created_at=?, customer_id=?, status=? where id=?
 
update products set name=?, price_cents=?, sku=?, stock=? where id=?

Hibernate updates every column of a dirty row by default, not only the changed one, which is why name and sku appear in a statement that only meant to change stock. That is fine here and configurable when it is not.

Paying again is refused:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 409
Content-Type: application/problem+json
 
{
  "detail": "Order 1 is PAID, only a NEW order can be paid",
  "instance": "/api/orders/1/pay",
  "status": 409,
  "title": "Request conflicts with the current state"
}

The rollback, proved with SQL

The setup: order 2 belongs to Grace Hopper and has two lines, KB-01 x1 and HS-03 x3. Both were in stock when it was placed. Then order 3 is placed for HS-03 x3 and paid, which takes that product's stock to zero. Order 2 is now unpayable — but only on its second line.

Stock before:

Text
$ curl -s -i http://localhost:18096/api/products/1
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "sku": "KB-01",
  "name": "Mechanical Keyboard",
  "priceCents": 10900,
  "stock": 10
}

Pay order 2:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders/2/pay
HTTP/1.1 409
Content-Type: application/problem+json
 
{
  "detail": "Insufficient stock for HS-03: requested 3, available 0",
  "instance": "/api/orders/2/pay",
  "status": 409,
  "title": "Request conflicts with the current state"
}

The loop got as far as decrementing KB-01 from 10 to 9 before it threw on HS-03. Stock after:

Text
$ curl -s -i http://localhost:18096/api/products/1
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 1,
  "sku": "KB-01",
  "name": "Mechanical Keyboard",
  "priceCents": 10900,
  "stock": 10
}

Still 10. And the order itself:

Text
$ curl -s -i http://localhost:18096/api/orders/2
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 2,
  "customerId": 2,
  "customerName": "Grace Hopper",
  "status": "NEW",
  ...
  "totalCents": 48400
}

Still NEW. The interesting evidence is in the SQL log. Counting the statements Hibernate sent between the two curl calls:

CallStatements
POST /api/orders/1/pay, committed4 — one select, three updates
POST /api/orders/2/pay, rolled back1 — the select, and nothing else

Not one UPDATE was sent on the failing call. The decrement of KB-01 existed only as a changed field on a managed entity inside the persistence context; the exception propagated out of the @Transactional method, the transaction was rolled back, and the persistence context was discarded before it ever flushed. There was no partial write to undo, because there was no partial write.

That is worth being precise about, because it is easy to draw the wrong lesson. The transaction is still what makes the method correct — with a REQUIRES_NEW per item, or with an explicit flush() in the loop, the first decrement would have hit the database and the rollback would have had real work to do. What the log shows is not that transactions are unnecessary here; it is that this one had a cheap job.

Cancelling restores stock

Order 3 is PAID and took HS-03 down to zero. Cancel it:

Text
$ curl -s -i -X POST http://localhost:18096/api/orders/3/cancel
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 3,
  "customerId": 1,
  "customerName": "Ada Lovelace",
  "status": "CANCELLED",
  "createdAt": "2026-09-10T10:12:22.844213Z",
  "items": [
    {
      "sku": "HS-03",
      "name": "Headset",
      "quantity": 3,
      "unitPriceCents": 12500,
      "lineTotalCents": 37500
    }
  ],
  "totalCents": 37500
}
Text
$ curl -s -i http://localhost:18096/api/products/3
HTTP/1.1 200
Content-Type: application/json
 
{
  "id": 3,
  "sku": "HS-03",
  "name": "Headset",
  "priceCents": 12500,
  "stock": 3
}

Back to 3. Cancelling again is refused with a 409, and cancelling order 2 — which is NEW and never took stock — moves no stock at all: KB-01 stays at 10 afterwards. That asymmetry is the branch in cancel, and it is the one place in this code where doing the obvious thing to both statuses would have created inventory out of nothing.

Deleting, and the foreign key that says no

A product nothing has ordered deletes cleanly:

Text
$ curl -s -i -X DELETE http://localhost:18096/api/products/4
HTTP/1.1 204
Text
$ curl -s -i http://localhost:18096/api/products/4
HTTP/1.1 404
Content-Type: application/problem+json
 
{
  "detail": "No product with id 4",
  "instance": "/api/products/4",
  "status": 404,
  "title": "Resource not found"
}

204 with no body at all, then 404 on the same URL. A customer with orders does not:

Text
$ curl -s -i -X DELETE http://localhost:18096/api/customers/1
HTTP/1.1 409
Content-Type: application/problem+json
 
{
  "detail": "The row is still referenced by other rows and cannot be deleted",
  "instance": "/api/customers/1",
  "status": 409,
  "title": "Request conflicts with the current state"
}

The foreign key refused it and the handler classified it. Note what is not in the response: no constraint name, no table names, no SQL. Those are in the log.

Honest limits

This is a complete application, not a production one. The gaps worth naming:

GapWhat it would take
No stock reservationPlacing an order only checks stock; two orders for the last unit both succeed and the second pay fails. Reserving would need a held quantity per product and an expiry
No optimistic lockingTwo concurrent pays on different orders for the same product can interleave. A @Version column on Product turns that into a retryable failure instead of a lost update
create-drop on an in-memory databaseEverything is gone at shutdown. Real deployments use validate and Flyway or Liquibase
No authenticationEvery route is open. Nothing here knows who is calling
No paging on the list endpointsGET /api/products returns everything. Pageable is the fix and it is a one-line change to the signature
No tests yetThe next article writes them

Each of those is a deliberate omission with a known fix, which is a different thing from an oversight.

FAQ

Why store money as an int of cents instead of BigDecimal?

Because the domain has no fractional cents. An int of minor units is exact, adds exactly, serialises as a plain JSON number and cannot pick up a rounding mode by accident. BigDecimal is the right answer when you need scale, currency conversion or division; double is never the right answer for money.

Why does the order copy the product price instead of reading it?

So that a record of a past event does not change when present data changes. Raising a product's price would otherwise rewrite the total of every order ever placed for it. This article demonstrates it: after the price goes from 8900 to 10900, the existing order still reports unitPriceCents 8900 and totalCents 21250.

Should the service or the controller decide the HTTP status code?

Neither, directly. The service throws a domain exception that knows nothing about HTTP, and one @RestControllerAdvice maps exception types to status codes in a single place. That keeps the rules testable without a web layer and keeps the status decision from being duplicated in every controller method.

What is the difference between 400 and 409 here?

400 means the body is invalid on its own terms — a blank SKU, a quantity of zero — and Bean Validation can decide it without touching the database. 409 means the body is well-formed but conflicts with existing state: a duplicate SKU, insufficient stock, an order that is not NEW. Deciding a 409 always requires a read.

Why is stock decremented on pay rather than on order creation?

Because an unpaid order is not a commitment. Decrementing at creation would let anyone empty your inventory by placing orders they never pay for, and would require a release path for every abandoned one. The cost of the choice is honest and stated above: placing an order does not reserve anything.

Does the failed payment really roll back, if no UPDATE was sent?

Yes, and the two facts are compatible. The decrement happened to a managed entity in the persistence context, which Hibernate flushes at commit. The exception meant there was no commit, so the changes were discarded before they became SQL. Roll back a transaction that has flushed — because you called flush(), or because a query forced one — and the database really does undo written rows.

Why does the pay UPDATE include name and sku when only stock changed?

Hibernate generates one UPDATE per dirty entity covering every mapped column, rather than a statement per changed field, so that it can reuse a single prepared statement. It is configurable per entity when a table is wide enough for it to matter, and it is not worth changing here.

Do I need spring-boot-starter-web or spring-boot-starter-webmvc on Boot 4?

spring-boot-starter-webmvc. On Spring Boot 4 the Initializr web dependency generates that artifact, and the old spring-boot-starter-web POM describes itself as deprecated in favour of it. If you are copying a pom.xml fragment from a Boot 3 tutorial, this is the line to change.

Conclusion

Nothing in this project is clever. Four entities with the owning side chosen deliberately, three repository interfaces with one join-fetch query between them, services that hold every rule and throw two exception types, controllers that map DTOs and set a Location header, and one advice class that turns exceptions into status codes. What makes it work is that each of those decisions was made once, in one place, rather than repeated in fourteen route handlers.

The two ideas most worth taking away are the ones this article proved by running rather than asserting: a record of a past event must copy the values it depends on, or a later edit will rewrite history; and a transaction that spans a whole business operation means a rule violated on the last line leaves nothing behind from the first.

Article 37 finishes both the project and the course. It applies everything Part 6 taught about testing to this exact codebase — unit tests for the services, slice tests for the controllers, an integration test for the whole stack — adds stream-based reporting over the order data, and closes with a retrospective mapping each decision here back to the article that argued for it.

Related Posts

[Advanced Java] Building a REST API with Spring Boot

A REST API on Spring Boot 4.1.1 and Java 21: how DispatcherServlet routes a request, the mapping annotations, ResponseEntity and status codes, jakarta.validation with @Valid, RFC 9457 ProblemDetail error bodies, content negotiation, and a @WebMvcTest run — every response captured from a running application.

[Advanced Java] Connecting Spring Boot to a Database with Spring Data JPA

Spring Data JPA on Spring Boot 4.1.1 and Hibernate 7.4.5: how JDBC, JPA, Hibernate and repositories stack, entity mapping and the generated DDL, derived query methods, the N+1 problem counted in real SQL logs, LazyInitializationException, proxy-based @Transactional, dirty checking and flush versus commit.

[Advanced Java] Buffered Streams and Object Serialization in Java

Advanced java.io on OpenJDK 21: the four abstract stream roots and the exact place a charset is chosen, the decorator chain and why its order matters, buffering measured as call counts rather than milliseconds, DataOutputStream and its big-endian layout, and object serialization end to end — the real byte format, transient, serialVersionUID, writeObject, Externalizable, object graphs, and the ObjectInputFilter that exists because the format is unsafe.

[Advanced Java] Unit Testing in Java with JUnit 5

Unit testing in Java with JUnit 5.11.3 on OpenJDK 21: the Platform, Jupiter and Vintage split, the lifecycle callbacks, one new test instance per method, real assertion failure messages, assertThrows and assertAll, DisplayName, Nested, Disabled and Tag, parameterized tests with every argument source, assumptions versus assertions, and the habits that make a test worthless.