Dependency Injection in FP: The Reader Monad
Developers familiar with hexagonal architecture know that to keep core business logic pure and independent, we place "side effects" like database calls and external API interactions into "ports" and "adapters." These are then injected into the application layer using Dependency Injection (DI). It's safe to say that classic object-oriented and layered architectures rely heavily on DI.
But when I started building things in MoonBit, I had no idea.
I wanted to follow best practices in a functionally-oriented environment like MoonBit, but with no classes, no interfaces, and no DI containers, how was I supposed to implement DI?
This led me to a crucial question: In a field as mature as software engineering, was there truly no established, functional-native solution for something as fundamental as dependency injection?
The answer is a resounding yes. In the functional world, this solution is a monad: the Reader Monad.
First, What is a Monad?
A Monad can be understood as a "wrapper" or a "context."
Think of a normal function as an assembly line. You put a bag of flour in at one end and expect instant noodles to come out the other. But this simple picture hides the complexities the assembly line has to handle:
- What if there's no flour? (null)
- What if the dough is too dry and jams the machine? (Throwing exceptions)
- The ingredient machine needs to read today's recipe is it beef or chicken flavor? (Reading external configuration)
- The packaging machine at the end needs to log how many packages it has processed today. (Updating a counter)
Monad is the master control system for this complex assembly line. It bundles your data together with the context of the processing flow, ensuring the entire process runs smoothly and safely.
In software development, the Monad family has several common members:
- Option(Maybe): Handles cases where a value might be missing. The box either has something in it or it's empty.
- Result(Either): Handles operations that might fail. The box is either green (success) and contains a result, or it's red (failure) and contains an error.
- State Monad: Manages situations that require modifying state. This box produces a result while also updating a counter on its side. Think of React's
useState
. - Future (or Promise): Deals with values that will exist in the future. This box gives you a "pickup slip," promising to deliver the goods later.
- Reader Monad: The box can consult an "environment" at any time, but it cannot modify it.
The Reader Monad
The idea behind the Reader Monad dates back to the 1990s, gaining popularity in purely functional languages like Haskell. To uphold the strict rule of "purity" (i.e., functions cannot have side effects), developers needed an elegant way for multiple functions to share a common configuration environment. The Reader Monad was born to resolve this tension.
And today, its applications are widespread:
- Application Configuration Management: Passing around global configurations like database connection pools, API keys, or feature flags.
- Request Context Injection: In web services, bundling information like the currently logged-in user into an environment that can be accessed by all functions in the request handling chain.
- Hexagonal Architecture: It's used to create a firewall between the core business logic (Domain/Application Layer) and external infrastructure (Infrastructure Layer).
In short, the Reader Monad is a specialized tool for handling read-only environmental dependencies. It solves two key problems:
- Parameter Drilling: It saves us from passing a configuration object down through many layers of functions.
- Decoupling Logic and Configuration: Business logic cares about what to do, not where the configuration comes from. This keeps the code clean and extremely easy to test.
The Core API
A Reader library typically includes a few core functions.
Reader::pure
This is like placing a value directly into a standard container. It takes an ordinary value and wraps it into the simplest possible Reader computation—one that doesn't depend on any environment. pure
is often the last step in a pipeline, taking your final calculated result and putting it back into the Reader context, effectively "packaging" it.
typealias @reader.Reader
// `pure` creates a computation that ignores the environment.
let ?
pure_reader : Reader[String
String, Int
Int] = (Int) -> ?
Reader::pure(100)
test {
// No matter what the environment is (e.g., "hello"), the result is always 100.
(a : Int, b : Int, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(?
pure_reader.(String) -> Int
run("hello"), 100)
}
Reader::bind
This is the "connector" of the assembly line. It links different processing steps together, like connecting the "kneading" step to the "rolling" step to form a complete production line. Its purpose is sequencing. bind
handles the plumbing behind the scenes; you define the steps, and it ensures the output of one computation is passed as the input to the next.
fnalias () -> ?
@reader.ask
// Step 1: Define a Reader that reads a value from the environment (an Int).
let ?
step1 : Reader[Int
Int, Int
Int] = () -> ?
ask()
// Step 2: Define a function that takes the result of Step 1
// and returns a new Reader computation.
fn (n : Int) -> ?
step2_func(Int
n : Int
Int) -> Reader[Int
Int, Int
Int] {
(Int) -> ?
Reader::pure(Int
n (self : Int, other : Int) -> Int
Multiplies two 32-bit integers. This is the implementation of the *
operator for Int
.
Parameters:
self
: The first integer operand.
other
: The second integer operand.
Returns the product of the two integers. If the result overflows the range of
Int
, it wraps around according to two's complement arithmetic.
Example:
inspect(42 * 2, content="84")
inspect(-10 * 3, content="-30")
let max = 2147483647 // Int.max_value
inspect(max * 2, content="-2") // Overflow wraps around
* 2)
}
// Use `bind` to chain the two steps together.
let ?
computation : Reader[Int
Int, Int
Int] = ?
step1.((Int) -> ?) -> ?
bind((n : Int) -> ?
step2_func)
test {
// Run the entire computation with an environment of 5.
// Flow: `ask()` gets 5 from the environment -> `bind` passes 5 to `step2_func`
// -> `step2_func` calculates 5*2=10 -> the result is `pure(10)`.
(a : Int, b : Int, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(?
computation.(Int) -> Int
run(5), 10)
}
Reader::map
This is like changing the value inside the container without touching the container itself. It simply transforms the result. Often, we just want to perform a simple conversion on a result, and using map
is more direct and expresses intent more clearly than using the more powerful bind
.
// `map` transforms the result without affecting the dependency.
let ?
reader_int : Reader[Unit
Unit, Int
Int] = (Int) -> ?
Reader::pure(5)
let ?
reader_string : Reader[Unit
Unit, String
String] = ?
reader_int.((Unit) -> String) -> ?
map(fn(Unit
n) {
"Value is \{Unit
n}"
})
test {
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(?
reader_string.(Unit) -> String
run(()), "Value is 5")
}
ask
ask
is like a worker on the assembly line who can, at any moment, look up at the "production recipe" hanging on the wall. This is our primary means of actually reading from the environment. While bind
passes the environment along implicitly, ask
is what you use when you need to explicitly find out what's written in that recipe.
// `ask` retrieves the entire environment.
let ?
ask_reader : Reader[String
String, String
String] = () -> ?
ask()
let String
result: String
String = ?
ask_reader.(String) -> String
run("This is the environment")
test {
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(String
result, "This is the environment")
}
A common helper, asks
, is just a convenient shorthand for chaining ask
and map
.
DI vs. Reader Monad
Let's consider a classic example: developing a UserService
that needs a Logger
to record logs and a Database
to fetch data.
In a traditional DI setup, you might have a UserService
class that declares its Logger
and Database
dependencies in its constructor. At runtime, you create instances of the logger and database and "inject" them when creating the UserService
instance.
interface Logger {
info(message: string): void
}
interface Database {
getUserById(id: number): { name: string } | undefined
}
class UserService {
constructor(
private logger: Logger,
private db: Database
) {}
getUserName(id: number): string | undefined {
this.logger.info(`Querying user with id: ${id}`)
const user = this.db.getUserById(id)
return user?.name
}
}
const myLogger: Logger = { info: (msg) => console.log(`[LOG] ${msg}`) }
const myDb: Database = {
getUserById: (id) => (id === 1 ? { name: 'MoonbitLang' } : undefined)
}
const userService = new UserService(myLogger, myDb)
const userName = userService.getUserName(1) // "MoonbitLang"
With the Reader Monad, the approach is different. The getUserName
function doesn't hold any dependencies itself. Instead, it's defined as a "computation description." It declares that it needs an AppConfig
environment (which contains the logger and database) to run. This function is completely decoupled from the concrete implementations of its dependencies.
fnalias ((Unit) -> String) -> ?
@reader.asks
struct User {
String
name : String
String
}
trait trait Logger {
info(Self, String) -> Unit
}
Logger {
(Self, String) -> Unit
info(type parameter Self
Self, String
String) -> Unit
Unit
}
trait trait Database {
getUserById(Self, Int) -> User?
}
Database {
(Self, Int) -> User?
getUserById(type parameter Self
Self, Int
Int) -> struct User {
name: String
}
User?
}
struct AppConfig {
&Logger
logger : &trait Logger {
info(Self, String) -> Unit
}
Logger
&Database
db : &trait Database {
getUserById(Self, Int) -> User?
}
Database
}
fn (id : Int) -> ?
getUserName(Int
id : Int
Int) -> Reader[struct AppConfig {
logger: &Logger
db: &Database
}
AppConfig, String
String?] {
((Unit) -> String) -> ?
asks(Unit
config => {
Unit
config.&Logger
logger.(&Logger, String) -> Unit
info("Querying user with id: \{Int
id}")
let User?
user = Unit
config.&Database
db.(&Database, Int) -> User?
getUserById(Int
id)
User?
user.(self : User?, f : (User) -> String) -> String?
Maps the value of an Option
using a provided function.
Example
let a = Some(5)
assert_eq(a.map(x => x * 2), Some(10))
let b = None
assert_eq(b.map(x => x * 2), None)
map(User
obj => User
obj.String
name)
})
}
struct LocalDB {}
impl trait Database {
getUserById(Self, Int) -> User?
}
Database for struct LocalDB {
}
LocalDB with (LocalDB, id : Int) -> User?
getUserById(_, Int
id) {
if Int
id (self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self
: The first integer to compare.
other
: The second integer to compare.
Returns true
if both integers have the same value, false
otherwise.
Example:
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
== 1 {
(User) -> User?
Some({ String
name: "MoonbitLang" })
} else {
User?
None
}
}
struct LocalLogger {}
impl trait Logger {
info(Self, String) -> Unit
}
Logger for struct LocalLogger {
}
LocalLogger with (LocalLogger, content : String) -> Unit
info(_, String
content) {
(input : String) -> Unit
Prints any value that implements the Show
trait to the standard output,
followed by a newline.
Parameters:
value
: The value to be printed. Must implement the Show
trait.
Example:
println(42)
println("Hello, World!")
println([1, 2, 3])
println("\{String
content}")
}
test "Test UserName" {
let AppConfig
appConfig = struct AppConfig {
logger: &Logger
db: &Database
}
AppConfig::{ &Database
db: struct LocalDB {
}
LocalDB::{ }, &Logger
logger: struct LocalLogger {
}
LocalLogger::{ } }
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq((id : Int) -> ?
getUserName(1).(AppConfig) -> Unit
run(AppConfig
appConfig).() -> String
unwrap(), "MoonbitLang")
}
This characteristic makes the Reader Monad a perfect match for hexagonal architecture. The core principle of this architecture is Dependency Inversion — the core business logic should not depend on concrete infrastructure.
The getUserName
function is a prime example. It only depends on the AppConfig
abstraction (the "port"), with no knowledge of whether the underlying implementation is MySQL, PostgreSQL, or a mock database for testing.
But what problem can't it solve? State modification.
The environment in a Reader Monad is always "read-only." Once injected, it cannot be changed throughout the computation. If you need a mutable state, you'll have to turn to its sibling, the State Monad.
So, the benefit is clear: you can read configuration from anywhere in your computation. The drawback is just as clear too: it can only read.
A Simple i18n Utility
Frontend developers are likely familiar with libraries like i18next for internationalization (i18n). The core pattern involves injecting an i18n instance into the entire application using something like React Context. Any component can then access translation functions from this context. This is, in essence, a form of dependency injection.
This brings us back to our original goal: finding a DI pattern to support i18n in a CLI tool. Here’s a simple demonstration.
So first, let's install the dependencies.
moon add colmugx/reader
And then, we define the environment and dictionary types our i18n library will need. The environment, which we can call I18nConfig
, would hold the current language (e.g., "en_US") and a dictionary. The dictionary would be a map of locales to their respective translation maps, where each translation map holds key-value pairs of translation keys and their translated strings.
typealias String as Locale
typealias String as TranslationKey
typealias String as TranslationValue
typealias type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Map[String
TranslationKey, String
TranslationValue] as Translations
typealias type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Map[String
Locale, type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Translations] as Dict
struct I18nConfig {
// 'mut' is used here for demonstration purposes to easily change the language.
mut String
lang : String
Locale
Map[String, Map[String, String]]
dict : type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Dict
}
Next, we create our translation function, t
. This function takes a translation key as input and returns a Reader
. This Reader
describes a computation that, when run, will use asks
to access the I18nConfig
from the environment. It will look up the current language, find the corresponding dictionary, and then find the translation for the given key. If anything is not found, it gracefully defaults to returning the original key.
fn (key : String) -> ?
t(String
key : String
TranslationKey) -> Reader[struct I18nConfig {
mut lang: String
dict: Map[String, Map[String, String]]
}
I18nConfig, String
TranslationValue] {
((Unit) -> String) -> ?
asks(Unit
config => Unit
config.Map[String, Map[String, String]]
dict
.(self : Map[String, Map[String, String]], key : String) -> Map[String, String]?
Get the value associated with a key.
get(Unit
config.String
lang)
.(self : Map[String, String]?, f : (Map[String, String]) -> String) -> String?
Maps the value of an Option
using a provided function.
Example
let a = Some(5)
assert_eq(a.map(x => x * 2), Some(10))
let b = None
assert_eq(b.map(x => x * 2), None)
map(Map[String, String]
lang_map => Map[String, String]
lang_map.(self : Map[String, String], key : String) -> String?
Get the value associated with a key.
get(String
key).(self : String?, default : String) -> String
Return the contained Some
value or the provided default.
unwrap_or(String
key))
.(self : String?, default : String) -> String
Return the contained Some
value or the provided default.
unwrap_or(String
key))
}
And that's it. The core logic is surprisingly simple.
Now, let's imagine our CLI tool needs to display a welcome message in the language specified by the operating system's LANG
environment variable.
We can define a welcome_message
function that takes some content as input. It uses our t
function to get the translation for the "welcome" key and then uses bind
to chain another Reader
computation that combines the translated text with the provided content.
RUN IT
fn (content : String) -> ?
welcome_message(String
content : String
String) -> Reader[struct I18nConfig {
mut lang: String
dict: Map[String, Map[String, String]]
}
I18nConfig, String
String] {
(key : String) -> ?
t("welcome").((Unit) -> Unit) -> ?
bind(Unit
welcome_text => (String) -> Unit
Reader::pure("\{Unit
welcome_text} \{String
content}"))
}
test {
let Map[String, Map[String, String]]
dict : type Map[K, V]
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
let map = { 3: "three", 8 : "eight", 1 : "one"}
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
Dict = {
"en_US": { "welcome": "Welcome To" },
"zh_CN": { "welcome": "欢迎来到" },
}
// Assuming your system language (LANG) is zh_CN
let I18nConfig
app_config = struct I18nConfig {
mut lang: String
dict: Map[String, Map[String, String]]
}
I18nConfig::{ String
lang: "zh_CN", Map[String, Map[String, String]]
dict }
let ?
msg = (content : String) -> ?
welcome_message("MoonbitLang")
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(?
msg.(I18nConfig) -> String
run(I18nConfig
app_config), "欢迎来到 MoonbitLang")
// Switch the language
I18nConfig
app_config.String
lang = "en_US"
(a : String, b : String, msg? : String, loc~ : SourceLoc = _) -> Unit raise Error
Asserts that two values are equal. If they are not equal, raises a failure
with a message containing the source location and the values being compared.
Parameters:
a
: First value to compare.
b
: Second value to compare.
loc
: Source location information to include in failure messages. This is
usually automatically provided by the compiler.
Throws a Failure
error if the values are not equal, with a message showing
the location of the failing assertion and the actual values that were
compared.
Example:
assert_eq(1, 1)
assert_eq("hello", "hello")
assert_eq(?
msg.(I18nConfig) -> String
run(I18nConfig
app_config), "Welcome To MoonbitLang")
}
And with that, I'd like to say: Welcome to MoonbitLang.