Capstone: A Multi-Capability Workflow

"The chief end of man is to compose effects."

-- A modernised paraphrase of Westminster, with apologies

What You'll Learn

  • How a real workflow combines typed errors, configuration, audit, and async execution
  • A complete before/after comparison: imperative Java vs MTL vs Effect Path API
  • Why the MTL capability style is the right answer for polymorphic library code
  • When the Effect Path API equivalent reads more naturally for application code

The Scenario

You are writing the order-processing layer of an e-commerce service. Every order goes through three steps:

  1. Validate the order (sync, may fail with InvalidOrder)
  2. Reserve stock against an inventory service (async, reads the inventory URL from config, may fail with OutOfStock)
  3. Charge the customer through a payment gateway (async, reads the gateway URL and API key from config, may fail with PaymentDeclined)

Each step appends an audit entry to a running log so the operations team can reconstruct what happened to any order.

record AppConfig(String inventoryUrl, String paymentUrl, String apiKey) {}

record AuditEntry(String step, String detail) {}

record Order(String id, String sku, int quantity, double amount) {}

record Receipt(String orderId, String confirmationCode) {}

sealed interface DomainError {
    record InvalidOrder(String reason) implements DomainError {}
    record OutOfStock(String sku) implements DomainError {}
    record PaymentDeclined(String reason) implements DomainError {}
}

The pipeline must:

  • Read AppConfig for service URLs and the API key
  • Append an AuditEntry at each step
  • Short-circuit with the appropriate DomainError if any step fails
  • Run asynchronously throughout

The Imperative Approach

The straightforward Java solution threads three concerns through every signature:

CompletableFuture<Either<DomainError, Receipt>> processOrder(
        Order order, AppConfig config, List<AuditEntry> log) {

    // 1. Validate
    if (order.quantity() <= 0) {
        log.add(new AuditEntry("validate", "rejected: quantity must be positive"));
        return CompletableFuture.completedFuture(
            Either.left(new DomainError.InvalidOrder("quantity must be positive")));
    }
    log.add(new AuditEntry("validate", "ok: " + order.id()));

    // 2. Reserve stock (async)
    return reserveAsync(config.inventoryUrl(), order.sku(), order.quantity())
        .thenCompose(reserveResult -> {
            if (reserveResult.isLeft()) {
                log.add(new AuditEntry("reserve", "failed: " + reserveResult.getLeft()));
                return CompletableFuture.completedFuture(Either.left(reserveResult.getLeft()));
            }
            log.add(new AuditEntry("reserve", "ok: sku=" + order.sku()));

            // 3. Charge (async)
            return chargeAsync(config.paymentUrl(), config.apiKey(), order.amount())
                .thenApply(chargeResult -> {
                    if (chargeResult.isLeft()) {
                        log.add(new AuditEntry("charge", "declined"));
                        return Either.<DomainError, Receipt>left(chargeResult.getLeft());
                    }
                    log.add(new AuditEntry("charge", "ok: " + chargeResult.getRight()));
                    return Either.<DomainError, Receipt>right(
                        new Receipt(order.id(), chargeResult.getRight()));
                });
        });
}

The business logic ("validate, then reserve, then charge") is buried under explicit thenCompose/thenApply plumbing. The audit log is a mutable parameter that every caller must remember to thread. The AppConfig is passed through every helper. Three concerns crash through every line.


The MTL Approach

When the same logic must run against different concrete stacks (production async, synchronous tests, audit-only interpreter), write it once against capability interfaces:

import org.higherkindedj.hkt.MonadError;
import org.higherkindedj.hkt.MonadReader;
import org.higherkindedj.hkt.MonadWriter;
import org.higherkindedj.hkt.expression.For;

<F extends WitnessArity<TypeArity.Unary>> Kind<F, Receipt> processOrder(
        Order order,
        MonadReader<F, AppConfig> env,
        MonadWriter<F, List<AuditEntry>> audit,
        MonadError<F, DomainError> errors) {

    return For.from(env, validate(order, errors, audit))
        .from(_ -> env.ask())
        .from((_, config) -> reserve(order, config.inventoryUrl(), errors, audit))
        .from((_, config, _) -> charge(order, config, errors, audit))
        .yield((_, _, _, code) -> new Receipt(order.id(), code));
}

The function declares exactly the capabilities it needs: read-only AppConfig, append-only List<AuditEntry>, typed error of DomainError. It says nothing about how those capabilities are assembled, only that they are available.

The helper steps follow the same shape:

<F extends WitnessArity<TypeArity.Unary>> Kind<F, Unit> validate(
        Order order,
        MonadError<F, DomainError> errors,
        MonadWriter<F, List<AuditEntry>> audit) {
    if (order.quantity() <= 0) {
        return For.from(audit, audit.tell(List.of(new AuditEntry("validate", "rejected"))))
            .from(_ -> errors.raiseError(new DomainError.InvalidOrder("quantity must be positive")))
            .yield((_, e) -> e);
    }
    return audit.tell(List.of(new AuditEntry("validate", "ok: " + order.id())));
}

