Context vs ConfigContext: Choosing the Right Tool

"If the rule you followed brought you to this, of what use was the rule?"

-- Anton Chigurh, No Country for Old Men

Both Context<R, A> and ConfigContext<F, R, A> solve the problem of accessing environment values without threading parameters through every function. Both enable clean, composable code. Both support testing through dependency injection. Yet they solve subtly different problems, and using the wrong one leads to subtle bugs or unnecessary complexity.

"A story has no beginning or end: arbitrarily one chooses that moment of experience from which to look back or from which to look ahead."

-- Graham Greene, The End of the Affair

The choice between them isn't about which is "better"; it's about where your story begins. Does your environment value exist at application startup, passed through a call chain? Or does it flow implicitly through thread scopes, inherited by forked virtual threads? The answer determines your tool.

What You'll Learn

  • The fundamental difference between Context and ConfigContext
  • When thread propagation semantics matter
  • How to choose the right tool for common scenarios
  • Using both together in the same application
  • Migration patterns between the two approaches

The Core Difference

ConfigContext: Explicit Parameter Passing

ConfigContext<F, R, A> wraps ReaderT<F, R, A>: a monad transformer that threads an environment R through a computation. The environment is provided explicitly when you run the computation.

// Define a computation that needs configuration
ConfigContext<IOKind.Witness, DatabaseConfig, User> fetchUser =
    ConfigContext.io(config ->
        userRepository.findById(userId, config.connectionString()));

// Provide configuration at runtime -- must pass explicitly
User user = fetchUser.runWithSync(productionConfig);

Key characteristic: The configuration flows through the call chain because you pass it at runWithSync(). Any code that needs the config must be part of the ConfigContext computation.

Context: Implicit Thread-Scoped Propagation

Context<R, A> reads from a ScopedValue<R>: Java's thread-scoped value container. The value is bound to a scope and automatically available to all code in that scope, including forked virtual threads.

// Define a scoped value
static final ScopedValue<DatabaseConfig> DB_CONFIG = ScopedValue.newInstance();

// Define a computation that reads from it
Context<DatabaseConfig, User> fetchUser =
    Context.asks(DB_CONFIG, config ->
        userRepository.findById(userId, config.connectionString()));

// Bind value to a scope -- implicitly available everywhere in scope
User user = ScopedValue
    .where(DB_CONFIG, productionConfig)
    .call(() -> fetchUser.run());

Key characteristic: The configuration is available implicitly within the scope. Code doesn't need to be part of a computation chain; any code executing in the scope can access the value.


When Thread Propagation Matters

The critical difference emerges with virtual threads and structured concurrency.

ConfigContext: Must Pass Explicitly to Forked Tasks

ConfigContext<IOKind.Witness, RequestInfo, Result> process =
    ConfigContext.io(requestInfo -> {
        // requestInfo is available here...

        return Scope.<PartialResult>allSucceed()
            .fork(() -> {
                // ❌ requestInfo is NOT available here!
                // Forked virtual threads don't inherit ConfigContext
                return fetchData();
            })
            .fork(() -> {
                // ❌ Also not available here
                return fetchMoreData();
            })
            .join(Result::combine)
            .run();
    });

To propagate ConfigContext values to forked tasks, you must pass them explicitly:

ConfigContext<IOKind.Witness, RequestInfo, Result> process =
    ConfigContext.io(requestInfo -> {
        // Must capture and pass explicitly
        return Scope.<PartialResult>allSucceed()
            .fork(() -> fetchData(requestInfo))      // Pass explicitly
            .fork(() -> fetchMoreData(requestInfo))  // Pass explicitly
            .join(Result::combine)
            .run();
    });

Context: Automatic Inheritance in Forked Tasks

ScopedValue<RequestInfo> REQUEST = ScopedValue.newInstance();

VTask<Result> process = VTask.delay(() -> {
    return Scope.<PartialResult>allSucceed()
        .fork(() -> {
            // ✓ REQUEST is automatically available!
            RequestInfo info = REQUEST.get();
            return fetchData(info);
        })
        .fork(() -> {
            // ✓ Also available here
            RequestInfo info = REQUEST.get();
            return fetchMoreData(info);
        })
        .join(Result::combine)
        .run();
});

// Bind once, propagates to all forked tasks
Result result = ScopedValue
    .where(REQUEST, requestInfo)
    .call(() -> process.run());
