Unifying Composable Effects and Advanced Optics for Java
Higher-Kinded-J brings two capabilities that Java has long needed: composable error handling through the Effect Path API, and type-safe immutable data navigation through the Focus DSL. Each is powerful alone. Together they form one approach to building robust applications, where effects and structure compose in the same vocabulary. At the edge of a service, Mapping at the Boundary turns the DTO-to-domain mapper into compile-time codegen that never loses an error, and for services that need several execution modes, Effect Handlers let you define domain operations as data and interpret them differently for production, testing, or audit.
No more pyramids of nested checks. No more scattered validation logic. Just clean, flat pipelines that read like the business logic they represent.
What You Get
Two artefacts, before any theory. The first is the shape of the code you write: a railway where success travels one track and failure the other, and every step reads top-to-bottom.
// Traditional Java: pyramid of nested checks
if (user != null) {
if (validator.validate(request).isValid()) {
try {
return paymentService.charge(user, amount);
} catch (PaymentException e) { ... }
}
}
// Effect Path API: flat, composable railway
return Path.maybe(findUser(userId))
.toEitherPath(() -> new UserNotFound(userId))
.via(user -> Path.either(validator.validate(request)))
.via(valid -> Path.tryOf(() -> paymentService.charge(user, amount)))
.map(OrderResult::success);
The nesting is gone. Each step follows the same pattern. Failures propagate automatically.
The second is what a client sees when the data is bad. A request with five defects (one inside a nested record, one on the second element of a list), answered by one response, with nobody writing a line of error-handling code to produce it:
{
"valid": false,
"errors": [
{ "path": "id", "message": "not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)" },
{ "path": "customer.email", "message": "not an email address" },
{ "path": "lines.1.price", "message": "not a number in plain notation (expected e.g. 123.45)" },
{ "path": "placedAt", "message": "not an ISO-8601 instant (expected e.g. 2026-07-28T12:34:56Z)" },
{ "path": "status", "message": "unknown OrderStatus (expected one of NEW, PAID, SHIPPED)" }
],
"errorCount": 5
}
It falls out of one spec interface and one annotation, and the mapping capstone builds it end to end, proven by a test the build runs. The client fixes all five and resubmits once.
Getting Started
One line configures the dependencies, the annotation processors, -parameters, the preview flags and compile-time Path checking:
// build.gradle.kts
plugins {
id("io.github.higher-kinded-j.hkj") version "LATEST_VERSION"
}
- Quickstart: Gradle and Maven setup, including the Maven plugin and the required Java 25 preview flags, and your first Effect Paths in five minutes
- Where to Start: one question, five answers, and the tool each one points at
- Cheat Sheet: a one-page operator reference
The Bridge: Effects Meet Optics
What makes Higher-Kinded-J unique is that Effect Paths and the Focus DSL speak the same language. Effect Paths are the effects, what the computation does: fetch, fail, wait, accumulate. Focus Paths are the optics, where the data lives: a field, an optional field, every element of a list. Both compose with via, and when you need to cross between them, the bridge connects the two worlds:
flowchart TB
subgraph effects["Effects: Effect Paths"]
direction TB
E1["MaybePath"] ~~~ E2["EitherPath"] ~~~ E3["TryPath"] ~~~ E4["ValidationPath"]
E5["EitherOrBothPath"] ~~~ E6["IOPath"] ~~~ E7["VTaskPath"] ~~~ E8["VStreamPath"]
end
subgraph optics["Optics: Focus Paths"]
direction TB
O1["FocusPath"] ~~~ O2["AffinePath"]
O3["TraversalPath"]
end
effects -->|".focus(path)<br/>navigate the data inside the effect"| B["The bridge"]
optics -->|".toEitherPath()<br/>.toMaybePath()<br/>lift the optic into an effect"| B
subgraph one["One composition"]
direction TB
S1["userService.findById(id)<br/>effect: fetch"] --> S2[".focus(UserFocus.address())<br/>optics: navigate"]
S2 --> S3[".via(validateAddress)<br/>effect: validate"]
S3 --> S4[".focus(AddressFocus.city())<br/>optics: extract"]
S4 --> S5[".map(String::toUpperCase)<br/>effect: transform"]
end
B --> S1
classDef effect fill:#8caaee,stroke:#1e66f5,color:#232634
classDef optic fill:#a6d189,stroke:#40a02b,color:#232634
classDef bridge fill:#e5c890,stroke:#df8e1d,color:#232634
class E1,E2,E3,E4,E5,E6,E7,E8,S1,S3,S5 effect
class O1,O2,O3,S2,S4 optic
class B bridge
// Fetch user (effect) → navigate to address (optics) →
// extract postcode (optics) → validate (effect)
EitherPath<Error, String> result =
userService.findById(userId) // EitherPath<Error, User>
.focus(UserFocus.address()) // EitherPath<Error, Address>
.focus(AddressFocus.postcode()) // EitherPath<Error, String>
.via(code -> validatePostcode(code));
This is the unification Java has been missing: effects and structure, composition and navigation, one vocabulary.
Why Higher-Kinded-J?
Modern Java handed you records, sealed interfaces, and pattern matching. What it didn't hand you is a way to make them compose: errors that chain instead of nest, validation that collects every failure instead of stopping at the first, deep immutable updates in one line instead of nested with… calls, and typed errors that survive a network hop. Higher-Kinded-J is the missing layer.
You don't need to learn an esoteric functional library to feel the benefit. Each capability replaces something you already reach for today:
| Instead of… | Today you reach for | Higher-Kinded-J gives you |
|---|---|---|
Nested Optional, thrown exceptions, and validation that stops at the first error | the standard library | one railway vocabulary (map / via / recover) across absence, typed errors, async, and accumulating validation |
Option / Either / Try from Vavr | the FP library most Java developers know | the same core types plus higher-kinded abstraction, a full optics suite, monad transformers, and an effect system, built natively on modern Java (records, sealed types, virtual threads), where Vavr keeps a Java 8 foundation |
| Hand-written DTO↔domain mappers and validation glue | custom converter classes per pair | @GenerateMapping over record, bean-shaped and generic wires: a total build, an accumulating parse that reports every bad field (nulls located, never an NPE), a stock codec vocabulary, and generated PATCH write-backs, all law-checked by the build's test suite |
| Resilience4j annotations for retry / circuit-breaker / bulkhead | AOP-style resilience | the same policies as composable path combinators (withRetry / withCircuitBreaker / withBulkhead) that treat a business Left as a value, never as a failure to retry |
Hand-written wither / copy-constructor updates on records | manual boilerplate | generated lenses, prisms, and traversals: the most comprehensive optics available for Java |
And unlike any of those tools, effects and data navigation speak the same language: the Effect-Optics bridge above is something no other Java library offers.
Every row in that table is a guarantee, not a convenience. An Either in a return type is checked by the compiler where an exception is not; an accumulating parse answers a client once where a first-failure mapper costs one round trip per defect; a generated lens is law-checked where a hand-written wither is trusted. The library holds itself to the same bar: every emission tier is pinned by golden files, the optic and mapping laws ship in hkj-test for your own types, and every HKJ module compiles under the HKJ checker at zero findings.
How the optics compare to other Java optics libraries
How the optics compare to other Java optics libraries
Higher-Kinded-J also offers the most advanced optics implementation in the Java ecosystem. Measured against the dedicated Java optics libraries:
| Feature | Higher-Kinded-J | Functional Java | Fugue Optics | Derive4J |
|---|---|---|---|---|
| Lens | ✓ | ✓ | ✓ | ✓^1^ |
| Prism | ✓ | ✓ | ✓ | ✓^1^ |
| Iso | ✓ | ✓ | ✓ | ✗ |
| Affine/Optional | ✓ | ✓ | ✓ | ✓^1^ |
| Traversal | ✓ | ✓ | ✓ | ✗ |
| Filtered Traversals | ✓ | ✗ | ✗ | ✗ |
| Indexed Optics | ✓ | ✗ | ✗ | ✗ |
| Code Generation | ✓ | ✗ | ✗ | ✓^1^ |
| External Type Spec Interfaces | ✓ | ✗ | ✗ | ✗ |
| Java Records Support | ✓ | ✗ | ✗ | ✗ |
| Sealed Interface Support | ✓ | ✗ | ✗ | ✗ |
| Effect Integration | ✓ | ✗ | ✗ | ✗ |
| Focus DSL | ✓ | ✗ | ✗ | ✗ |
| Profunctor Architecture | ✓ | ✓ | ✓ | ✗ |
| Fluent API | ✓ | ✗ | ✗ | ✗ |
| Modern Java (21+) | ✓ | ✗ | ✗ | ✗ |
| Virtual Threads | ✓ | ✗ | ✗ | ✗ |
| Effect Handlers / Free Monads | ✓ | ✗ | ✗ | ✗ |
^1^ Derive4J generates getters/setters but requires Functional Java for actual optic classes
What's in the Library
Each capability has a chapter that opens with the problem it solves and closes with a capstone. The short version of each, with a quick example folded underneath:
Effect Path API
A railway model for computation: map, via and recover work identically whether you are handling optional values, typed errors, accumulated validations, exceptions or deferred side effects. ForPath comprehensions sequence steps by name, VTaskPath and VStreamPath put structured concurrency on virtual threads behind the same vocabulary, and the lazy carriers chain path-native resilience that treats a business Left as a value, never as a failure to retry.
Quick example
Quick example
VResultPath<OrderError, Reservation> guarded =
reserveInventory(order)
.withRetry(error -> error instanceof OrderError.SystemError,
RetryPolicy.exponentialBackoffWithJitter(3, Duration.ofMillis(200)))
.withTimeout(Duration.ofSeconds(5),
() -> OrderError.SystemError.timeout("inventory", Duration.ofSeconds(5)));
Only a SystemError is retried; a business Left such as an out-of-stock decision is a value on the failure track and passes straight through. The same withRetry / withTimeout / withCircuitBreaker / withBulkhead chain on IOPath, VTaskPath and VResultPath alike. See Resilience Patterns.
Optics
The most comprehensive optics implementation available for Java: lenses, prisms, isos, affines, traversals, folds and setters, all composable, generated from annotations on records, sealed interfaces, collections and types you don't own (Jackson, JOOQ, Immutables, Lombok, AutoValue, Protocol Buffers). Filtered and indexed traversals, and 31 container types across the JDK and five third-party collection libraries widening to the right path type automatically.
Quick example
Quick example
@GenerateLenses @GenerateFocus
public record Street(String name, int number) {}
@GenerateLenses @GenerateFocus
public record Address(Street street, String city) {}
@GenerateLenses @GenerateFocus
public record User(String name, Address address) {}
User updated = UserFocus.address().street().name().set("New Street", user);
Write the records, add the annotations, and the processor writes StreetLenses, AddressFocus, UserFocus and the rest: a typed path builder for every field, three layers down in one line, with no reflection and no copy-and-rebuild code. Start at the Quickstart or the Annotations at a Glance table.
Mapping at the Boundary
One spec interface and one annotation replace the hand-written mapper. @GenerateMapping derives both directions at compile time for record, bean-shaped and generic wires of any width: a total build out, an accumulating parse back that locates every bad field, and both PATCH styles as write-backs. A stock codec vocabulary covers the standard conversions, so a typical boundary needs no hand-written leaves, and every tier is law-checked and pinned by golden files.
Quick example
Quick example
@GenerateMapping
interface OrderMapping extends MappingSpec<Order, OrderDto> {
default ValidatedPrism<String, UUID> id() { return uuid(); }
default ValidatedPrism<String, LocalDate> placedOn() { return localDate(); }
default ValidatedPrism<String, OrderStatus> status() { return enumByName(OrderStatus.class); }
default ValidatedPrism<String, BigDecimal> total() { return bigDecimal(); }
}
OrderMappingImpl.INSTANCE.parse(new OrderDto("NOPE", "28/07/2026", "SHIPPED", "1E+3"));
// Invalid(NonEmptyList[
// id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000),
// placedOn: not an ISO-8601 date (expected e.g. 2026-07-28),
// status: unknown OrderStatus (expected one of NEW, PAID, CANCELLED),
// total: not a number in plain notation (expected e.g. 123.45)])
Four leaves from StandardCodecs, no other code, and every failure located and worded for the client. The 422 payload shown at the top of this page is what this Invalid becomes at a Spring controller. @GenerateMerge assembles one record from several and @GenerateErrorEnvelope gives a sealed error hierarchy a typed context; see Merge and Error Envelopes.
Explore Mapping at the Boundary →
Effect Handlers
Algebraic-effect-style programming via Free monads and interpreters. Define domain operations as a sealed interface with record variants, compose several algebras with @ComposeEffects, then write one interpreter per mode (production, test, dry-run, audit) and run the same program unchanged through each. Testing is mock-free through Id interpreters, and ProgramAnalyser inspects a program before any side effect executes.
Quick example
Quick example
@EffectAlgebra
public sealed interface PaymentGatewayOp<A>
permits PaymentGatewayOp.Authorise, PaymentGatewayOp.Charge, PaymentGatewayOp.Refund {
record Authorise<A>(Money amount, PaymentMethod method,
Function<AuthorisationToken, A> k) implements PaymentGatewayOp<A> { /* ... */ }
record Charge<A>(Money amount, PaymentMethod method,
Function<ChargeResult, A> k) implements PaymentGatewayOp<A> { /* ... */ }
record Refund<A>(TransactionId txId, Money amount,
Function<RefundResult, A> k) implements PaymentGatewayOp<A> { /* ... */ }
}
@EffectAlgebra generates the functor, the smart constructors and an interpreter skeleton, so a program is written once against PaymentGatewayOps and a production interpreter, a fake, a dry-run and an audit log are four small classes. The Payment Processing example runs one program through all four.
Spring Boot Integration
The hkj-spring-boot-starter lets controllers return Either, Validated, EitherPath, VTaskPath and the rest directly, and an Invalid of located field errors renders as one 422 listing every bad field by path, with no exception handler and no hand-rolled error DTO. @HkjHttpClient keeps the error channel intact when one service calls another.
Quick example
Quick example
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public Either<DomainError, User> getUser(@PathVariable String id) {
return userService.findById(id);
// Right(user) → HTTP 200 with JSON
// Left(UserNotFoundError) → HTTP 404 with error details
}
@PostMapping
public Validated<NonEmptyList<FieldError>, User> createUser(@RequestBody UserDto dto) {
return userCodec.parse(dto);
// Valid(user) → HTTP 200
// Invalid(errors) → one HTTP 422 listing EVERY bad field by path
}
}
@HttpExchange("/users")
@HkjHttpClient
public interface UserClientApi {
@GetExchange("/{id}")
EitherPath<DomainError, User> getUser(@PathVariable String id);
// HTTP 200 → Right(user); HTTP 404 → Left(UserNotFoundError), decoded from the response
}
Auto-configuration handles the conversions; Right is a 200 and a typed Left maps to its status. Actuator metrics for success rates and error distributions, Spring Security integration with Either-based authentication, and an EffectBoundary that selects interpreters by profile come with it. See Declarative HTTP Clients and Migrating to Functional Errors.
Get Started with Spring Boot Integration →
Testing With hkj-test
Fluent AssertJ assertions for every type in the library, the optic laws (LensLaws, PrismLaws, TraversalLaws, ValidatedPrismLaws) and the MappingLaws every generated mapping is checked against, and a SteppableClock for deterministic time. Tests read in the same vocabulary as the code under test.
Quick example
Quick example
import static org.higherkindedj.hkt.assertions.EitherAssert.assertThatEither;
import static org.higherkindedj.hkt.assertions.MaybeAssert.assertThatMaybe;
import static org.higherkindedj.hkt.assertions.TryAssert.assertThatTry;
assertThatEither(result).isRight().hasRight(42);
assertThatMaybe(value).isJust().hasValue("hello");
assertThatTry(computation).isFailure().hasExceptionOfType(IOException.class);
Coverage spans the discriminated unions, the effect types (IO, VTask, VStream), the Reader / Writer / State trio, every monad transformer, the Free / EitherF algebras and the VTaskPath / VStreamPath / VTaskContext Path-and-context assertions. On Java 25 with --enable-preview, import module org.higherkindedj.test; brings every helper into scope in one line.
Foundations
Underneath it all: a simulation of higher-kinded types by defunctionalisation, so Functor, Applicative, Monad, Traverse and friends can be written once and applied across Optional, List, CompletableFuture, VTask and your own types; the core types (Either, Maybe, Try, Validated, IO, Reader, Writer, State, Free); and the monad transformers and MTL capabilities for the cases the Path API does not fit: a different outer monad, or polymorphic library code. Most applications start with Effect Paths and never need to look down here; the triage page says when you do.
Path Types at a Glance
Twenty-one Path types share one vocabulary. Most applications start with EitherPath (typed errors), MaybePath (absence), ValidationPath (every error at once) and IOPath (deferred effects), and reach for the rest as a need appears. The lazy carriers (IOPath, VTaskPath, VResultPath) additionally chain path-native resilience (withRetry / withTimeout / withCircuitBreaker / withBulkhead) that treats a business Left as a value, never as a failure to retry.
All twenty-one Path types
All twenty-one Path types
| Path Type | When to Reach for It |
|---|---|
MaybePath<A> | Absence is normal, not an error |
EitherPath<E, A> | Errors carry typed, structured information |
EitherOrBothPath<L, A> | Success that also carries non-fatal warnings (inclusive-or) |
TryPath<A> | Wrapping code that throws exceptions |
ValidationPath<E, A> | Collecting all errors, not just the first |
IOPath<A> | Side effects you want to defer and sequence |
VResultPath<E, A> | Async work that fails with a typed domain error (VTask<Either<E, A>>) |
TrampolinePath<A> | Stack-safe recursion |
CompletableFuturePath<A> | Async operations |
ReaderPath<R, A> | Dependency injection, configuration access |
WriterPath<W, A> | Logging, audit trails, collecting metrics |
WithStatePath<S, A> | Stateful computations (parsers, counters) |
ListPath<A> | Batch processing with positional zipping |
StreamPath<A> | Lazy sequences, large data processing |
NonDetPath<A> | Non-deterministic search, combinations |
LazyPath<A> | Deferred evaluation, memoisation |
IdPath<A> | Pure computations (testing, generic code) |
OptionalPath<A> | Bridge for Java's standard Optional |
FreePath<F, A> / FreeApPath<F, A> | DSL building and interpretation |
VTaskPath<A> | Virtual thread-based concurrency with Par combinators |
VStreamPath<A> | Lazy pull-based streaming on virtual threads |
Each Path wraps its underlying effect and provides map, via, run, recover, and integration with the Focus DSL. See Core Paths for the railway model behind them.
Learn by Doing
The fastest way to master Higher-Kinded-J is through our interactive tutorial series: seventeen journeys of hands-on exercises with immediate test feedback. Start with Effect API (~65 min) for the railway, Optics: Lens & Prism (~40 min) for immutable updates, or Optics: Boundary Mapping (~35 min) for the 422 leg.
All seventeen journeys
All seventeen journeys
| Journey | Focus | Duration | Exercises |
|---|---|---|---|
| Core: Foundations | HKT simulation, Functor, Applicative, Monad | ~40 min | 24 |
| Core: Error Handling | MonadError, concrete types, real-world patterns | ~30 min | 20 |
| Core: Advanced | Natural Transformations, Coyoneda, Free Applicative | ~40 min | 26 |
| Effect API | Effect paths, ForPath, Effect Contexts | ~65 min | 15 |
| Monad Transformers | When Path isn't enough, async + absence, stacking, MTL | ~90 min | 28 |
| Expression: ForState | Named fields, guards, pattern matching, zoom | ~25 min | 11 |
| Expression: ForPath Parallel | Parallel composition, accumulating and racing steps | ~20 min | 9 |
| Concurrency: VTask | Virtual threads, VTaskPath, Par combinators | ~45 min | 16 |
| Concurrency: Scope & Resource | Structured concurrency, resource management | ~30 min | 12 |
| Resilience Patterns | Circuit breaker, saga, retry, bulkhead | ~45 min | 22 |
| Optics: Lens & Prism | Lens basics, Prism, Affine | ~40 min | 30 |
| Optics: Traversals | Traversals, composition, practical applications | ~40 min | 27 |
| Optics: Fluent & Free | Fluent API, Free Monad DSL | ~35 min | 22 |
| Optics: Focus DSL | Type-safe path navigation, container widening | ~35 min | 29 |
| Optics: Batching & Coupled Updates | Optic-driven request batching, Edits, coupled fields | ~40 min | 13 |
| Optics: Boundary Mapping | Multi-edit and sparse updates, @GenerateMapping, the 422 leg | ~35 min | 13 |
| Capstone: One Line, Six Layers | One pipeline across effects, optics, resilience and concurrency | ~30 min | 7 |
Perfect for developers who prefer learning by building. Get started →
Documentation Guide
If you want working code immediately, start with the Quickstart. For a deeper understanding, continue with the Effect Path API chapter. Every chapter below unfolds to its reading order; the sidebar carries the same structure.
Effect Path API (start here)
Effect Path API (start here)
- Quickstart: Three runnable examples showing MaybePath, EitherPath, and ForPath in about 150 lines
- Core Paths: The railway model, the six core path types, composition, and basic ForPath comprehensions
- Optics Integration: Bridging Effect Paths with the Focus DSL
- Advanced Paths: Free monads, effect handlers, contexts, ForPath parallelism, and resilience
- Reference: Capability type classes, type conversions, compiler errors, and production readiness
Optics
Optics
- Quickstart: Three runnable examples covering generated lenses, prisms and traversals, plus
@ImportOpticsfor Jackson - Annotations at a Glance: Every annotation, what it generates, and when to reach for each one
- Fundamentals: Lens, Prism, Affine, Iso, composition rules, and coupled fields
- Java-Friendly APIs: Focus DSL, optics for external types, Kind field support, and the Fluent API
- Integration and Recipes: Validation pipelines, core-type integration, and the cookbook
- Advanced Optics: Free Monad DSL and interpreters for programs-as-data
- Reference: Capabilities, conversions, compiler errors, production readiness, and consolidated decision trees
Mapping at the Boundary
Mapping at the Boundary
- Introduction: The mapper every service carries, and the one response that replaces it
- Basics: One spec interface, both directions, every bad field located
- Standard Codecs and Shared Vocabulary: The stock
ValidatedPrismleaves, custom codecs, and mix-in interfaces - Beans and Sparse PATCH: Bean-shaped wires and the
UpdateSpecwrite-back - Capstone: One 422, every bad field, compiled and law-checked
Monad Transformers
Monad Transformers
For the cases where the Path API does not fit (a different outer monad, polymorphic library code, or integrating with raw Kind shapes).
- Path or Transformer?: The triage page; read this first to know whether the rest of the chapter applies to you
- Quickstart: Three runnable transformer examples in about 150 lines
- Stack Archetypes: Seven named patterns covering the most common composition problems
- MTL Capabilities: Stack-independent capability abstractions for polymorphic library code
- Capstone: End-to-end multi-capability workflow combining typed errors, configuration, audit, and async
- Common Compiler Errors: Six common errors and the fix for each
Effect Handlers
Effect Handlers
- Effect Handlers Introduction: Motivation, terminology, and when to use
- Effect Handler Reference: Defining, composing, and interpreting effects
- Payment Processing Example: Complete worked example with four interpreters
Foundations (reference)
Foundations (reference)
These sections document the underlying machinery. Most users can start with Effect Paths directly.
- Higher-Kinded Types: The simulation and why it matters
- Type Classes: Functor, Monad, and other type classes
- Core Types: Either, Maybe, Try, and other effect types
- Order Example Walkthrough: A complete workflow with monad transformers
- One vocabulary:
map,viaandrecoverwork the same across absence, typed errors, exceptions, accumulating validation, deferred I/O and virtual-thread concurrency - Effects and structure compose:
.focus(path)takes an Effect Path through a Focus Path and back, which no other Java library offers - Optics are generated, not written:
@GenerateLenses,@GenerateFocus,@GeneratePrisms,@GenerateTraversalsand@ImportOpticscover records, sealed types, collections and types you don't own - The boundary never loses an error:
@GenerateMappingderives both directions, locates every bad field, and renders as one 422 at a Spring controller - Everything is law-checked: the optic and mapping laws ship in
hkj-test, and the library compiles under its own checker - Start with the Quickstart, and reach for the foundations only when the triage page says so
History
Higher-Kinded-J evolved from a simulation originally created for the blog post Higher Kinded Types with Java and Scala. Since then it has grown into a comprehensive functional programming toolkit, with the Effect Path API providing the unifying layer that connects HKTs, type classes, and optics into a coherent whole.