MTL Capabilities:

Writing Code That Doesn't Care How Effects Are Assembled

"I am the sum total of everything that went before me, of all I have been seen done, of everything done-to-me."

-- Salman Rushdie, Midnight's Children

Rushdie's narrator insists that identity does not depend on the specifics of its construction. The same principle applies to effectful code. A function that reads configuration should not care whether the environment arrives through ReaderT<CompletableFuture, ...>, ReaderT<IO, ...>, or some custom stack. It should declare what it needs ("I need to read an environment") and let the caller decide how that capability is provided.

That is what MTL-style capability interfaces do. They separate the declaration of an effect from its implementation, so your business logic composes against abstract capabilities rather than concrete transformer stacks.

See Example Code:

What You'll Learn

  • Why coding against concrete transformer stacks creates coupling that makes refactoring painful
  • How MonadReader, MonadState, and MonadWriter declare capabilities without fixing the stack
  • Writing polymorphic functions that work with any stack providing the required capabilities
  • When to use MTL interfaces and when concrete transformers are the simpler choice

The Problem: Locked to a Stack

Consider a function that reads a URL from configuration and fetches a user profile:

// Locked to ReaderT over CompletableFuture — cannot be reused with IO, Id, or VTask
ReaderT<CompletableFutureKind.Witness, AppConfig, UserProfile>
    fetchProfile(String userId) {
  return ReaderT.of(config ->
      FUTURE.widen(CompletableFuture.supplyAsync(() ->
          callApi(config.apiUrl(), userId))));
}

This works, but it welds the function to CompletableFuture. Want to test it with Id? Rewrite it. Want to use it in a VTask pipeline? Rewrite it again. The business logic ("read the API URL from config, call the service") is identical each time; only the plumbing changes.

The same problem arises with StateT and WriterT. Any function that names a specific transformer stack becomes locked to that stack, even when the business logic is entirely independent of the choice.

The Solution: Declare Capabilities, Not Stacks

// Works with ANY monad that provides environment access
<F extends WitnessArity<TypeArity.Unary>> Kind<F, UserProfile>
    fetchProfile(MonadReader<F, AppConfig> env, String userId) {
  return For.from(env, env.ask())
      .yield(config -> callApi(config.apiUrl(), userId));
}

The function says: "Give me something that can read an AppConfig, and I will produce a UserProfile inside whatever monad you choose." The caller provides a ReaderTMonadReader, a custom test instance, or any future implementation that satisfies MonadReader. The function never changes.

    ┌──────────────────────────────────────────────────────────────┐
    │  CONCRETE STACK (before MTL)                                 │
    │                                                              │
    │  fetchProfile : ReaderT<Future, AppConfig, UserProfile>      │
    │                                                              │
    │  Locked to Future. Must rewrite for Id, IO, VTask.           │
    └──────────────────────────────────────────────────────────────┘

                              ▼  refactor  ▼

    ┌──────────────────────────────────────────────────────────────┐
    │  MTL CAPABILITY (after)                                      │
    │                                                              │
    │  fetchProfile : MonadReader<F, AppConfig> → Kind<F, Profile> │
    │                                                              │
    │  Works with ReaderT<Future>, ReaderT<IO>, ReaderT<Id>, ...   │
    │  Business logic unchanged. Only the instance varies.         │
    └──────────────────────────────────────────────────────────────┘

This is the same idea behind Java interfaces like List vs ArrayList. You program to the capability, not the implementation. MTL applies that principle to monadic effects.


The Capability Family

Higher-Kinded-J provides three MTL-style interfaces, each extending Monad<F>. Together with the existing MonadError, they form a family of four capability abstractions:

    ┌────────────────────────────────────────────────────────────┐
    │                        Monad<F>                            │
    │                     (of, map, flatMap)                     │
    │                                                            │
    │         ┌──────────────┬──────────────┬──────────────┐     │
    │         │              │              │              │     │
    │         ▼              ▼              ▼              ▼     │
    │   MonadReader     MonadState     MonadWriter    MonadError │
    │   <F, R>          <F, S>         <F, W>         <F, E>     │
    │                                                            │
    │   ask()           get()          tell(w)       raiseError()│
    │   local(f, ma)    put(s)         listen(ma)    handleError │
    │   reader(f)       modify(f)      pass(ma)                  │
    │   asks(f)         gets(f)        listens(f,ma)             │
    │                   inspect(f)     censor(f, ma)             │
    └────────────────────────────────────────────────────────────┘

Each interface declares a single, orthogonal capability. A function can require one, two, or all of them by accepting multiple parameters. This is the MTL pattern: capabilities as type class constraints.

CapabilityWhat It ProvidesAnalogy
MonadReaderRead-only access to a shared environmentA global AppConfig everyone can read but nobody can change
MonadStateRead-write access to threaded stateA mutable variable that passes through each step
MonadWriterAppend-only output accumulationA log file that each step writes to but never reads
MonadErrorTyped error raising and recoveryA try-catch block with domain-specific error types

Because each interface extends Monad<F>, every MTL instance is also a full monad. You get of, map, flatMap, and ap for free alongside the capability-specific operations. No need for a separate monad parameter.


When to Use MTL vs Concrete Transformers

ScenarioRecommendation
Single module, one transformer stackConcrete transformer. Simpler. Less indirection.
Library code consumed by multiple callersMTL. Callers choose their own stack.
Polymorphic function used across different stacksMTL. Write once, interpret many ways.
Testing with simplified stacksMTL. Swap ReaderT<CompletableFuture> for ReaderT<Id> in tests.
Learning or prototypingConcrete transformer. Easier to follow the types.

MTL Is Not Always Better

MTL adds a layer of abstraction. If your entire application uses a single transformer stack and will never change it, the indirection of MTL buys you nothing. Use it when the abstraction pays for itself: in libraries, in polymorphic utility functions, and in code that genuinely needs to run against multiple stack configurations.


In This Section

  • MonadReader -- Read-only environment access. Inject configuration, database URLs, API keys, and other dependencies without threading parameters through every function.

  • MonadState -- Mutable state management. Read and update state as a computation progresses, with automatic state threading through flatMap chains.

  • MonadWriter -- Append-only output accumulation. Build audit trails, diagnostic logs, and computation summaries that travel with the result.

  • Combining Capabilities -- Using multiple MTL interfaces together. Concrete instances, ForState integration, and practical patterns for multi-capability functions.


Section Contents

  1. MonadReader -- Environment access and scoped modification
  2. MonadState -- State threading and mutation
  3. MonadWriter -- Output accumulation and log inspection
  4. Combining Capabilities -- Multi-capability functions and concrete instances

Previous: WriterT Next: MonadReader