Capstone: An Order Desk
Three order-desk operations, built from named paths, a checked PATCH, a filtered discount and a sealed state.
- Name each path once, as a constant, and reuse it across methods
- Accept an order only when every price passes, then discount its bulk lines
- Amend an order from a sparse request, and report every bad field it sent
- Move a sealed state to another variant only when it is the one you expect
The code on this page is OrderDesk.java and its OrderDeskTest.java: the page includes them, so the build compiles and runs them.
The order service has a desk that takes orders in, amends them when a customer asks, and sends goods out. Each operation is a few lines, because the paths do the navigating. The records are the chapter's cast: an Order placed by a Customer, with LineItems, and a Consignment whose state is a sealed ConsignmentState.
Name the paths once
A path is a value, so the desk builds each one once and names it for what it reaches:
// Paths are values: build each one once, name it, and every method below reuses it
static final FocusPath<Order, String> EMAIL = OrderFocus.customer().email().value();
static final FocusPath<Order, OrderStatus> STATUS = OrderFocus.status();
static final TraversalPath<Order, BigDecimal> PRICES =
OrderFocus.lines().via(LineItemFocus.price());
static final TraversalPath<Order, BigDecimal> BULK_PRICES =
OrderFocus.lines().filter(line -> line.quantity() >= 4).via(LineItemFocus.price());
static final AffinePath<Consignment, ConsignmentState.Pending> PENDING =
ConsignmentFocus.state().via(ConsignmentStatePrisms.pending());
EMAIL and STATUS are FocusPaths, so each reaches exactly one value. PRICES reaches every line's price, and BULK_PRICES the price of every line with four or more of an item. PENDING reaches a consignment's state only when it is Pending. Each type says how many values its path can reach, so the code that uses it cannot forget.
Take an order in
An order is accepted when every price passes the check, and only then are its bulk lines discounted by 10%:
static Validated<String, BigDecimal> checkPrice(BigDecimal price) {
return price.signum() < 0
? Validated.invalid("Price cannot be negative: " + price)
: Validated.valid(price);
}
/** Accepts an order: every price checked, then a 10% discount on each bulk line. */
static Validated<List<String>, Order> accept(Order order) {
return OpticOps.modifyAllValidated(order, PRICES.toTraversal(), OrderDesk::checkPrice)
.map(checked -> BULK_PRICES.modifyAll(p -> p.multiply(new BigDecimal("0.9")), checked));
}
map on a Validated changes the value inside a Valid and passes an Invalid through untouched, so the discount runs only on an accepted order. Ada's order of a £40.00 lamp and four £2.50 bulbs comes back Valid, with the bulbs at 2.250 and the lamp as it was. An order priced at -1.00, 2.50 and -3.00 comes back Invalid with both bad prices named, and nothing is discounted.
Amend an order
A customer can ask to change the email on an order, its status, or both. The request is sparse, each component null when it was not sent, and each value it sends is parsed before anything is written:
/** A change a customer asks for: each component is null when the request did not send it. */
record OrderAmendment(@Nullable String email, @Nullable String status) {}
static Validated<NonEmptyList<FieldError>, String> parseEmail(String raw) {
String email = raw.strip().toLowerCase(Locale.ROOT);
return email.contains("@")
? Validated.validNel(email)
: Validated.invalidNel(FieldError.of("not an email"));
}
static Validated<NonEmptyList<FieldError>, OrderStatus> parseStatus(String raw) {
try {
return Validated.validNel(OrderStatus.valueOf(raw.strip().toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException e) {
return Validated.invalidNel(FieldError.of("not a status"));
}
}
/** Amends an order: every field the request sent is checked, and all of them are written. */
static Validated<NonEmptyList<FieldError>, Order> amend(Order order, OrderAmendment amendment) {
return Edits.accumulate(
parseIfPresent(EMAIL, amendment.email(), OrderDesk::parseEmail),
parseIfPresent(STATUS, amendment.status(), OrderDesk::parseStatus))
.apply(order);
}
A request sends the email ada.example.org, which has no @, and the status LOST, which OrderStatus does not have. Before you read on: how many errors does amend report, in what order, and where is each located?
Answer and why
Answer and why
Two, in edit order: Invalid(NonEmptyList[customer.email.value: not an email, status: not a status]). accumulate checks every edit before it writes anything, and each generated path labels its own errors, so the email's error carries the whole route, three records down.
Where this lives: Validated PATCH.
A good request, Ada@Example.ORG and paid, gives an order whose customer's email is ada@example.org and whose status is PAID, with its lines the same list as before. A request that sends nothing leaves the order as it was.
The same amendment by hand
The same amendment by hand
Without paths, each field rebuilds every record that holds it, and the first bad field throws, so a caller never hears about the second:
/** The same amendment written by hand: each field rebuilt, and the first bad one throws. */
static Order amendByHand(Order order, OrderAmendment amendment) {
Order amended = order;
if (amendment.email() != null) {
String email = amendment.email().strip().toLowerCase(Locale.ROOT);
if (!email.contains("@")) {
throw new IllegalArgumentException("not an email");
}
Customer customer = new Customer(amended.customer().name(), new EmailAddress(email));
amended =
new Order(
amended.id(),
customer,
amended.lines(),
amended.placedAt(),
amended.currency(),
amended.status());
}
if (amendment.status() != null) {
OrderStatus status = OrderStatus.valueOf(amendment.status().strip().toUpperCase(Locale.ROOT));
amended =
new Order(
amended.id(),
amended.customer(),
amended.lines(),
amended.placedAt(),
amended.currency(),
status);
}
return amended;
}
Lombok's withers would shorten the rebuilds, but not the early throw.
Send goods out
A consignment is dispatched only from Pending. Moving to Dispatched is a different variant, so it is a check through the prism and then a write of the new state, not a modify:
/** Dispatches a pending consignment; any other state is left as it is. */
static Consignment dispatch(Consignment consignment, Instant at) {
return PENDING.matches(consignment)
? ConsignmentFocus.state().set(new ConsignmentState.Dispatched(at), consignment)
: consignment;
}
A pending consignment comes back Dispatched at the given instant. A returned one comes back as the very object it was.
What the desk used
| The desk | What it rests on | Where it was taught |
|---|---|---|
EMAIL, three records down | a generated path with navigators | Quickstart |
PRICES, every line's price | a List field is already element-level | Collections, Optionals and Sealed Types |
BULK_PRICES | filter narrows a traversal path | Focus DSL |
PRICES.toTraversal() | a path hands over its optic | What a Path Is Made Of |
modifyAllValidated | every bad value reported at once | Updates That Can Fail |
Edits.accumulate and parseIfPresent | a sparse request, every bad field located | Many Edits at Once |
PENDING.matches, then a write | moving to another variant is not a modify | Quickstart |
You have built an order desk from the chapter's first pages, and each operation is compiled and tested. The next page checks what stayed with you, and its score says where to go next.
- Name a path once. A path is a value; build it as a constant and every method reuses it.
- Check, then change.
modifyAllValidateddecides whether an order is accepted, andmapchanges it only when it is. - A filter belongs on the path. The discount never sees a line the filter leaves out.
- A sparse request is one
accumulate. Every field it sent is checked, and every bad one is reported where it lives. - A move between variants is a check and a write.
matchesasks, and the state's own path writes the new variant.
Build the desk's pieces yourself, on requests this page did not show, in Capstone: The Order Desk (5 exercises).
- Many Edits at Once: the PATCH model the amendment rests on
- Mapping at the Boundary: the same order service, parsed from a request and built into a response
Previous: Many Edits at Once Next: Check Your Understanding