Effect Handlers: Programs as Data

"All moments, past, present, and future, always have existed, always will exist."

-- Kurt Vonnegut, Slaughterhouse-Five

Billy Pilgrim experiences every moment of his life simultaneously; nothing is executed in sequence, everything simply is. A Free monad program has the same quality: every instruction, from charging a card to sending a receipt, exists simultaneously as data in a tree. No side effect has fired. No network call has been made. The entire workflow is present, inspectable, transformable. Only when an interpreter walks the tree does a single timeline, production, test, audit, or dry-run, materialise from the structure. Traditional dependency injection chooses the timeline at compile time; effect handlers let the program contain all of them at once.


What You'll Learn

  • Why dependency injection falls short for multi-interpretation workflows
  • How effect handlers relate to Higher-Kinded-J's existing capabilities
  • The core idea: programs as data structures built from records and sealed interfaces
  • How this connects to Data Oriented Programming in modern Java
  • A terminology bridge mapping FP concepts to familiar Java equivalents
  • When to use effect handlers and when to stick with simpler approaches

The Problem: When Dependency Injection Is Not Enough

Consider a familiar Spring service. A PaymentService depends on four external systems: a payment gateway, a fraud detector, an accounting ledger, and a notification sender.

@Service
public class PaymentService {

    @Autowired private PaymentGateway gateway;
    @Autowired private FraudDetector fraud;
    @Autowired private AccountingLedger ledger;
    @Autowired private NotificationSender notifications;

    public PaymentResult processPayment(Customer customer, Money amount) {
        RiskScore risk = fraud.checkTransaction(amount, customer);
        if (risk.exceeds(THRESHOLD)) {
            notifications.alertFraudTeam(customer, risk);
            return PaymentResult.declined("High risk");
        }
        Money balance = ledger.getBalance(customer.accountId());
        if (balance.lessThan(amount)) {
            return PaymentResult.declined("Insufficient funds");
        }
        ChargeResult charge = gateway.charge(amount, customer.paymentMethod());
        ledger.recordTransaction(customer.accountId(), amount);
        notifications.sendReceipt(customer, charge);
        return PaymentResult.success(charge.transactionId());
    }
}

Spring's @Autowired lets you swap implementations for testing. But the business needs more than implementation swapping:

RequirementSpring DIEffect Handlers
Swap to test doublesYesYes
Inspect the workflow before execution (count calls, estimate cost)NoYes
Guarantee every operation is handled at compile timeNoYes
Run the same logic as a fee estimate without chargingManual second serviceSame program, different interpreter
Add audit logging without modifying business logicAOP proxy (runtime)Interpreter wrapping (compile-time)

The root cause is that the Spring service executes immediately. Each method call fires a side effect the moment it runs. You cannot step back and ask "what would this program do?" because it has already done it.

Effect handlers solve this by separating description from execution. The business logic becomes a data structure, a tree of instructions. Interpreters walk the tree and decide what each instruction means. Different interpreters produce different behaviours from the same program.


Where This Fits: Effects, Optics, and Handlers

Higher-Kinded-J provides two core capabilities for modern Java:

  • Effect Path API: Composable error handling with railway-oriented pipelines. Success travels one track, failure travels another. Operations like map, via, and recover work identically across all effect types.

  • Focus DSL / Optics: Type-safe immutable data navigation. Lenses, prisms, and traversals treat data access as first-class values, eliminating the verbose copy-and-update boilerplate that Java records require.

Effect Handlers open a third dimension. Where Effect Paths handle how computations succeed or fail and Optics handle how data is accessed and transformed, Effect Handlers address a different question: how domain operations are defined, composed, and interpreted.

        Effect Path API                Focus DSL / Optics
        ───────────────                ──────────────────
        "How computations              "How data is accessed
         succeed or fail"               and transformed"
              │                              │
              │     ┌─────────────────┐      │
              └────▶│    Shared       │◀─────┘
                    │   Foundations   │
                    │                 │
                    │  Records        │
                    │  Sealed types   │
                    │  Composition    │
                    └────────┬────────┘
                             │
                    ┌────────▼────────┐
                    │ Effect Handlers │
                    │                 │
                    │ "How domain     │
                    │  operations are │
                    │  interpreted"   │
                    └─────────────────┘

