The ReaderT Transformer:
Threading Configuration Through Effects
"No man is an island, entire of itself."
– John Donne, Meditation XVII
No computation is independent of its environment. ReaderT makes that dependency explicit and composable.
- How to combine dependency injection (
Reader) with other effects like async operations - Building configuration-dependent workflows that are also async or failable
- Using
Forcomprehensions withask,reader, andliftto keep witness types localised - Creating testable microservice clients with injected configuration
- When to use the
ReaderPathPath type or theMonadReadercapability instead of rawReaderT
For most use cases, ReaderPath<R, A> is the better starting point when the environment is the only effect. When you need polymorphic, stack-independent code, the MonadReader<F, R> capability is usually a better fit than the concrete ReaderT.
Reach for raw ReaderT only when you need to combine an environment with a specific outer monad that Path does not wrap, or when you are constructing your own MTL instance.
The Problem: Configuration Everywhere
Consider a service that needs API keys and URLs for every operation:
CompletableFuture<ServiceData> fetchData(AppConfig config, String itemId) {
return CompletableFuture.supplyAsync(() ->
callApi(config.apiKey(), config.serviceUrl(), itemId));
}
CompletableFuture<ProcessedData> processData(AppConfig config, ServiceData data) {
return CompletableFuture.supplyAsync(() ->
transform(data, config.apiKey()));
}
CompletableFuture<ProcessedData> workflow(AppConfig config) {
return fetchData(config, "item123")
.thenCompose(data -> processData(config, data));
}
The config parameter threads through every function signature, every call site, every test. It's noise that obscures the actual logic. Rename a config field, and you touch every function in the chain.
The Solution
With the Effect Path API (single effect)
If the environment is the only effect, ReaderPath is the simplest expression:
ReaderPath<AppConfig, ProcessedData> workflow() {
return Path.<AppConfig>ask()
.via(config -> Path.right(callApi(config.apiKey(), "item123")))
.via(data -> Path.<AppConfig>ask()
.map(config -> transform(data, config.apiKey())));
}
With raw ReaderT (combined effect)
When the environment must combine with another monad (here CompletableFuture):
var futureMonad = Instances.monadError(completableFuture());
var readerTMonad = Instances.readerT(futureMonad);
ReaderT<CompletableFutureKind.Witness, AppConfig, ServiceData> fetchDataRT(String itemId) {
return ReaderT.of(config ->
FUTURE.widen(CompletableFuture.supplyAsync(() ->
callApi(config.apiKey(), config.serviceUrl(), itemId))));
}
ReaderT<CompletableFutureKind.Witness, AppConfig, ProcessedData> processDataRT(ServiceData data) {
return ReaderT.reader(futureMonad,
config -> transform(data, config.apiKey()));
}
// Compose with For:
var workflowRT = For.from(readerTMonad, fetchDataRT("item123"))
.from(data -> processDataRT(data))
.yield((data, processed) -> processed);
// Provide the config once at the edge:
var result = FUTURE.join(READER_T.narrow(workflowRT).run().apply(prodConfig));
The AppConfig is threaded implicitly through flatMap. Each operation declares its dependency on AppConfig in its return type but never receives it as a parameter.
The Railway View
Value ═══●═══════════●═══════════●═══▶ ProcessedData (in F)
fetchData process map
(flatMap) (flatMap)
▲ ▲ ▲ config read at each step
AppConfig ═══╧═══════════╧═══════════╧═══▶ read-only, never modified
apiKey serviceUrl executor
│
run(prodConfig) provide the environment once at the edge
The configuration sits on its own track and is never consumed; each flatMap step reads from it without changing it. run().apply(config) supplies the environment at the boundary, after which the value track collapses into the outer monad F.
How ReaderT Works
ReaderT<F, R, A> wraps a function R -> Kind<F, A>. When you supply an environment of type R, you get back a monadic value Kind<F, A>.
┌──────────────────────────────────────────────────────────┐
│ ReaderT<CompletableFutureKind.Witness, AppConfig, A> │
│ │
│ ┌── AppConfig ──┐ │
│ │ │ │
│ │ apiKey │ │
│ │ serviceUrl │ │
│ │ executor │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌─── Function: R → Kind<F, A> ───────────────────┐ │
│ │ │ │
│ │ config → CompletableFuture<result> │ │
│ │ │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ flatMap ──▶ threads same config to next operation │
│ map ──────▶ transforms result, config unchanged │
│ ask ──────▶ gives you the config itself │
│ run ──────▶ provides config, returns F<A> │
└──────────────────────────────────────────────────────────┘
F: The witness type of the outer monad (e.g.CompletableFutureKind.Witness).R: The type of the read-only environment (configuration, dependencies).A: The type of the value produced, within the outer monadF.run: The core functionR -> Kind<F, A>.
public record ReaderT<F, R, A>(@NonNull Function<R, Kind<F, A>> run)
implements ReaderTKind<F, R, A> {
// ... static factory methods ...
}
Setting Up ReaderTMonad
The ReaderTMonad<F, R> class implements Monad<ReaderTKind.Witness<F, R>>, providing standard monadic operations. It requires a Monad<F> instance for the outer monad:
record AppConfig(String apiKey) {}
var readerTOptionalMonad = Instances.readerT(Instances.monadError(optional()));
Witness Type: ReaderTKind<F, R, A> extends Kind<ReaderTKind.Witness<F, R>, A>. The outer monad F and environment R are fixed; A is the variable value type.
KindHelper: ReaderTKindHelper provides READER_T.widen and READER_T.narrow for safe conversion. With For comprehensions you rarely need them; they appear at the boundaries when interoperating with raw flatMap chains.
Kind<ReaderTKind.Witness<F, R>, A> kind = READER_T.widen(readerT);
ReaderT<F, R, A> concrete = READER_T.narrow(kind);
Key Operations
| Operation | Behaviour |
|---|---|
readerTMonad.of(value) | Lifts a pure value; environment is ignored. Returns ReaderT(r -> outerMonad.of(value)) |
readerTMonad.map(f, kind) | Transforms the result value A -> B within the outer monad; environment unchanged |
readerTMonad.flatMap(f, kind) | Sequences operations; threads the same environment to the next step |
The MonadReader capability adds ask(), reader(f), and local(f, ma) on top.
Creating ReaderT Instances
var optMonad = Instances.monadError(optional());
record Config(String setting) {}
// 1. Directly from R -> F<A> function
var rt1 = ReaderT.<OptionalKind.Witness, Config, String>of(
cfg -> OPTIONAL.widen(Optional.of("Data based on " + cfg.setting())));
// 2. Lifting an existing F<A> (environment ignored)
Kind<OptionalKind.Witness, Integer> optionalValue = OPTIONAL.widen(Optional.of(123));
var rt2 = ReaderT.<OptionalKind.Witness, Config, Integer>lift(optMonad, optionalValue);
// 3. From R -> A function (result lifted into F)
var rt3 = ReaderT.<OptionalKind.Witness, Config, String>reader(
optMonad, cfg -> "Hello from " + cfg.setting());
// 4. ask: provides the environment itself as the result
var rt4 = ReaderT.<OptionalKind.Witness, Config>ask(optMonad);
Real-World Example: Configuration-Dependent Async Services
The problem: async service operations that all need an AppConfig (API keys, URLs, executor). Compose them without passing config through every call.
The solution:
record AppConfig(String apiKey, String serviceUrl, ExecutorService executor) {}
record ServiceData(String rawData) {}
record ProcessedData(String info) {}
var futureMonad = Instances.monadError(completableFuture());
var readerTMonad = Instances.readerT(futureMonad);
ReaderT<CompletableFutureKind.Witness, AppConfig, ServiceData> fetchServiceDataRT(String itemId) {
return ReaderT.of(config -> FUTURE.widen(
CompletableFuture.supplyAsync(() ->
new ServiceData("Raw data for " + itemId + " from " + config.serviceUrl()),
config.executor())));
}
ReaderT<CompletableFutureKind.Witness, AppConfig, ProcessedData> processDataRT(ServiceData data) {
return ReaderT.reader(futureMonad,
config -> new ProcessedData("Processed: " + data.rawData().toUpperCase()));
}
// Compose with For:
var workflowRT = For.from(readerTMonad, fetchServiceDataRT("item123"))
.from(data -> processDataRT(data))
.yield((data, processed) -> processed);
// Run with different configs:
var prodConfig = new AppConfig("prod_key", "https://api.prod.example.com", executor);
var stagingConfig = new AppConfig("staging_key", "https://api.staging.example.com", executor);
var prodResult = FUTURE.join(READER_T.narrow(workflowRT).run().apply(prodConfig));
var stagingResult = FUTURE.join(READER_T.narrow(workflowRT).run().apply(stagingConfig));
Why this works: the AppConfig is threaded through both operations by flatMap. The same workflow runs against production and staging by changing the argument to run().apply(...). The workflow definition is completely decoupled from the environment.
Using ask to Access Configuration Mid-Workflow
The problem: within a composed workflow, read a specific config value without restructuring the computation.
The solution:
var getConfigRT = ReaderT.<CompletableFutureKind.Witness, AppConfig>ask(futureMonad);
var serviceUrlRT = readerTMonad.map(
(AppConfig cfg) -> "Service URL: " + cfg.serviceUrl(),
getConfigRT);
var stagingUrl = FUTURE.join(READER_T.narrow(serviceUrlRT).run().apply(stagingConfig));
// → "Service URL: https://api.staging.example.com"
ReaderT.ask returns the entire environment as the result, which you can then map over to extract specific fields.
Fire-and-Forget Operations with Unit
The problem: some operations (logging, initialisation, sending metrics) depend on configuration but don't produce a meaningful return value.
The solution: use Unit as the value type:
ReaderT<CompletableFutureKind.Witness, AppConfig, Unit> initialiseComponentRT() {
return ReaderT.of(config -> FUTURE.widen(
CompletableFuture.runAsync(() -> {
System.out.println("Initialising with API Key: " + config.apiKey());
}, config.executor()).thenApply(v -> Unit.INSTANCE)));
}
var result = FUTURE.join(initialiseComponentRT().run().apply(prodConfig));
// → () (Unit.INSTANCE, signifying successful completion)
Transforming the Outer Monad with mapT
Sometimes you need to change the outer monad of a ReaderT without altering the environment-threading logic. Perhaps you want to switch from Optional to Id (collapsing optionality with a default), or apply a natural transformation to move between effect types.
Because ReaderT wraps a function rather than a value, mapT composes the transformation function after each result of run:
env ──> run() ──> Kind<F, A> ──> f ──> Kind<G, A>
│ │
└──── combined into new ReaderT<G, R, A> ──┘
ReaderT<OptionalKind.Witness, Config, String> optReader = ...;
var idReader = optReader.mapT(optKind -> {
Optional<String> opt = OPTIONAL.narrow(optKind);
return ID.widen(Id.of(opt.orElse("default")));
});
map transforms the value produced by the reader (the A in Kind<F, A>).
mapT transforms the outer monad that wraps each result, the F in R -> F<A>.
The environment-threading is completely unaffected.
- Forgetting to run: a
ReaderTis a description of a computation, not the computation itself. It does nothing until you call.run().apply(config). If your tests pass but nothing happens, check that you are running theReaderT. - Mutating the environment:
Rshould be immutable.ReaderTpasses the sameRto every operation in a chain. Mutating it would break referential transparency and produce unpredictable results. - Using
ReaderTwhen you need state changes: if the configuration changes between steps, you needStateT, notReaderT. The "Reader" inReaderTmeans read-only. - Reaching for the transformer when
ReaderPathwould do: if your only effect is the environment,ReaderPathis shorter and reads more naturally.
- ReaderPath / Advanced Effects - The Path-API equivalent
- MonadReader - The MTL capability for stack-independent code
- Stack Archetypes - The Context Stack archetype maps to
ReaderT/ReaderPath - Migration Cookbook - Side-by-side translations
- Monad Transformers - General concept and choosing the right transformer
- StateT - When your environment needs to change between steps
- EitherT - When operations can fail with typed errors
- Reader Monad for Dependency Injection - Practical examples without frameworks (12 min read)
- Functional Dependency Injection - Conference talk on Reader pattern (40 min watch)
- ReaderT Design Pattern - Michael Snoyman's production patterns (30 min read)
- A Fresh Perspective on Monads - Rock the JVM on composing monadic effects (20 min read)
The MonadReader capability that wraps ReaderT is exercised in Tutorial 04: Polymorphic Capabilities (MTL) (14 exercises, ~30-40 minutes).