Production Readiness
Senior engineers evaluating a library for production use inevitably ask three questions: "What do the stack traces look like?", "How much does the abstraction cost?", and "Will it blow the stack?" This page answers all three honestly, with data.
- How to read stack traces from Path chains and add debug logging
- The allocation cost of wrapper objects and why it is negligible in practice
- When recursive chains can overflow the JVM stack and how to prevent it
Reading Path Chain Stack Traces
Path chains produce deeper stack traces than their imperative equivalents. Each map, via, or recover call adds a frame. This is the trade-off for composability; the good news is that the traces are predictable once you know what to look for.
An Annotated Example
Consider a simple service pipeline:
EitherPath<AppError, Invoice> result =
Path.<AppError, String>right(orderId)
.via(id -> lookupOrder(id)) // step 1
.via(order -> validateOrder(order)) // step 2
.map(order -> generateInvoice(order)) // step 3
.recover(error -> Invoice.empty()); // step 4
If validateOrder throws an unexpected NullPointerException, the stack trace might look like this:
java.lang.NullPointerException: Cannot invoke "Address.postcode()" on null reference
at com.example.OrderService.validateOrder(OrderService.java:47) // <-- YOUR CODE: the actual failure
at com.example.OrderService.lambda$process$1(OrderService.java:23) // <-- YOUR CODE: the .via() lambda
at org.higherkindedj.hkt.either.Either.flatMap(Either.java:142) // library internals (skip)
at org.higherkindedj.hkt.effect.EitherPath.via(EitherPath.java:98) // library internals (skip)
at com.example.OrderService.process(OrderService.java:23) // <-- YOUR CODE: the chain call site
at com.example.OrderController.handleOrder(OrderController.java:31)
...
How to Read Path Stack Traces
The pattern is consistent:
- Top of the trace: your code that threw the exception (the business logic inside the lambda)
- Middle frames: library internals (
Either.flatMap,EitherPath.via); skip these - Lower frames: the call site where you built the chain, then your normal application frames
Rule of thumb: look for your package name in the trace. The topmost frame with your package is the failing business logic; the next one down is the chain step that invoked it.
Using peek for Debug Logging
When you need to inspect intermediate values without breaking the chain, peek provides observation points:
EitherPath<AppError, Invoice> result =
Path.<AppError, String>right(orderId)
.via(id -> lookupOrder(id))
.peek(order -> log.debug("Looked up order: {}", order.id()))
.via(order -> validateOrder(order))
.peek(order -> log.debug("Validated order: {}", order.id()))
.map(order -> generateInvoice(order));
peek runs a side effect on the success track without changing the value. If the chain has already diverted to the error track, peek is skipped. This makes it safe to leave debug logging in place; it only executes on the happy path and has negligible cost.
For clearer stack traces, extract lambdas into named methods:
// Anonymous lambda: shows as lambda$process$1 in traces
.via(id -> lookupOrder(id))
// Method reference: shows as OrderService.lookupOrder in traces
.via(this::lookupOrder)
Method references produce more readable stack frames than anonymous lambdas.
Allocation Overhead
Every step in a Path chain creates a small wrapper object. A map call on EitherPath creates one new Either and one new EitherPath. This is real allocation, and it is worth understanding.
The Cost in Context
| Operation | Typical Cost | Order of Magnitude |
|---|---|---|
Wrapper allocation (EitherPath, MaybePath) | 5-20 ns | Nanoseconds |
map or via step (wrapper + lambda object) | 10-50 ns | Nanoseconds |
| HashMap lookup | 10-100 ns | Nanoseconds |
| JSON serialisation (small object) | 1-10 us | Microseconds |
| Database query (local) | 0.5-5 ms | Milliseconds |
| HTTP request (same data centre) | 1-50 ms | Milliseconds |
| HTTP request (cross-region) | 50-200 ms | Milliseconds |
A typical Path chain of five steps adds roughly 50-250 nanoseconds of overhead. A single database call takes 500,000-5,000,000 nanoseconds. The wrapper overhead is three to four orders of magnitude smaller than any I/O operation your application performs.
When Overhead Matters
Allocation overhead is not zero, and in two scenarios it deserves attention:
-
Tight computational loops processing millions of items per second with no I/O. If you are writing a number-crunching inner loop, use primitive types directly. Path types are designed for orchestrating effectful operations, not replacing arithmetic.
-
Very long chains (hundreds of steps). Each step allocates, and the objects are short-lived, which means GC pressure. In practice, chains rarely exceed 10-20 steps. If yours does, consider breaking it into named submethods that each return a Path.
For the vast majority of applications, particularly those performing any I/O, the overhead is negligible.
Measuring It Yourself
The project includes JMH benchmarks that measure construction, execution, and composition overhead for all Path types. To run them:
./gradlew jmh --includes=".*IOPathBenchmark.*"
./gradlew jmh --includes=".*AbstractionOverheadBenchmark.*"
./gradlew jmh --includes=".*MemoryFootprintBenchmark.*"
- IOPathBenchmark.java - Construction, execution, and composition overhead
- AbstractionOverheadBenchmark.java - Raw Java vs IO vs VTask comparison
- MemoryFootprintBenchmark.java - Allocation rates under GC profiling
- TrampolineBenchmark.java - Stack-safe recursion performance
Stack Safety and Trampolining
The JVM does not perform Tail Call Optimisation (TCO). Every method call, including every recursive call, adds a frame to the call stack. The default stack size is typically 512KB to 1MB, which allows roughly 5,000-15,000 frames depending on frame size.
When Path Chains Are Stack-Safe
Most Path usage is inherently stack-safe. A chain like this:
Path.right(value)
.via(this::step1)
.via(this::step2)
.map(this::step3)
.recover(this::handleError)
Each step executes and returns immediately. The chain does not recurse. Even a chain with 50 steps uses only a handful of stack frames at any point; the depth is constant, not proportional to chain length.
When Recursion Causes Problems
The risk arises when a Path chain calls itself recursively:
// DANGER: recursive Path chain, will overflow for large n
EitherPath<Error, Integer> countdown(int n) {
if (n <= 0) return Path.right(0);
return Path.<Error, Integer>right(n)
.via(x -> countdown(x - 1)); // recursive call adds a stack frame
}
countdown(100_000); // StackOverflowError
Each call to countdown adds a frame. For large inputs, this exhausts the stack.
The Solution: TrampolinePath
TrampolinePath converts recursive calls into a loop that uses constant stack space:
// SAFE: trampolined recursion, works for any depth
TrampolinePath<Integer> countdown(int n) {
if (n <= 0) return TrampolinePath.done(0);
return TrampolinePath.defer(() -> countdown(n - 1));
}
Integer result = countdown(1_000_000).run(); // completes without overflow
TrampolinePath.done(value) signals completion. TrampolinePath.defer(supplier) describes the next step without executing it. The trampoline runner bounces through deferred steps in a loop, never growing the call stack.
Quick Decision Guide
| Scenario | Stack-safe? | Action needed |
|---|---|---|
Linear chain (via, map, recover) | Yes | None |
| Bounded recursion (depth < 1,000) | Usually | None, but consider TrampolinePath as insurance |
| Unbounded recursion (paginated APIs, tree traversal) | No | Use TrampolinePath |
| Mutual recursion (A calls B calls A) | No | Use TrampolinePath |
- TrampolinePath - Creation, usage, and conversion
- Advanced Topics: Stack-Safe Recursion - Deep dive with fibonacci, mutual recursion, and integration patterns
- Safe Recursion Stack archetype - When to reach for
TrampolinePathin enterprise applications
- Stack traces from Path chains are deeper but predictable. Look for your package name; the top frame is your failing logic, the next is the chain step that invoked it.
- Allocation overhead is real but negligible for any application that performs I/O. Wrapper allocation costs nanoseconds; database calls cost milliseconds.
- Linear Path chains are stack-safe. Only unbounded recursion needs
TrampolinePath, which converts recursion into constant-space iteration. - Honesty builds trust. These are the real trade-offs. For the vast majority of production workloads, they are non-issues.
Previous: Advanced Topics