Skip to main content

Dependency Injection in FP: The Reader Monad

· 10 min read

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.