Testing Domain Logic
Plain JUnit tests for invariants, money and the Order lifecycle.
You are viewing a free preview lesson.
In the previous lesson, we established our testing strategy: Domain behaviour gets domain tests, use case orchestration gets use case tests, persistence gets PostgreSQL integration tests, HTTP contracts get API tests, and concurrency gets real concurrent integration tests. Now we begin with the smallest and fastest layer.
Our domain contains business rules that can be evaluated entirely in memory. A Product price must not be negative; Inventory must not become negative; an OrderItem quantity must be positive and its unitPriceCents non-negative; an Order must contain valid items and derive its total correctly; an UNPAID Order can become PAID or CANCELLED, while a PAID Order cannot be cancelled and a CANCELLED one cannot be paid. None of these rules requires Spring Boot, PostgreSQL, JPA, HTTP, a JWT or the payment provider. The goal of this lesson is:
Test the domain directly, using plain Java and JUnit, so business invariants and state transitions are protected by fast, deterministic tests that clearly document the behavior of the model.
What Are We Testing?
Our domain has a few classes with clearly different responsibilities, and the tests follow them:
| Class | Responsibility |
|---|---|
Product | Current Product validity and lifecycle |
Inventory | Valid local quantity state |
OrderItem | Purchase-time quantity and price |
Order (with OrderStatus) | Ownership, items, total, lifecycle |
ProductId, OrderId, CustomerId | Valid identity representation |
We are not starting from zero. ProductTest (4.04, with the creation tests from 7.02), OrderTest (4.06) and OrderLifecycleTest (7.11) already exist. This lesson reviews what they protect, fills the gaps, and adds the test classes the domain still lacks: InventoryTest, OrderItemTest and CustomerIdTest.
Test the Rule Where It Lives
Suppose we want to prove that a PAID Order cannot be cancelled. The best test is the one OrderLifecycleTest already contains:
@Test
void paidOrderCannotBeCancelled() {
Order order = createOrder();
order.markPaid();
assertThrows(OrderNotCancellable.class, order::cancel);
assertEquals(OrderStatus.PAID, order.status());
}
It says exactly what the business rule is, with no POST /api/v1/orders/{id}/cancel involved. The HTTP test later proves the API mapping, OrderNotCancellable → 409 ORDER_NOT_CANCELLABLE: a different layer and a different guarantee.
No Spring, Repositories, HTTP or Mocks
A Domain test should normally not use @SpringBootTest, @Autowired or @MockitoBean. A test that creates an Order and calls order.cancel() runs as ordinary Java, which is one reason we deliberately kept Order, Product, Inventory and OrderItem framework-light. A productRepository.findById(...) inside a total test is a warning sign: once an OrderItem exists, it already holds the productId, quantity and unitPriceCents, so order.totalCents() is pure domain behaviour. Nor should Order.cancel() be tested only through POST /api/v1/orders/{id}/cancel, and mock(Order.class) replaces exactly the behaviour under test (10.11 shows why).
Where Domain Tests Live
Tests mirror the production capability structure, which keeps test ownership clear:
src/test/java/
└── com/tarkev/ordermanagement/
├── product/
│ └── domain/
│ └── ProductTest.java
├── inventory/
│ └── domain/
│ └── InventoryTest.java
└── order/
└── domain/
├── CustomerIdTest.java
├── OrderItemTest.java
├── OrderLifecycleTest.java
└── OrderTest.java
Testing Product
Product owns its id, name, priceCents and active state, and changes them through rename(...), changePrice(...) and deactivate(). The important money invariant is priceCents >= 0, and ProductTest already protects it: a new Product starts active and keeps its price in cents (8990, not 89.90: the domain uses integer minor units), a zero price is valid, a negative price is rejected, the price can change, and a blank name is rejected at creation.
The zero-price test matters more than it looks. Our invariant is priceCents >= 0, not priceCents > 0, so a "Free Sample" at 0 is valid. Do not invent a minimum price in the test suite: tests protect requirements, they do not create them.
What ProductTest does not yet check is the state after a rejected mutation. These two tests go into ProductTest:
@Test
void rejectedPriceChangeKeepsPreviousPrice() {
Product product = Product.create(ProductId.newId(), "Keyboard", 8990);
assertThrows(IllegalArgumentException.class,
() -> product.changePrice(-1));
assertEquals(8990, product.priceCents());
}
@Test
void rejectedRenameKeepsPreviousName() {
Product product = Product.create(ProductId.newId(), "Keyboard", 8990);
assertThrows(IllegalArgumentException.class,
() -> product.rename(" "));
assertEquals("Keyboard", product.name());
}
The second assertion in each test matters: the failed operation must not leave priceCents = -1 or a blank name behind. This is a general domain-testing principle: when a valid object receives an invalid operation, test both the exception and that the state remains valid. Do not add normalization assertions such as " Keyboard " → "Keyboard" either, unless trimming is an agreed Product rule.
Deactivation Is Not Cancellation
repeatedDeactivationIsHarmless calls product.deactivate() twice and expects the Product to stay inactive, with no exception. Order cancellation is intentionally different: cancelling twice is invalid. The tests preserve that semantic difference. Do not generalize "all state-changing operations must behave the same" and copy Order semantics into Product tests.
Testing Inventory
Inventory owns a ProductId and an availableQuantity, protects availableQuantity >= 0, and changes through decrease(...), increase(...) and setAvailableQuantity(...) (4.01). No test class covers it yet:
package com.tarkev.ordermanagement.inventory.domain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.tarkev.ordermanagement.product.domain.ProductId;
import org.junit.jupiter.api.Test;
class InventoryTest {
private static Inventory inventoryWith(int quantity) {
return Inventory.create(ProductId.newId(), quantity);
}
@Test
void zeroQuantityIsValid() {
assertEquals(0, inventoryWith(0).availableQuantity());
}
@Test
void negativeQuantityIsRejected() {
assertThrows(IllegalArgumentException.class,
() -> inventoryWith(-1));
}
@Test
void decreasesAvailableQuantity() {
Inventory inventory = inventoryWith(10);
inventory.decrease(3);
assertEquals(7, inventory.availableQuantity());
}
@Test
void cannotDecreaseBelowZero() {
Inventory inventory = inventoryWith(2);
assertThrows(InsufficientInventory.class,
() -> inventory.decrease(3));
assertEquals(2, inventory.availableQuantity());
}
@Test
void amountsMustBePositive() {
Inventory inventory = inventoryWith(5);
assertThrows(IllegalArgumentException.class,
() -> inventory.decrease(0));
assertThrows(IllegalArgumentException.class,
() -> inventory.increase(-2));
assertEquals(5, inventory.availableQuantity());
}
@Test
void increasesAvailableQuantity() {
Inventory inventory = inventoryWith(5);
inventory.increase(2);
assertEquals(7, inventory.availableQuantity());
}
@Test
void setsAvailableQuantity() {
Inventory inventory = inventoryWith(5);
inventory.setAvailableQuantity(20);
assertEquals(20, inventory.availableQuantity());
}
@Test
void cannotSetNegativeQuantity() {
Inventory inventory = inventoryWith(5);
assertThrows(IllegalArgumentException.class,
() -> inventory.setAvailableQuantity(-1));
assertEquals(5, inventory.availableQuantity());
}
}
Zero means "no units available", not "invalid Inventory". Notice the two kinds of failure. Asking for more than is available is a business outcome, InsufficientInventory; a non-positive amount or a negative quantity is a programming error, IllegalArgumentException. Either way, the failure preserves the previous valid state.
These tests prove that one loaded Inventory object cannot become negative. They do not prove that two concurrent Orders cannot oversell: the Order workflow consumes stock through the conditional UPDATE from 7.08, and that guarantee gets its own concurrency test in 10.10.
Testing OrderItem
OrderItem (4.06) records purchase-time line information: the ProductId, the quantity and the unitPriceCents. It protects quantity > 0 and unitPriceCents >= 0, and calculates its totalCents() with Math.multiplyExact:
package com.tarkev.ordermanagement.order.domain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.tarkev.ordermanagement.product.domain.ProductId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class OrderItemTest {
private static final ProductId PRODUCT = ProductId.newId();
@Test
void calculatesLineTotal() {
assertEquals(17980, new OrderItem(PRODUCT, 2, 8990).totalCents());
}
@Test
void zeroUnitPriceIsValid() {
assertEquals(0, new OrderItem(PRODUCT, 5, 0).totalCents());
}
@ParameterizedTest
@ValueSource(ints = {0, -1})
void quantityMustBePositive(int quantity) {
assertThrows(IllegalArgumentException.class,
() -> new OrderItem(PRODUCT, quantity, 8990));
}
@Test
void negativeUnitPriceIsRejected() {
assertThrows(IllegalArgumentException.class,
() -> new OrderItem(PRODUCT, 1, -1));
}
@Test
void lineTotalFailsInsteadOfOverflowing() {
OrderItem item = new OrderItem(PRODUCT, 2, Long.MAX_VALUE);
assertThrows(ArithmeticException.class, item::totalCents);
}
}
8990 × 2 = 17980: no rounding, no decimal arithmetic, exact cents. A zero unit price is valid for the same reason a zero Product price is. The overflow test protects a technical correctness property, that wrong money is never silently produced; do not turn it into an invented business limit such as "maximum quantity = 100" unless the requirements define one.
Testing Order Construction and Totals
Order is the richest domain object in the project. It protects its OrderId and CustomerId, non-empty items with unique Product lines, the historical item data, a derived total and its lifecycle. OrderTest from 4.06 already proves that an Order needs at least one item, rejects duplicate Product lines, starts UNPAID and calculates its total: 2 × 1000 + 1 × 500 = 2500.
Why does Order reject duplicates when CreateOrderUseCase already does? The use case rejects them to fail before any side effect; the domain rejects them so that an invalid Order can never exist. Different protection, so both are tested.
The total test also documents a design decision. It does not call order.setTotalCents(...), because there is no independent mutable total: the OrderItems are the source of truth, so the test creates items and checks the result. That protects our design against duplicated monetary state.
Four guarantees are still missing. A small helper keeps them short; these additions go into OrderTest, which already defines the CUSTOMER and CREATED_AT constants:
private static Order orderWith(OrderItem... items) {
return Order.create(OrderId.newId(), CUSTOMER, List.of(items), CREATED_AT);
}
@Test
void zeroPricedItemsGiveZeroTotal() {
Order order = orderWith(new OrderItem(ProductId.newId(), 5, 0));
assertEquals(0, order.totalCents());
}
@Test
void totalFailsInsteadOfOverflowing() {
Order order = orderWith(
new OrderItem(ProductId.newId(), 1, Long.MAX_VALUE),
new OrderItem(ProductId.newId(), 1, 1));
assertThrows(ArithmeticException.class, order::totalCents);
}
@Test
void callerCannotChangeItemsAfterCreation() {
List<OrderItem> items = new ArrayList<>(
List.of(new OrderItem(ProductId.newId(), 1, 1000)));
Order order = Order.create(OrderId.newId(), CUSTOMER, items, CREATED_AT);
items.clear();
assertEquals(1, order.items().size());
assertThrows(UnsupportedOperationException.class,
() -> order.items().clear());
}
@Test
void orderBelongsOnlyToItsCustomer() {
Order order = orderWith(new OrderItem(ProductId.newId(), 1, 1000));
assertTrue(order.belongsTo(CUSTOMER));
assertFalse(order.belongsTo(new CustomerId("customer-2")));
}
The overflow test protects Math.addExact in totalCents(), and the items test protects the List.copyOf that keeps an Order from becoming empty from outside. orderBelongsOnlyToItsCustomer proves only that the Order retains its owner's identity, not that security works: whether the authenticated caller may use that Order is tested in 10.03 and 10.08.
Testing the Order Lifecycle
The lifecycle matrix has six rows, and every row deserves protection:
| Current state | Operation | Result |
|---|---|---|
UNPAID | Pay | PAID |
UNPAID | Cancel | CANCELLED |
PAID | Pay | ORDER_NOT_PAYABLE |
PAID | Cancel | ORDER_NOT_CANCELLABLE |
CANCELLED | Pay | ORDER_NOT_PAYABLE |
CANCELLED | Cancel | ORDER_NOT_CANCELLABLE |
OrderLifecycleTest covers all six, plus the UNPAID start and reconstitution, so we do not rewrite it. Two rows deserve a closer look:
PAIDcannot be paid again protects local lifecycle correctness. It does not prove that the external payment provider cannot charge twice; duplicate-charge protection belongs to the payment idempotency tests.CANCELLEDcannot be cancelled again matters because application-level cancellation also restores Inventory. A repeated cancellation is not harmless repetition.
The state-after-rejection rule from Product applies here too: paidOrderCannotBeCancelled and cancelledOrderCannotBeMarkedPaid assert both the exception and the unchanged status, but the two "again" tests in 7.11 assert only the exception, so add the status check to them as well.
Testing Reconstitution
reconstitutionPreservesStoredStatus makes sure that loading a PAID Order through Order.reconstitute(...) never silently resets it to UNPAID. Reconstitution is not a backdoor mutation API, though: do not write tests, or code, that reconstitute a current Order with another status and treat the result as a transition. Lifecycle changes go through markPaid() and cancel().
Testing Identity Values
Our external customer identity is a stable, non-blank String, owned by the identity provider. It is not assumed to be a UUID, so a test like "CustomerId must be a UUID" would invent a requirement. CustomerIdTest protects what our application does require:
package com.tarkev.ordermanagement.order.domain;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
class CustomerIdTest {
@Test
void acceptsAnyStableNonBlankSubject() {
CustomerId customerId = new CustomerId("auth0|customer-123");
assertEquals("auth0|customer-123", customerId.value());
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void rejectsMissingSubject(String value) {
assertThrows(IllegalArgumentException.class,
() -> new CustomerId(value));
}
}
ProductId and OrderId are application-generated UUIDs in records. Their equality, hashCode and accessors come from Java: two ProductIds with the same UUID are equal because records work that way. Do not write tests that UUID.toString() returns a String or that a record accessor returns its value; the only custom behaviour, the null check, is cheap to test if you want it. The same applies throughout the domain: test business rules, not syntax.
Helpers and Fixed Values
Small helpers such as inventoryWith(...), orderWith(...) and 7.11's createOrder() keep each test focused, and explicit values (price 8990, quantity 2, expected 17980, a fixed Instant.parse("2026-09-30T12:00:00Z") for createdAt) keep it readable. 10.12 returns to fixtures and deterministic data for the whole suite.
What Domain Tests Do Not Prove
Each of these guarantees belongs to another layer:
| Guarantee | Where it is tested |
|---|---|
| A Product row persists | Repository integration |
The CHECK constraint rejects a negative price | Database integration |
A negative price over HTTP returns 400 | API test |
| An administrator can change a Product price | Security/API integration |
| Two concurrent Inventory consumers cannot oversell | Concurrency integration |
Some invariants exist in several places without making a test redundant. priceCents >= 0 is protected by Product and by a PostgreSQL CHECK: the domain test proves that application objects cannot enter an invalid state, the database test that invalid stored state is rejected. Defence in depth. Quantity appears in HTTP validation (quantity <= 0 → 400) and in OrderItem (quantity > 0). HTTP validation gives clients good feedback; domain validation guarantees that an invalid OrderItem cannot exist, even when another application path, a test fixture or a future background process constructs it.
Test an invariant at its owner. "The Order total is correct" belongs in OrderTest, not only in CreateOrderUseCaseTest, because the calculation belongs to the domain; "a Product price cannot be negative" belongs in ProductTest, not only in UpdateProductUseCaseTest. Tests should reveal responsibility.
The same goes for side effects the domain does not own. Order.markPaid() does not call a provider (PayOrderUseCase calls PaymentService and only then order.markPaid()), so markingPaidCallsProvider does not belong in OrderTest. And order.cancel() does not restore Inventory: CancelOrderUseCase coordinates it with InventoryRepository.restore(...), so use case and integration tests check the restoration. Test responsibility follows code responsibility.
Parameters and Exceptions
Read the test names together (newOrderStartsUnpaid, paidOrderCannotBeCancelled, productCanHaveZeroPrice, lineTotalFailsInsteadOfOverflowing) and a new engineer can understand much of the business model from them.
Parameterized tests help when the behaviour is truly the same across inputs. In OrderItemTest, 0 and -1 break the same rule, "quantity must be positive", so one @ParameterizedTest covers both; CustomerIdTest does the same for null, empty and blank values. But do not merge conceptually different rules merely to reduce line count: readable tests matter more than clever compression.
At the domain layer, the important contract is usually the exception type plus state preservation. assertThrows(OrderNotPayable.class, order::markPaid) is stronger than coupling the test to a message such as "Only unpaid orders can be paid", unless that message has its own requirement. Public error wording belongs to API tests. For the same reason, OrderTest never asserts 409, ORDER_NOT_PAYABLE or https://api.tarkev.com/problems/order-not-payable. The domain does not know them, and there is no response at the domain layer.
A Fast Domain Suite
With the additions above, the domain suite has six test classes: ProductTest, InventoryTest, OrderItemTest, OrderTest, OrderLifecycleTest and CustomerIdTest. Run together against the course's real classes on JUnit 6, its 42 tests finished in under a tenth of a second. That is fast enough to run constantly, edit, run, edit, run, without waiting for containers, a Spring context or the network. And if paidOrderCannotBeCancelled fails, it means "the Order lifecycle changed", never "Docker is unavailable".
Test Through the Public Domain API
Tests should use domain behaviour the way production code does: order.markPaid(), not setPrivateField(order, "status", PAID); product.changePrice(...), not reflection. What happens with invalid database values is a persistence-test question. If the domain prevents an invalid object from existing, good: test the database safeguards separately, and never add an Order.unsafeCreateForTest(...) to production code.
Good domain tests survive refactoring. Changing an if statement into a switch inside Order.markPaid() leaves the public behaviour unchanged, so the tests stay unchanged too. If every refactoring breaks dozens of tests while behaviour stays the same, the tests are too coupled to the implementation.
Practice Exercise 1: Close the Lifecycle Matrix
Solutions: lesson 10.14.
Two of the four rejected transitions in OrderLifecycleTest, paidOrderCannotBeMarkedPaidAgain and cancelledOrderCannotBeCancelledAgain, still assert only the exception. Add the status check to both, so that every rejected row of the lifecycle table verifies the exception and the unchanged status, and run the class: all eight tests pass. Then say what kind of bug the two new assertions catch that the old versions would miss.
Practice Exercise 2: Exact or Fail at the Edges
Money and stock are exact or fail, never silently wrong. First check which existing tests already cover each boundary: Product priceCents 0 and -1, OrderItem unitPriceCents 0 and -1, quantities 1 and 0, an overflowing line total and an overflowing Order total. The suite tests the failing side of each overflow, but not the value just inside it. Write M10EdgeValuesTest in order.domain, a plain JUnit test, that proves:
- One unit at
Long.MAX_VALUEcents has exactly that line total. - An Order with items at
Long.MAX_VALUE - 1and1cents totals exactlyLong.MAX_VALUE: the overflow check must not fire one cent early. - One Inventory object, starting at
10, reads7afterdecrease(3),9afterincrease(2)and0aftersetAvailableQuantity(0); thendecrease(1)andsetAvailableQuantity(-1)are rejected and it stays0. - Inventory at
Integer.MAX_VALUE - 1acceptsincrease(1); a secondincrease(1)fails withArithmeticExceptionand leavesInteger.MAX_VALUE.
Then answer: which of these tests proves that two concurrent Orders cannot oversell?
Practice Exercise 3: Which Layer Owns It?
Decide where each test belongs, and give the reason in one sentence:
- An Order cannot become
PAIDafter cancellation. - A customer cannot pay another customer's Order.
- The Inventory update rolls back when saving the Order fails.
POST /api/v1/orders/{id}/payreturns409for aCANCELLEDOrder.- A deactivated Product keeps its name and price.
- A declined payment leaves the Order
UNPAIDand its attemptDECLINED.
Knowledge Check
Question 1
Why does a test of a rejected changePrice(-1) assert the price afterwards, not only the exception?
Question 2
Why is a Product price of 0 tested as valid instead of being left untested?
Question 3
Why does OrderTest never assert 409 or ORDER_NOT_CANCELLABLE?
Question 4
priceCents >= 0 is protected by Product and by a PostgreSQL CHECK. Why is testing both not redundant?
Question 5
Every refactoring of Order.markPaid() breaks a dozen tests although its behaviour stays the same. What does that say about the tests?
Knowledge Check Answers
Answer 1
A rejected operation must leave a valid object. An implementation that assigns the value before validating would throw correctly and still leave priceCents = -1 behind.
Answer 2
The requirement is priceCents >= 0, and a free sample is a real case. The test pins the boundary down, so nobody can tighten it to > 0 without a failing test and a conversation.
Answer 3
The domain knows OrderNotCancellable, not HTTP. The status code and the Problem Details code are the API layer's translation, tested by an API test.
Answer 4
They guard different paths. The domain test proves that application objects cannot hold a negative price; the database test proves that stored data is rejected even when it comes from manual SQL, a bug in persistence code or a future migration.
Answer 5
They test implementation steps instead of observable behaviour. Good domain tests call the public methods and check the resulting state, so a refactoring that keeps the behaviour keeps them green.
Engineering Principle
Domain tests should exercise business behavior directly, without Spring or infrastructure. If a rule can be proven by constructing an object and calling its methods, that is usually the best place to test it.
A rejected domain operation should not only throw the expected error; the object should remain in its previous valid state.
Summary
- Domain tests are plain Java and JUnit, with no Spring, PostgreSQL, HTTP or mocked domain objects; they extend the existing
ProductTest,OrderTestandOrderLifecycleTest. - Product, Inventory, OrderItem and Order tests protect their invariants (
priceCents >= 0,availableQuantity >= 0,quantity > 0, non-empty unique items), and zero prices and zero Inventory stay valid; tests must not invent limits. - A rejected mutation is tested twice: the expected exception and the unchanged previous state.
- Money is exact or fails:
multiplyExactandaddExactthrowArithmeticExceptionrather than silently overflowing, and the Order total is derived from its items, never set. - Every row of the lifecycle matrix is tested;
PAIDandCANCELLEDare terminal, and reconstitution preserves the stored status without becoming a mutation API. CustomerIdis a stable non-blank string, not a UUID; record-generated behaviour ofProductIdandOrderIdneeds no tests of its own.- Small helpers, fixed values and instants, and behaviour names make the suite documentation that runs in well under a second.
- Domain tests do not prove persistence, constraints, HTTP mapping, authorization, provider calls, Inventory restoration or concurrency; the same invariant can be tested at several boundaries when each protects something different.
In the next lesson, Testing Use Cases with Test Doubles, we move one level outward to the use cases, from CreateProductUseCase to PayOrderUseCase, using fakes, stubs and mocks to prove validation order, boundary interactions, ownership, side effects that must not happen after a failure, server-authoritative pricing and PaymentService interaction, without booting Spring or PostgreSQL.
Think first
Coming soonCheckpoint questions that ask you to commit to an answer before the lesson shows it.