Unifying Composable Effects and Advanced Optics for Java

Static Badge Codecov Maven Central Version Latest Snapshot GitHub Discussions Mastodon Follow

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.

Discover Optics Integration →


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 forHigher-Kinded-J gives you
Nested Optional, thrown exceptions, and validation that stops at the first errorthe standard libraryone railway vocabulary (map / via / recover) across absence, typed errors, async, and accumulating validation
Option / Either / Try from Vavrthe FP library most Java developers knowthe 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 gluecustom 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 / bulkheadAOP-style resiliencethe 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 recordsmanual boilerplategenerated 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.

Why this matters

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

Higher-Kinded-J also offers the most advanced optics implementation in the Java ecosystem. Measured against the dedicated Java optics libraries:

FeatureHigher-Kinded-JFunctional JavaFugue OpticsDerive4J
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

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.

Explore the Effect Path API →

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

@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.

Explore Optics →

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

@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

@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.

Explore Effect Handlers →

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

@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

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.

Explore hkj-test →

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

Path TypeWhen 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

JourneyFocusDurationExercises
Core: FoundationsHKT simulation, Functor, Applicative, Monad~40 min24
Core: Error HandlingMonadError, concrete types, real-world patterns~30 min20
Core: AdvancedNatural Transformations, Coyoneda, Free Applicative~40 min26
Effect APIEffect paths, ForPath, Effect Contexts~65 min15
Monad TransformersWhen Path isn't enough, async + absence, stacking, MTL~90 min28
Expression: ForStateNamed fields, guards, pattern matching, zoom~25 min11
Expression: ForPath ParallelParallel composition, accumulating and racing steps~20 min9
Concurrency: VTaskVirtual threads, VTaskPath, Par combinators~45 min16
Concurrency: Scope & ResourceStructured concurrency, resource management~30 min12
Resilience PatternsCircuit breaker, saga, retry, bulkhead~45 min22
Optics: Lens & PrismLens basics, Prism, Affine~40 min30
Optics: TraversalsTraversals, composition, practical applications~40 min27
Optics: Fluent & FreeFluent API, Free Monad DSL~35 min22
Optics: Focus DSLType-safe path navigation, container widening~35 min29
Optics: Batching & Coupled UpdatesOptic-driven request batching, Edits, coupled fields~40 min13
Optics: Boundary MappingMulti-edit and sparse updates, @GenerateMapping, the 422 leg~35 min13
Capstone: One Line, Six LayersOne pipeline across effects, optics, resilience and concurrency~30 min7

Perfect for developers who prefer learning by building. Get started →


Documentation Guide

Effect Path API (start here)

  1. Quickstart: Three runnable examples showing MaybePath, EitherPath, and ForPath in about 150 lines
  2. Core Paths: The railway model, the six core path types, composition, and basic ForPath comprehensions
  3. Optics Integration: Bridging Effect Paths with the Focus DSL
  4. Advanced Paths: Free monads, effect handlers, contexts, ForPath parallelism, and resilience
  5. Reference: Capability type classes, type conversions, compiler errors, and production readiness

Optics

  1. Quickstart: Three runnable examples covering generated lenses, prisms and traversals, plus @ImportOptics for Jackson
  2. Annotations at a Glance: Every annotation, what it generates, and when to reach for each one
  3. Fundamentals: Lens, Prism, Affine, Iso, composition rules, and coupled fields
  4. Java-Friendly APIs: Focus DSL, optics for external types, Kind field support, and the Fluent API
  5. Integration and Recipes: Validation pipelines, core-type integration, and the cookbook
  6. Advanced Optics: Free Monad DSL and interpreters for programs-as-data
  7. Reference: Capabilities, conversions, compiler errors, production readiness, and consolidated decision trees

Mapping at the Boundary

  1. Introduction: The mapper every service carries, and the one response that replaces it
  2. Basics: One spec interface, both directions, every bad field located
  3. Standard Codecs and Shared Vocabulary: The stock ValidatedPrism leaves, custom codecs, and mix-in interfaces
  4. Beans and Sparse PATCH: Bean-shaped wires and the UpdateSpec write-back
  5. Capstone: One 422, every bad field, compiled and law-checked

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).

  1. Path or Transformer?: The triage page; read this first to know whether the rest of the chapter applies to you
  2. Quickstart: Three runnable transformer examples in about 150 lines
  3. Stack Archetypes: Seven named patterns covering the most common composition problems
  4. MTL Capabilities: Stack-independent capability abstractions for polymorphic library code
  5. Capstone: End-to-end multi-capability workflow combining typed errors, configuration, audit, and async
  6. Common Compiler Errors: Six common errors and the fix for each

Effect Handlers

  1. Effect Handlers Introduction: Motivation, terminology, and when to use
  2. Effect Handler Reference: Defining, composing, and interpreting effects
  3. Payment Processing Example: Complete worked example with four interpreters

Foundations (reference)

These sections document the underlying machinery. Most users can start with Effect Paths directly.

  1. Higher-Kinded Types: The simulation and why it matters
  2. Type Classes: Functor, Monad, and other type classes
  3. Core Types: Either, Maybe, Try, and other effect types
  4. Order Example Walkthrough: A complete workflow with monad transformers

Key Takeaways

  • One vocabulary: map, via and recover work 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, @GenerateTraversals and @ImportOptics cover records, sealed types, collections and types you don't own
  • The boundary never loses an error: @GenerateMapping derives 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.