One Line, Six Layers
One compiled Effect Path line that edits an order, taken apart into the six layers it uses.
Here is a service method that changes the email address on an order. Its body is one expression, and it comes from a compiled, tested example, so it builds as shown.
static EitherPath<OrderError, Order> changeEmail(
OrderRepository orders, UUID id, String newEmail) {
return orders
.find(id)
.toEitherPath(OrderError.notFound(id))
.map(order -> OrderFocus.customer().email().value().set(newEmail, order))
.via(orders::save);
}
orders is an OrderRepository. Its find returns a MaybePath<Order>, and its save returns an EitherPath<OrderError, Order>, refusing an order that has already shipped. OrderFocus is generated by @GenerateFocus on Order, one of the order-service records the Optics chapter introduces. Called for a new order, an unknown id and a shipped order, the method gives three answers:
Either<OrderError, Order> changed = changeEmail(orders, placed, "ada@example.org").run();
// Right(Order[id=..., customer=Customer[name=Ada,
// email=EmailAddress[value=ada@example.org]], ...])
Either<OrderError, Order> missing = changeEmail(orders, unknown, "ada@example.org").run();
// Left(NotFound[orderId=...])
Either<OrderError, Order> refused = changeEmail(orders, shipped, "ada@example.org").run();
// Left(AlreadyShipped[orderId=...])
A missing order becomes a NotFound that carries its id. The new address goes in three records deep, and you write none of the copies: the lens rebuilds Order, Customer and EmailAddress. A save that refuses ends the chain with its own reason. Each failure is part of the return type, a value the caller handles, rather than an exception the signature never mentions.
- Name the layer of the library that each step of a Path expression uses, and the page that teaches it
- Choose
mapfor a step that cannot fail, andviafor a step that can - Match each Path method to the type class operation over
Kindthat gives the same answer
The code on this page is OneLinerBook.java and its OneLinerBookTest.java: the page includes them, so the build compiles and runs them.
Six layers in one expression
Each step of the line belongs to a different layer of the library, and the last column links the page that teaches it.
| Step | Layer | What it does here | Where to read more |
|---|---|---|---|
orders.find(id) | An effect type | Returns a MaybePath<Order>: an order or nothing, as a value rather than a null | Maybe |
.toEitherPath(OrderError.notFound(id)) | A natural transformation | Turns the MaybePath into an EitherPath, giving the empty case a reason and leaving the order untouched | Natural Transformation |
OrderFocus.customer().email().value() | An optic | A lens three records deep, whose set returns a new Order and leaves the old one as it was | Optics |
.map(order -> ...) | Functor | Applies a function that cannot fail to the order inside, and does nothing on a Left | Functor |
.via(orders::save) | Monad | Runs a step that needs the edited order and can fail for a reason of its own | Monad |
| The whole expression | The algebra | The same operations exist as type classes over Kind<F, A>, for one method that serves any container | Two encodings, one algebra |
No step tests for failure. EitherPath carries a failure as a Left, and each later step passes the Left along without running. The interesting code is in the middle, where the domain logic lives.
Two encodings, one algebra
Higher-Kinded-J offers map, via and their neighbours twice. On a Path, they are methods of a concrete type, as in the line. As type classes, they are methods of interfaces such as Functor<F> and Monad<F>, which work over Kind<F, A>: some container F holding an A, without saying which container. One method written that way serves any container:
| What you want | On a Path | As a type class over Kind<F, A> |
|---|---|---|
| Change the value inside | path.map(f) | functor.map(f, fa) |
| Combine two independent values | path.zipWith(other, f) | applicative.map2(fa, fb, f) |
| Run a step that needs the value and may fail | path.via(f) | monad.flatMap(f, fa) |
| Recover from an error | path.recover(f) | monadError.handleError(fa, f) |
EitherPath holds an Either and calls it directly: no Kind and no type class instance sit between your call and the Either. The type classes do a different job, one method for any container, as the chapter introduction's totals is. The two agree. Run through Functor and Monad over Kind, the email change gives the same Right or Left as the line for each of the three ids, and the page's test checks it.
GenericPath.of(kind, monad) joins the two encodings: it lifts any Kind, with its Monad, to the Path level. Most application code stays with Paths, and the "Which level am I working at?" tip in the Foundations introduction says when to leave them.
The same skeleton, different verbs
Once the skeleton is in place, a different change is a different middle step. Marking the order paid, or taking a discount off every line, leaves the rest of the line as it was:
static EitherPath<OrderError, Order> markPaid(OrderRepository orders, UUID id) {
return orders
.find(id)
.toEitherPath(OrderError.notFound(id))
.map(order -> OrderFocus.status().set(OrderStatus.PAID, order))
.via(orders::save);
}
static EitherPath<OrderError, Order> discountEveryLine(
OrderRepository orders, UUID id, UnaryOperator<BigDecimal> discount) {
return orders
.find(id)
.toEitherPath(OrderError.notFound(id))
.map(order -> OrderFocus.lines().price().modifyAll(discount, order))
.via(orders::save);
}
The discount uses a traversal, OrderFocus.lines().price(), which reaches every line's price at once, and modifyAll applies the discount to each one. Only the middle step changes, and each is a function from Order to Order, so one method that takes that function could serve all three.
Mid-chain, toEitherPath has no target type to infer from, so it takes its error type from its argument. Given a NotFound, the whole chain's error type becomes NotFound, and the method no longer returns what it declares:
static EitherPath<OrderError, Order> changeEmail(
OrderRepository orders, UUID id, String newEmail) {
return orders
.find(id)
.toEitherPath(new OrderError.NotFound(id))
.map(order -> OrderFocus.customer().email().value().set(newEmail, order))
.via(orders::save);
}
The compiler rejects the via: EitherPath<OrderError,Order> cannot be converted to Chainable<? extends EitherPath<? extends NotFound,?>,B>. The chain is typed on NotFound, and save can fail with any OrderError. A factory typed as the sealed OrderError fixes the error type at the first step, so every later step fits. Naming the type once works too, as .<OrderError>toEitherPath(new OrderError.NotFound(id)), which the Quickstart uses. Effect compiler errors shows how to read the message, and the other places a chain takes its error type.
The Ship pages that follow take the type-class layers in order. They start beneath them, with how Kind<F, A> stands for a container, in How the Encoding Works.
Previous: Foundations Next: How the Encoding Works