┌─────────────────────────────────────────────────────────────────────┐
│                   Thread Propagation Comparison                     │
│                                                                     │
│   ConfigContext (ReaderT)              Context (ScopedValue)        │
│   ─────────────────────                ─────────────────────        │
│                                                                     │
│   runWithSync(config)                  ScopedValue.where(KEY, val)  │
│         │                                      │                    │
│         ▼                                      ▼                    │
│   ┌───────────┐                        ┌───────────┐                │
│   │  Parent   │                        │  Parent   │                │
│   │config = ✓ │                        │ KEY = ✓   │                │
│   └─────┬─────┘                        └─────┬─────┘                │
│         │                                    │                      │
│    fork │ fork                          fork │ fork                 │
│         │                                    │                      │
│   ┌─────┴─────┐                        ┌─────┴─────┐                │
│   │           │                        │           │                │
│   ▼           ▼                        ▼           ▼                │
│ ┌─────┐   ┌─────┐                  ┌─────┐   ┌─────┐                │
│ │Child│   │Child│                  │Child│   │Child│                │
│ │ ❌   │   │ ❌   │                  │ ✓   │   │ ✓   │                │
│ │     │   │     │                  │KEY  │   │KEY  │                │
│ │Must │   │Must │                  │auto │   │auto │                │
│ │pass │   │pass │                  │     │   │     │                │
│ └─────┘   └─────┘                  └─────┘   └─────┘                │
│                                                                     │
│   Children don't inherit            Children inherit automatically  │
│   Must pass explicitly              via ScopedValue binding         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Decision Guide

The Key Question

"Does this value need to automatically propagate to child virtual threads?"

If yes → Use Context with ScopedValue If no → Use ConfigContext (or plain parameter passing)

Scenario-Based Recommendations

ScenarioRecommendedReasoning
Request trace IDContextMust follow forked tasks for distributed tracing
User authenticationContextSecurity context must propagate to all operations
Request localeContextFormatting should be consistent across parallel ops
Database configConfigContextSet once at startup, no thread propagation needed
Feature flagsConfigContextApp-level config, doesn't need per-thread inheritance
API base URLsConfigContextStatic configuration, passed explicitly
Tenant ID (multi-tenant)ContextMust propagate to all data access operations
Transaction contextContextAll operations must participate in same transaction
Logging configurationConfigContextStatic, doesn't vary per request
Request deadline/timeoutContextMust be visible to all forked operations

Decision Flowchart

                    What kind of value is it?
                              │
              ┌───────────────┴───────────────┐
              │                               │
       Per-Request                    Application-Level
       (varies per call)              (same for all calls)
              │                               │
              │                               ▼
              │                        ConfigContext
              │                     (or plain parameters)
              │
              ▼
    Will you fork virtual threads?
              │
       ┌──────┴──────┐
       │             │
      Yes            No
       │             │
       ▼             ▼
    Context     Either works
                (Context for consistency,
                 ConfigContext if already using)

Detailed Comparison

AspectConfigContextContext
Underlying mechanismReaderT monad transformerScopedValue API
Java version requiredAnyJava 21+ (preview), Java 25+ (final)
Thread inheritanceNo (must pass explicitly)Yes (automatic)
Scope definitionrunWithSync(value) callScopedValue.where().run() block
Multiple valuesSingle R type (use record for multiple)Multiple ScopedValues, each independent
Type safetyCompile-time via genericsRuntime via ScopedValue.get()
Effect integrationBuilt-in (IOKind.Witness)Convert with toVTask()
ComposabilityVia flatMap, mapVia flatMap, map
TestingPass mock at runWithSync()Bind mock in ScopedValue.where()
LayerLayer 2 (Effect Context)Core effect type

Common Patterns

Pattern 1: Application Config with ConfigContext

// Define configuration record
record AppConfig(
    String databaseUrl,
    String apiBaseUrl,
    int maxConnections,
    Duration timeout
) {}

// Use ConfigContext for app-level config
public class UserService {
    public ConfigContext<IOKind.Witness, AppConfig, User> getUser(String id) {
        return ConfigContext.io(config -> {
            var connection = connect(config.databaseUrl());
            return connection.query("SELECT * FROM users WHERE id = ?", id);
        });
    }
}

// At application startup
AppConfig config = loadConfig();
User user = userService.getUser("123").runWithSync(config);

Pattern 2: Request Context with Context

// Define scoped values for request data
public final class RequestContext {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
}

// Use Context for request-scoped data
public class OrderService {
    public VTask<Order> createOrder(OrderRequest request) {
        return VTask.delay(() -> {
            String traceId = RequestContext.TRACE_ID.get();
            String userId = RequestContext.USER_ID.get();

            log.info("[{}] Creating order for user {}", traceId, userId);
            return orderRepository.create(request, userId);
        });
    }
}

// At request handling
public Response handleRequest(HttpRequest request) {
    return ScopedValue
        .where(RequestContext.TRACE_ID, request.traceId())
        .where(RequestContext.USER_ID, request.userId())
        .call(() -> orderService.createOrder(parseBody(request)).runSafe());
}

Pattern 3: Using Both Together

Most applications need both: application-level configuration and request-scoped context.

public class OrderController {

    // App config via ConfigContext
    private final ConfigContext<IOKind.Witness, AppConfig, OrderService> serviceFactory =
        ConfigContext.io(config -> new OrderService(config.databaseUrl()));

