Advanced Effects

"We are surrounded by huge institutions we can never penetrate... They've made themselves user-friendly, but they define the tastes to which we conform. They're rather subtle, subservient tyrannies, but no less sinister for that."

— J.G. Ballard

Ballard was describing the modern landscape of invisible systems: banks, networks, bureaucracies that shape our choices while remaining opaque. Software faces the same challenge. Configuration systems, database connections, logging infrastructure. These are the "institutions" your code must navigate. They're everywhere, they're necessary, and handling them explicitly at every call site creates clutter that obscures your actual logic.

This chapter introduces three effect types that model these pervasive concerns: Reader for environment access, State for threaded computation state, and Writer for accumulated output. Each represents a different kind of computational context that you'd otherwise pass explicitly through every function signature.

What You'll Learn

  • ReaderPath for dependency injection and environment access
  • WithStatePath for computations with mutable state
  • WriterPath for logging and accumulated output
  • How to compose these effects with other Path types
  • Patterns for real-world use: configuration, audit trails, and state machines

See Example Code

Advanced Feature

The Reader, State, and Writer Path types are an advanced part of the Effect Path API. They build on the core Path types covered earlier and require familiarity with those foundations.


ReaderPath: The Environment You Inherit

ReaderPath<R, A> wraps Reader<R, A>, representing a computation that needs access to an environment of type R to produce a value of type A.

Think of it as implicit parameter passing. Instead of threading a Config or DatabaseConnection through every method signature, you describe computations that assume the environment exists, then provide it once at the edge of your system.

Why Reader?

Consider a typical service method:

// Without Reader: environment threaded explicitly
public User getUser(String id, DbConnection db, Config config, Logger log) {
    log.debug("Fetching user: " + id);
    int timeout = config.getTimeout();
    return db.query("SELECT * FROM users WHERE id = ?", id);
}

Every function in the call chain needs these parameters. The signatures become cluttered; the actual logic is buried.

With Reader:

// With Reader: environment is implicit
public ReaderPath<AppEnv, User> getUser(String id) {
    return ReaderPath.ask()
        .via(env -> {
            env.logger().debug("Fetching user: " + id);
            return ReaderPath.pure(
                env.db().query("SELECT * FROM users WHERE id = ?", id)
            );
        });
}

The environment is accessed when needed but not passed explicitly. The method signature shows what it computes, not what it requires.

Creation

// Pure value (ignores environment)
ReaderPath<Config, String> pure = ReaderPath.pure("hello");

// Access the environment
ReaderPath<Config, Config> askAll = ReaderPath.ask();

// Project part of the environment
ReaderPath<Config, String> dbUrl = ReaderPath.asks(Config::databaseUrl);

// From a Reader function
ReaderPath<Config, Integer> timeout = ReaderPath.of(config -> config.timeout());

Core Operations

ReaderPath<Config, String> dbUrl = ReaderPath.asks(Config::databaseUrl);

// Transform
ReaderPath<Config, Integer> urlLength = dbUrl.map(String::length);

// Chain dependent computations
ReaderPath<Config, Connection> connection =
    dbUrl.via(url -> ReaderPath.of(config ->
        DriverManager.getConnection(url, config.username(), config.password())
    ));

Running a Reader

Eventually you must provide the environment:

Config config = loadConfig();

ReaderPath<Config, User> userPath = getUser("123");
User user = userPath.run(config);  // Provide environment here

The Reader executes with the given environment. All ask and asks calls within the computation receive this environment.

Local Environment Modification

Sometimes a sub-computation needs a modified environment:

ReaderPath<Config, Result> withTestMode =
    computation.local(config -> config.withTestMode(true));

The inner computation sees the modified environment; the outer computation is unaffected.

When to Use ReaderPath

ReaderPath is right when:

  • Multiple functions need the same "context" (config, connection, logger)
  • You want dependency injection without frameworks
  • Computations should be testable with different environments
  • You're building a DSL where environment is implicit

ReaderPath is wrong when:

  • The environment changes during computation: use StatePath
  • You need to accumulate results: use WriterPath
  • The environment is only needed in one place: just pass it directly

StatePath: Computation with Memory

StatePath<S, A> wraps State<S, A>, representing a computation that threads state through a sequence of operations. Each step can read the current state, produce a value, and update the state for subsequent steps.