validate declares only the two capabilities it actually uses (MonadError and MonadWriter). It does not see, and cannot accidentally depend on, the MonadReader capability that other steps need.

Running the polymorphic function

To execute processOrder, the caller assembles a concrete stack that provides all three capabilities. For tests, a synchronous stack over Id is enough:

// Compose three transformers over Id: ReaderT outside, WriterT in the middle, EitherT inside.
// Build the concrete monad once at the boundary; pass it as the three capability views.
var stack = buildTestStack(idMonad, listMonoid);

Kind<TestStack.Witness, Receipt> program =
    processOrder(order, stack.reader(), stack.writer(), stack.errors());

TestStack.Result<Receipt> result = stack.run(program, prodConfig);
//   result.value()  -> Either<DomainError, Receipt>
//   result.audit()  -> List<AuditEntry>

The full mechanics of stacking three transformers live in Combining Capabilities. What matters here is that the workflow definition never named a stack: the same processOrder function runs against a production stack over CompletableFuture, a test stack over Id, an audit-only interpreter, or any future stack a caller invents.


The Effect Path Approach

For workflows where you do not need stack polymorphism (most application code), the Effect Path API expresses the same pipeline more directly. Each capability has its own Path type, and a ForPath comprehension threads them together:

import org.higherkindedj.hkt.effect.EitherPath;
import org.higherkindedj.hkt.effect.Path;
import org.higherkindedj.hkt.effect.WriterPath;

EitherPath<DomainError, Receipt> processOrder(Order order, AppConfig config) {
    return validate(order)
        .via(_ -> reserve(order, config.inventoryUrl()))
        .via(_ -> charge(order, config))
        .map(code -> new Receipt(order.id(), code));
}

EitherPath<DomainError, Unit> validate(Order order) {
    return order.quantity() <= 0
        ? Path.<DomainError, Unit>left(new DomainError.InvalidOrder("quantity must be positive"))
        : Path.right(Unit.INSTANCE);
}

EitherPath carries the typed errors. The audit log can be threaded with a separate WriterPath chain, or recorded out-of-band via a logging context. Configuration is passed as a plain parameter because it does not vary mid-workflow; if you need to compose it through For, swap in ReaderPath<AppConfig, A>.

The Path API trades the polymorphism of MTL for shorter, more concrete code. When you control the call site and know the outer monad, that is usually a better trade.


What Happened

The railway diagram for the success-and-failure tracks of the polymorphic version:

    Success ═══●══════════════●══════════════●══════════●═══▶  Receipt
             validate       reserve         charge       map
             (errors)        (env+errors)    (env+errors)
              │                │                │
              ▼ telltelltell
    Audit   ──●──────────────●──────────────●─────────────────▶  List<AuditEntry>
             "validate ok"  "reserve ok"   "charge ok"
              ╲                ╲                ╲
               ╲                ╲                ╲  Left: skip remaining steps
                ╲                ╲                ╲
    Failure ──●────────────────●──────────────●────────────────▶  DomainError
             InvalidOrder    OutOfStock     PaymentDeclined

The audit track and the value track advance together. The error track absorbs any Left from any step and short-circuits the rest. All three concerns coexist in one comprehension because the capabilities composed through MTL share the same monad witness F.


Side-by-side

AspectImperativeMTL polymorphicEffect Path
Lines of plumbing~25~10~6
Stack polymorphismNoneFullNone
Capability declarationsImplicit (parameters)Explicit (interfaces)Implicit (Path types)
Reusable across stacksRewriteYesNo
Best forSmall, throwaway codeLibrary codeApplication code

The MTL version pays for its polymorphism with explicit capability parameters and the cost of constructing a concrete stack at the call site. That is the right cost when callers will plug their own outer monad underneath your function: you cannot know in advance whether they want CompletableFuture, IO, VTask, or something else.

The Effect Path version pays nothing for that polymorphism it never uses, and reads more directly. When you control the runtime, that is the better trade.


Key Takeaways

Key Takeaways

  • MTL is the polymorphism story. When the same business logic must run against many concrete stacks, declare capabilities on the function signature and let the caller assemble a stack that satisfies them. The function never names a transformer.
  • Effect Paths are the directness story. When you control the call site and know the outer monad, the Path API expresses the same workflow with less ceremony. Most application code lives here.
  • Multiple concerns ride together. The audit track, the configuration track, and the error track all compose through the same For body because every step shares one monad witness. The body of the comprehension reads like ordinary sequential code.
  • The cost of polymorphism is the cost of stack assembly. The MTL function is short. Constructing a concrete stack that implements MonadReader<F, AppConfig>, MonadWriter<F, List<AuditEntry>>, and MonadError<F, DomainError> over CompletableFuture is not. Reach for MTL when that cost buys you something the Path API cannot.

See Also


Previous: Common Compiler Errors Next: Foundations