A Complementary Capability

Effect Paths and Optics remain the primary entry points for most Higher-Kinded-J users. Effect Handlers are for teams that need multi-interpretation workflows: services where the same business logic must run in production, test, dry-run, and audit modes without code duplication. If your service has a single execution mode and straightforward testing needs, you may not need effect handlers at all.

All three capabilities share the same building blocks: records, sealed interfaces, and composition. They also work together: an effect handler program can use optics to navigate nested data structures and effect paths to propagate errors at the boundary.


The Idea: Programs as Data Structures

If you have used sealed interfaces and records in Java 21+, you already know the foundation.

An effect algebra is a sealed interface where each permitted record represents an operation. This is standard Data Oriented Programming:

@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> { /* ... */ }
}

Each record carries its parameters (the data the operation needs) and a continuation function k that transforms the result. When you chain these operations together using flatMap, you build a tree:

       FlatMapped
       ╱        ╲
  Suspend       λ(risk) → FlatMapped
  [FraudCheck]              ╱        ╲
                       Suspend       λ(balance) → ...
                       [GetBalance]
                                         ╲
                                     Suspend     → Pure(result)
                                     [Charge]

This tree is the program. It is an ordinary Java data structure. No side effect has occurred. The tree records what the program intends to do, not how to do it.

Because the program is data, you can:

  • Inspect it before execution: count the number of external calls, identify error recovery points, estimate cost
  • Transform it by composing interpreters: wrap every operation with audit logging, add retry logic
  • Interpret it in different modes: production (real services), testing (pure values), quoting (fee estimation), auditing (record every step)

Just as optics represent data access as composable values, effect handlers represent domain operations as composable data.


The DOP Connection: Where Modern Java Is Heading

Java's evolution toward Data Oriented Programming provides exactly the building blocks that effect handlers need:

Java FeatureDOP UsageEffect Handler Usage
RecordsImmutable data carriersOperation definitions with parameters
Sealed interfacesExhaustive type hierarchiesClosed sets of operations (effect algebras)
Pattern matchingDeconstructing dataInterpreting operations in handlers

This is not exotic functional programming imported from Haskell. It is standard Java 21+ patterns applied to domain workflows. A switch expression over a sealed interface is how you write an interpreter:

// This is just pattern matching, standard Java DOP
public <A> Kind<IOKind.Witness, A> apply(PaymentGatewayOp<A> op) {
    return switch (op) {
        case Authorise<A> auth -> handleAuthorise(auth);
        case Charge<A> charge -> handleCharge(charge);
        case Refund<A> refund -> handleRefund(refund);
    };
}

If you add a new operation to the sealed interface, every interpreter must handle it or the code will not compile. This is the same exhaustiveness guarantee that switch expressions provide for any sealed type.

Higher-Kinded-J's @EffectAlgebra annotation processor generates the boilerplate (the Kind marker, Functor instance, smart constructors, and interpreter skeleton) so you can focus on defining operations and writing interpreters.


Terminology Bridge: FP Concepts in Java Terms

Effect handler documentation uses terms from functional programming that may be unfamiliar. Each maps directly to a Java concept you already know.

FP TermJava EquivalentOne-Liner
Effect algebraSealed interface + recordsA closed set of operations
Continuation (CPS)Function parameter on each recordLike thenApply on CompletableFuture
mapKMethod on each recordLike Stream.map but for an instruction
Natural transformationAn interpreterConverts DSL instructions to real actions
EitherFUnion type for composing algebrasLike Either but for type constructors
Free monadThe program treeA data structure of sequenced instructions
foldMapTree-walking interpreter runnerLike Stream.reduce for programs

Effect Algebra

A sealed interface annotated with @EffectAlgebra where each permitted record represents a domain operation. The sealed modifier guarantees that the set of operations is closed: the compiler knows every possible instruction. This is the same pattern that Effect Path uses for error types.

Continuation-Passing Style (CPS)

Each operation record includes a Function parameter (conventionally named k) that transforms the operation's natural result type to the generic type parameter A. If Charge naturally produces a ChargeResult, the continuation Function<ChargeResult, A> lets callers transform that result inline. This is the same idea as CompletableFuture.thenApply: chain a transformation onto a value that does not exist yet.