Unlike mutable state, StatePath keeps everything pure: the "mutation" is actually a transformation that produces new state values.

Why State?

Consider tracking statistics through a pipeline:

// Without State: manual state threading
Stats stats1 = new Stats();
ResultA a = processA(input, stats1);
Stats stats2 = stats1.incrementProcessed();
ResultB b = processB(a, stats2);
Stats stats3 = stats2.incrementProcessed();
// ... and so on

With State:

// With State: automatic threading
StatePath<Stats, ResultC> pipeline =
    StatePath.of(processA(input))
        .via(a -> StatePath.modify(Stats::incrementProcessed)
            .then(() -> StatePath.of(processB(a))))
        .via(b -> StatePath.modify(Stats::incrementProcessed)
            .then(() -> StatePath.of(processC(b))));

Tuple2<Stats, ResultC> result = pipeline.run(Stats.initial());

The state threads through automatically. Each step can read it, modify it, or ignore it.

Creation

// Pure value (state unchanged)
StatePath<Counter, String> pure = StatePath.pure("hello");

// Get current state
StatePath<Counter, Counter> current = StatePath.get();

// Set new state (discards old)
StatePath<Counter, Unit> reset = StatePath.set(Counter.zero());

// Modify state
StatePath<Counter, Unit> increment = StatePath.modify(Counter::increment);

// Get and modify in one step
StatePath<Counter, Integer> getAndIncrement =
    StatePath.getAndModify(counter -> {
        int value = counter.value();
        return Tuple.of(counter.increment(), value);
    });

Core Operations

StatePath<Counter, Integer> current = StatePath.get().map(Counter::value);

// Chain with state threading
StatePath<Counter, String> counted =
    StatePath.modify(Counter::increment)
        .then(() -> StatePath.get())
        .map(c -> "Count: " + c.value());

// Combine independent state operations
StatePath<Counter, Result> combined =
    operationA.zipWith(operationB, Result::new);

Running State

Counter initial = Counter.zero();

StatePath<Counter, String> computation = ...;

// Get both final state and result
Tuple2<Counter, String> both = computation.run(initial);

// Get just the result
String result = computation.eval(initial);

// Get just the final state
Counter finalState = computation.exec(initial);

When to Use StatePath

StatePath is right when:

  • You need to accumulate or track information through a computation
  • Multiple operations must coordinate through shared state
  • You're implementing state machines or interpreters
  • You want mutable-like semantics with immutable guarantees

StatePath is wrong when:

  • State never changes: use ReaderPath
  • You're accumulating a log rather than replacing state: use WriterPath
  • The state is external (database, file): use IOPath

WriterPath: Accumulating Output

WriterPath<W, A> wraps Writer<W, A>, representing a computation that produces both a value and accumulated output. The output (type W) is combined using a Monoid, allowing automatic aggregation of logs, metrics, or any combinable data.

Why Writer?

Consider building an audit trail:

// Without Writer: manual log passing
public Tuple2<List<String>, User> createUser(UserInput input, List<String> log) {
    List<String> log2 = append(log, "Validating input");
    Validated validated = validate(input);
    List<String> log3 = append(log2, "Creating user record");
    User user = repository.save(validated);
    List<String> log4 = append(log3, "User created: " + user.id());
    return Tuple.of(log4, user);
}

With Writer:

// With Writer: automatic log accumulation
public WriterPath<List<String>, User> createUser(UserInput input) {
    return WriterPath.tell(List.of("Validating input"))
        .then(() -> WriterPath.pure(validate(input)))
        .via(validated -> WriterPath.tell(List.of("Creating user record"))
            .then(() -> WriterPath.pure(repository.save(validated))))
        .via(user -> WriterPath.tell(List.of("User created: " + user.id()))
            .map(unit -> user));
}

The log accumulates automatically. No explicit threading required.

Creation

// Pure value (empty log)
WriterPath<List<String>, Integer> pure = WriterPath.pure(42, Monoids.list());

// Write to log (no value)
WriterPath<List<String>, Unit> logged =
    WriterPath.tell(List.of("Something happened"), Monoids.list());

// Create with both value and log
WriterPath<List<String>, User> withLog =
    WriterPath.of(user, List.of("Created user"), Monoids.list());