    // Request handler combines both
    public Response createOrder(HttpRequest request, AppConfig appConfig) {
        // 1. Create service with app config
        OrderService service = serviceFactory.runWithSync(appConfig);

        // 2. Bind request context and process
        return ScopedValue
            .where(RequestContext.TRACE_ID, request.traceId())
            .where(RequestContext.USER_ID, extractUserId(request))
            .where(SecurityContext.PRINCIPAL, authenticate(request))
            .call(() -> {
                // Service methods can read from RequestContext
                Order order = service.createOrder(parseBody(request)).run();
                return Response.ok(order);
            });
    }
}
┌─────────────────────────────────────────────────────────────────────┐
│                    Combined Usage Pattern                           │
│                                                                     │
│   Application Startup                                               │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │  AppConfig loaded from environment/files                    │   │
│   │  Services created with ConfigContext.runWithSync(config)    │   │
│   └─────────────────────────────────────────────────────────────┘   │
│                              │                                      │
│                              ▼                                      │
│   Per Request                                                       │
│   ┌─────────────────────────────────────────────────────────────┐   │
│   │  ScopedValue.where(TRACE_ID, ...)                           │   │
│   │             .where(USER_ID, ...)                            │   │
│   │             .where(PRINCIPAL, ...)                          │   │
│   │             .call(() -> {                                   │   │
│   │                 // Services use app config (injected)       │   │
│   │                 // Operations read request context          │   │
│   │                 // Forked tasks inherit request context     │   │
│   │             });                                             │   │
│   └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│   ConfigContext: Application-level, passed at startup               │
│   Context: Request-level, bound per request, inherits in forks      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Migration Patterns

From ConfigContext to Context

If you find yourself manually passing context to every forked task, consider migrating to Context:

// Before: Manual propagation
ConfigContext<IOKind.Witness, RequestInfo, Result> process =
    ConfigContext.io(info -> {
        return Scope.<Data>allSucceed()
            .fork(() -> fetch1(info))  // Must pass
            .fork(() -> fetch2(info))  // Must pass
            .fork(() -> fetch3(info))  // Must pass
            .join(Result::combine)
            .run();
    });

// After: Automatic propagation
static final ScopedValue<RequestInfo> REQUEST_INFO = ScopedValue.newInstance();

VTask<Result> process = Scope.<Data>allSucceed()
    .fork(() -> {
        RequestInfo info = REQUEST_INFO.get();  // Available automatically
        return fetch1(info);
    })
    .fork(() -> {
        RequestInfo info = REQUEST_INFO.get();  // Available automatically
        return fetch2(info);
    })
    .fork(() -> {
        RequestInfo info = REQUEST_INFO.get();  // Available automatically
        return fetch3(info);
    })
    .join(Result::combine);

// Usage
Result result = ScopedValue
    .where(REQUEST_INFO, requestInfo)
    .call(() -> process.run());

From Context to ConfigContext

If you're using Context for static configuration that doesn't need thread propagation, ConfigContext may be simpler:

// Before: ScopedValue for static config (overkill)
static final ScopedValue<DatabaseConfig> DB_CONFIG = ScopedValue.newInstance();

VTask<User> getUser = VTask.delay(() -> {
    DatabaseConfig config = DB_CONFIG.get();
    return userRepo.find(config);
});

// Must bind everywhere
ScopedValue.where(DB_CONFIG, config).call(() -> getUser.run());

// After: ConfigContext for static config (appropriate)
ConfigContext<IOKind.Witness, DatabaseConfig, User> getUser =
    ConfigContext.io(config -> userRepo.find(config));

// Pass once
User user = getUser.runWithSync(config);

Anti-Patterns to Avoid

Anti-Pattern 1: Using ConfigContext for Request Data

// ❌ Bad: Request data via ConfigContext
ConfigContext<IOKind.Witness, RequestInfo, Response> handler = ...;

// Problem: Every forked task needs explicit passing
// Problem: Easy to forget, causing bugs

Anti-Pattern 2: Using Context for Static Config

// ❌ Bad: Static config via ScopedValue
static final ScopedValue<DatabaseUrl> DB_URL = ScopedValue.newInstance();

// Problem: Must bind at every entry point
// Problem: No benefit from thread inheritance for static data
// Problem: More boilerplate than necessary

Anti-Pattern 3: Mixing Indiscriminately

// ❌ Bad: Some request data in ConfigContext, some in Context
// Inconsistent, confusing, easy to make mistakes

// ✓ Good: Clear separation
// - All request-scoped data: Context (ScopedValue)
// - All app-level config: ConfigContext (or constructor injection)

Summary

Use CaseToolWhy
Request trace/correlation IDsContextMust propagate to forked tasks
User authentication/securityContextMust propagate to forked tasks
Request locale/timezoneContextMust propagate to forked tasks
Tenant ID (multi-tenant)ContextMust propagate to forked tasks
Database connection configConfigContextStatic, no propagation needed
API endpoints/URLsConfigContextStatic, no propagation needed
Feature flagsConfigContextStatic, no propagation needed
Logging configurationConfigContextStatic, no propagation needed

The rule of thumb:

  • Changes per-request and needs thread inheritanceContext
  • Set at startup and passed through call chainConfigContext

See Also


Previous: SecurityContext Patterns Next: Effect Contexts Overview