mapK

A method on each record that composes the continuation function with a new transformation. The generated Functor delegates to mapK rather than using unsafe casts. Think of it as Stream.map applied to a single instruction rather than a collection.

Natural Transformation (Interpreter)

A function that converts each instruction in your DSL into an action in a target monad. A production interpreter converts Charge into an IO action that calls Stripe. A test interpreter converts Charge into an Id value with a canned result. The abstract skeleton is generated by @EffectAlgebra; you fill in the handler methods.

EitherF

A union type lifted to the type constructor level. When your program uses multiple effect algebras (payments, fraud, ledger, notifications), EitherF composes them into a single type via right-nesting. The @ComposeEffects annotation generates this composition automatically.

Free Monad

The data structure (Free<F, A>) that represents a program as a tree of instructions. Three node types: Pure (return a value), Suspend (an instruction to execute), and FlatMapped (sequence two programs). Because the program is data, it can be inspected, transformed, and interpreted in different ways.

foldMap

The method that interprets a Free monad program. It traverses the instruction tree, applies a natural transformation (interpreter) to each Suspend node, and combines results using the target monad's flatMap. Stack-safe via internal trampolining. This is how you "run" the program: program.foldMap(interpreter, monad).

Glossary

For quick-reference definitions, see the Effect Handlers glossary entries.


Where Effect Handlers Shine (and Where They Don't)

When to Use Effect Handlers

ScenarioWhy Handlers Help
Services with multiple external dependenciesEach dependency becomes an effect algebra; interpreters handle them uniformly
Multiple execution modes (production, test, audit, dry-run)Same program, different interpreters
Audit and replay requirementsProgram tree can be logged, serialised, and replayed
Mock-free testingId monad interpreters return pure values; no mocking framework needed
Compile-time exhaustivenessSealed interfaces guarantee every operation is handled

When Not to Use Effect Handlers

ScenarioWhy Simpler Approaches Suffice
Simple CRUD servicesSpring DI works well; the overhead of effect algebras is not justified
Performance-critical hot pathsFree monad allocates intermediate objects; measure before committing
Single-interpretation servicesIf you only run in production mode, the abstraction adds complexity without benefit
Teams new to the patternIntroduce gradually; start with Effect Paths and Optics first

How It Fits Spring

The functional core / imperative shell pattern applies directly:

┌───────────────────────────────────────────────────┐
│              Spring Controller                    │
│              (imperative shell)                   │
│                                                   │
│   Chooses interpreter based on context:           │
│   - Production: IO interpreters                   │
│   - Quote mode: Id interpreters with fee calc     │
│   - Testing:    Id interpreters with fixed values │
│                                                   │
│   ┌───────────────────────────────────────────┐   │
│   │         Free Monad Program                │   │
│   │         (functional core)                 │   │
│   │                                           │   │
│   │   Pure business logic                     │   │
│   │   No side effects                         │   │
│   │   Uses Optics for data navigation         │   │
│   │   Uses Effect Paths for error handling    │   │
│   └───────────────────────────────────────────┘   │
└───────────────────────────────────────────────────┘

The Free program is the functional core: pure, testable, inspectable. The Spring controller is the imperative shell that selects the interpreter and executes the result. Optics handle data navigation within the program; Effect Paths handle error propagation at the edges.

FeatureSpring DIEffect Handlers
Program inspectionNoYes (ProgramAnalyser)
Exhaustive checkingNoYes (sealed interfaces)
Multiple interpretationsManual wiringBuilt-in (foldMap)
Compositional decorationLimited (AOP)Yes (interpreter wrapping)
Mock-free testingNo (needs Mockito)Yes (Id monad)

What's Next

See Also

  • Effect Handler Reference: Technical reference for @EffectAlgebra, @ComposeEffects, Free monad programs, and interpreter patterns
  • Payment Processing Example: Complete worked example with four interpretation modes (production, testing, quote, high-risk decline)
  • FreePath: Fluent Effect Path API wrapper for Free monad programs

Previous: Production Readiness | Next: Effect Handler Reference