The Monoid<W> parameter defines how log entries combine:

  • Monoids.list(): concatenate lists
  • Monoids.string(): concatenate strings
  • Custom monoids for metrics, events, etc.

Core Operations

WriterPath<List<String>, Integer> computation = ...;

// Transform value (log unchanged)
WriterPath<List<String>, String> formatted = computation.map(n -> "Value: " + n);

// Add to log
WriterPath<List<String>, Integer> withExtra =
    computation.tell(List.of("Extra info"));

// Chain with log accumulation
WriterPath<List<String>, Result> pipeline =
    stepOne()
        .via(a -> stepTwo(a))
        .via(b -> stepThree(b));
// Logs from all three steps combine automatically

Running Writer

WriterPath<List<String>, User> computation = createUser(input);

// Get both log and result
Tuple2<List<String>, User> both = computation.run();

// Get just the result
User user = computation.value();

// Get just the log
List<String> log = computation.written();

When to Use WriterPath

WriterPath is right when:

  • You're building audit trails or structured logs
  • Accumulating metrics or statistics
  • Collecting warnings or diagnostics alongside computation
  • Any scenario where output should aggregate, not replace

WriterPath is wrong when:

  • Output should replace previous output: use StatePath
  • You need to read accumulated output mid-computation: use StatePath
  • Output goes to external systems: use IOPath

Combining Advanced Effects

These effect types compose with each other and with the core Path types.

Reader + Either: Environment with Errors

// A computation that needs config and might fail
ReaderPath<Config, EitherPath<Error, User>> getUser(String id) {
    return ReaderPath.asks(Config::database)
        .map(db -> Path.either(db.findUser(id))
            .toEitherPath(() -> new Error.NotFound(id)));
}

State + Writer: State with Logging

// Track state and log what happens
public StatePath<GameState, WriterPath<List<Event>, Move>> makeMove(Position pos) {
    return StatePath.get()
        .via(state -> {
            Move move = calculateMove(state, pos);
            GameState newState = state.apply(move);
            return StatePath.set(newState)
                .map(unit -> WriterPath.of(
                    move,
                    List.of(new Event.MoveMade(pos, move)),
                    Monoids.list()
                ));
        });
}

Patterns: Configuration Service

public class ConfigurableService {
    public ReaderPath<ServiceConfig, EitherPath<Error, Result>> process(Request req) {
        return ReaderPath.ask()
            .via(config -> {
                if (!config.isEnabled()) {
                    return ReaderPath.pure(Path.left(new Error.ServiceDisabled()));
                }
                return ReaderPath.pure(
                    Path.tryOf(() -> doProcess(req, config))
                        .toEitherPath(Error.ProcessingFailed::new)
                );
            });
    }
}

// Usage
ServiceConfig config = loadConfig();
EitherPath<Error, Result> result = service.process(request).run(config);

Patterns: Audit Trail

public class AuditedRepository {
    public WriterPath<List<AuditEvent>, EitherPath<Error, User>> saveUser(User user) {
        return WriterPath.tell(List.of(new AuditEvent.AttemptSave(user.id())))
            .then(() -> {
                Either<Error, User> result = repository.save(user);
                if (result.isRight()) {
                    return WriterPath.of(
                        Path.right(result.getRight()),
                        List.of(new AuditEvent.SaveSucceeded(user.id())),
                        Monoids.list()
                    );
                } else {
                    return WriterPath.of(
                        Path.left(result.getLeft()),
                        List.of(new AuditEvent.SaveFailed(user.id(), result.getLeft())),
                        Monoids.list()
                    );
                }
            });
    }
}

Summary

Effect TypeModelsKey OperationsUse Case
ReaderPath<R, A>Environment accessask, asks, localConfig, DI
StatePath<S, A>Threaded stateget, set, modifyCounters, state machines
WriterPath<W, A>Accumulated outputtell, writtenLogging, audit trails

These effects handle the "invisible institutions" of software: the configuration that's everywhere, the state that threads through, the logs that accumulate. By making them explicit in the type system, you gain the same composability and predictability that the core Path types provide for error handling.

The systems remain subtle and pervasive, but no longer tyrannical.

See Also


Previous: Patterns and Recipes Next: Effect Contexts