ConfigContext: Dependency Injection Without the Framework
"The onlyes power is no power."
— Russell Hoban, Riddley Walker
Hoban's aphorism hints at a paradox: sometimes the most powerful abstractions are those that feel like nothing at all. ConfigContext provides dependency injection that doesn't feel like a framework—no annotations, no containers, no reflection. Dependencies flow through your code as naturally as function arguments, but without cluttering every signature.
- Threading configuration through effectful computations
- Accessing the environment with
ask()and projecting parts withmap() - Chaining dependent operations that all read the same config
- Locally modifying configuration with
local() - Adapting contexts to different config types with
contramap()
The Problem
Consider a service that needs configuration:
public class ReportService {
public Report generate(ReportConfig config, UserId userId) {
User user = userService.fetch(config.userServiceUrl(), config.timeout(), userId);
List<Order> orders = orderService.fetch(config.orderServiceUrl(), config.timeout(), user);
return buildReport(config.format(), user, orders);
}
}
The config parameter appears everywhere. Every method in the call chain needs it, even if it only uses one field. Signatures become cluttered. Testing requires constructing complete config objects even when you only care about one aspect.
Dependency injection frameworks solve this with containers and annotations—but those come with their own complexity: lifecycle management, circular dependency detection, runtime magic.
The Solution
ConfigContext threads configuration implicitly:
public ConfigContext<IOKind.Witness, ReportConfig, Report> generate(UserId userId) {
return ConfigContext.<ReportConfig>ask()
.via(config -> fetchUser(userId)) // Config available inside
.via(user -> fetchOrders(user)) // Config still available
.via(orders -> buildReport(orders)); // And here
}
// Execute by providing config once at the edge
Report report = generate(userId).runWithSync(config);
Configuration is declared once. Every computation in the chain can access it. No parameters threaded through signatures.
Creating ConfigContexts
ask: Access the Entire Configuration
ask() yields the configuration itself:
ConfigContext<IOKind.Witness, AppConfig, AppConfig> config = ConfigContext.ask();
// Then project what you need
ConfigContext<IOKind.Witness, AppConfig, String> apiUrl =
ConfigContext.<AppConfig>ask()
.map(AppConfig::apiUrl);
io: Compute Using Configuration
When your computation depends on config:
ConfigContext<IOKind.Witness, DbConfig, Connection> connection =
ConfigContext.io(config -> DriverManager.getConnection(
config.url(),
config.username(),
config.password()
));
The function receives the configuration and returns a value. Execution is immediate when the context runs.
ioDeferred: Defer Computation
When you want the IO aspect to be truly deferred:
ConfigContext<IOKind.Witness, ApiConfig, Response> response =
ConfigContext.ioDeferred(config -> () -> {
// This supplier is invoked later when runWith() is called
return httpClient.get(config.endpoint());
});
pure: Ignore Configuration
For values that don't need the config:
ConfigContext<IOKind.Witness, AnyConfig, Integer> fortyTwo =
ConfigContext.pure(42);
Transforming Values
map: Transform the Result
ConfigContext<IOKind.Witness, ServiceConfig, String> endpoint =
ConfigContext.<ServiceConfig>ask()
.map(ServiceConfig::baseUrl);
ConfigContext<IOKind.Witness, ServiceConfig, URI> uri =
endpoint.map(URI::create);
Chaining Computations
via: Chain Dependent Operations
record AppConfig(String userServiceUrl, String orderServiceUrl, Duration timeout) {}
ConfigContext<IOKind.Witness, AppConfig, Invoice> invoice =
ConfigContext.<AppConfig>ask()
.via(config -> fetchUser(config.userServiceUrl()))
.via(user -> fetchOrders(user.id()))
.via(orders -> createInvoice(orders));
private ConfigContext<IOKind.Witness, AppConfig, User> fetchUser(String url) {
return ConfigContext.io(config -> userClient.fetch(url, config.timeout()));
}
private ConfigContext<IOKind.Witness, AppConfig, List<Order>> fetchOrders(UserId id) {
return ConfigContext.io(config -> orderClient.fetch(config.orderServiceUrl(), id));
}
Each step has access to the same configuration. The config flows through without explicit passing.
flatMap: Type-Preserving Chain
ConfigContext<IOKind.Witness, AppConfig, Report> report =
getUser()
.flatMap(user -> getOrders(user))
.flatMap(orders -> generateReport(orders));
then: Sequence Ignoring Values
ConfigContext<IOKind.Witness, Config, String> workflow =
logStart()
.then(() -> doWork())
.then(() -> logComplete())
.then(() -> ConfigContext.pure("done"));
Modifying Configuration Locally
local: Temporary Config Override
Sometimes a sub-computation needs modified settings:
ConfigContext<IOKind.Witness, ApiConfig, Data> withLongerTimeout =
fetchData()
.local(config -> config.withTimeout(Duration.ofMinutes(5)));
The modified config applies only to this computation. Other computations in the same chain see the original.
Pattern: Feature Flags
record AppConfig(boolean debugMode, int maxRetries) {}
ConfigContext<IOKind.Witness, AppConfig, Result> withDebugMode =
processData()
.local(config -> new AppConfig(true, config.maxRetries()));
Pattern: Environment-Specific Settings
ConfigContext<IOKind.Witness, ServiceConfig, Response> callService =
ConfigContext.io(config -> httpClient.call(config.endpoint()));
// In tests, use a test environment
ConfigContext<IOKind.Witness, ServiceConfig, Response> testCall =
callService.local(config -> config.withEndpoint("http://localhost:8080"));
Adapting to Different Config Types
contramap: Transform the Required Config
When composing code with different config types:
record GlobalConfig(DatabaseConfig db, ApiConfig api) {}
// This needs DatabaseConfig
ConfigContext<IOKind.Witness, DatabaseConfig, Connection> dbConnection = ...;
// Adapt to work with GlobalConfig
ConfigContext<IOKind.Witness, GlobalConfig, Connection> adapted =
dbConnection.contramap(GlobalConfig::db);
contramap transforms the input (the config), allowing you to compose contexts that expect different configuration types.
Pattern: Modular Configuration
// User module expects UserConfig
ConfigContext<IOKind.Witness, UserConfig, User> fetchUser = ...;
// Order module expects OrderConfig
ConfigContext<IOKind.Witness, OrderConfig, Order> fetchOrder = ...;
// Application config combines both
record AppConfig(UserConfig userConfig, OrderConfig orderConfig) {}
// Adapt both to AppConfig
ConfigContext<IOKind.Witness, AppConfig, User> appUser =
fetchUser.contramap(AppConfig::userConfig);
ConfigContext<IOKind.Witness, AppConfig, Order> appOrder =
fetchOrder.contramap(AppConfig::orderConfig);
// Now they can be chained
ConfigContext<IOKind.Witness, AppConfig, Invoice> invoice =
appUser.via(user -> appOrder.map(order -> createInvoice(user, order)));
Execution
runWith: Get an IOPath
ConfigContext<IOKind.Witness, AppConfig, Report> reportCtx = generateReport();
// Provide config, get IOPath
AppConfig config = loadConfig();
IOPath<Report> ioPath = reportCtx.runWith(config);
// Execute when ready
Report report = ioPath.unsafeRun();
runWithSync: Immediate Execution
For synchronous code:
Report report = reportCtx.runWithSync(config);
This is equivalent to runWith(config).unsafeRun().
Real-World Patterns
Service Layer with Configuration
public class OrderService {
public ConfigContext<IOKind.Witness, ServiceConfig, Order> createOrder(OrderRequest request) {
return validateRequest(request)
.via(valid -> checkInventory(valid))
.via(checked -> processPayment(checked))
.via(paid -> saveOrder(paid));
}
private ConfigContext<IOKind.Witness, ServiceConfig, ValidatedRequest> validateRequest(
OrderRequest request) {
return ConfigContext.io(config ->
validator.validate(request, config.validationRules()));
}
private ConfigContext<IOKind.Witness, ServiceConfig, CheckedRequest> checkInventory(
ValidatedRequest request) {
return ConfigContext.io(config ->
inventoryClient.check(config.inventoryServiceUrl(), request.items()));
}
// ... similar for other methods
}
// At the application edge
ServiceConfig config = loadConfig();
Order order = orderService.createOrder(request).runWithSync(config);
Database Access
record DbConfig(String url, String user, String password, int poolSize) {}
public class UserRepository {
public ConfigContext<IOKind.Witness, DbConfig, User> findById(UserId id) {
return ConfigContext.io(config -> {
try (Connection conn = getConnection(config)) {
return queryUser(conn, id);
}
});
}
private Connection getConnection(DbConfig config) {
return DriverManager.getConnection(config.url(), config.user(), config.password());
}
}
Testability
// Production config
DbConfig prodConfig = new DbConfig(
"jdbc:postgresql://prod-db:5432/app",
"app_user",
prodPassword,
20
);
// Test config
DbConfig testConfig = new DbConfig(
"jdbc:h2:mem:test",
"sa",
"",
1
);
// Same code, different config
User prodUser = repository.findById(id).runWithSync(prodConfig);
User testUser = repository.findById(id).runWithSync(testConfig);
Combining with ErrorContext
public ConfigContext<IOKind.Witness, ApiConfig, ErrorContext<IOKind.Witness, ApiError, User>>
fetchUser(UserId id) {
return ConfigContext.io(config -> {
return ErrorContext.<ApiError, User>io(
() -> httpClient.get(config.userEndpoint() + "/" + id),
ApiError::fromException);
});
}
Escape Hatch
When you need the raw transformer:
ConfigContext<IOKind.Witness, AppConfig, String> ctx = ConfigContext.ask().map(AppConfig::name);
ReaderT<IOKind.Witness, AppConfig, String> transformer = ctx.toReaderT();
Summary
| Operation | Purpose |
|---|---|
ask() | Access the entire configuration |
io(config -> value) | Compute using configuration |
ioDeferred(config -> supplier) | Deferred computation using config |
pure(value) | Value ignoring configuration |
map(f) | Transform the result |
via(f) / flatMap(f) | Chain dependent computation |
local(f) | Temporarily modify configuration |
contramap(f) | Adapt to different config type |
runWith(config) | Execute with provided configuration |
runWithSync(config) | Execute synchronously |
ConfigContext embodies the paradox of invisible power. Dependencies flow through your code without ceremony, without frameworks, without the infrastructure that usually accompanies "enterprise" patterns. The only power is no power—the power that feels like nothing at all.
- ReaderT Transformer - The underlying transformer
- Reader Monad - The Reader type
- Advanced Effects - ReaderPath for simpler Reader usage
Previous: Optional Contexts Next: MutableContext