When I wrote my first project in ReasonML I just imported modules from anywhere as that just seemed to be the way things were done. This was fine when writing a simple React frontend, but when I later wrote the more complex backend for Bouken it didn't scale and quickly became messy and hard to test.
I was used to using dependency injection – often known as DI – as a pattern from writing Java and Scala, which is a pattern that enables inversion of control. Or more simply put, constructing an object or module from other modules/objects and using the functions provided.
Now in ReasonML functors – functions from modules to modules – can be used to implement dependency injection. Functors can be used for more advanced techniques, such as creating modules that parameterise other modules; however, this is a good introduction to the module system. When I first used this technique the language server failed to understand what I was doing! Thankfully, that issue been fixed!
Why would you want to use dependency injection? Well, for example, you could have a service that uses another module to make a call to an API:
module MyService = {
let userAndComments = id => {
let user = UserApi.userById(id);
switch(user) {
| Some(user) => {
let comments = CommentsApi.fetchUserComments(id);
Some({ user.name, comments });
}
| None => None
}
};
};
The code should work fine, but are a few issues with this:
- It can lead to a spiderweb of dependencies.
- It can be hard to unit test, in this example you cannot test the
userAndComments
method without making an HTTP call. In order to test this module, you would require either a mocked server or an integration test.
We can use dependency injection to control what the service receives in order to facilitate easier unit testing. So how would we go about that?
First, let define a type that we can inject. This is a simple type definition for logging output.
module type Logger = {
let log: string => unit;
};
Then we can create a new module that uses the Logger we have defined. In OCaml and Reason a module that is built from other modules is known as a Functor.
module GreetingService = (LOG: Logger) => {
let sayHello = name => LOG.log("Hello " ++ name);
};
Now we can implement a logger and pass it into a GreetingService
: creating a module from another module.
module ConsoleLogger = {
let log = loggable => Js.Console.log(log);
};
module ConsoleGreeter = GreetingService(ConsoleLogger);
ConsoleGreeter.sayHello("World");
Note that we are free to choose how the Logger
works – it only has to match the type signature. For example, you could implement an ErrorLogger
instead:
module ErrorLogger = {
let log = loggable => Js.Console.error(log);
};
Effectively we have taken away control from GreetingService
to Logger
. Now GreetingService
describes what it wants to do, but doesn't actually implement how to do it. This is often referred to as inversion of control.
When writing systems in a modern object-oriented style using programming languages such as Java or Kotlin, this technique is heavily used. Often you will read that you should:
"prefer composition over inheritance"
...and DI is used to alongside composition to create cleaner more testable code.
This pattern can be used in ReasonML (and OCaml), by using functors for dependency injection. Lets, go back to the original example to demonstrate this
Updating the Original Service
Remember this? It's the MyService module from the first example.
module MyService = {
let userAndComments = id => {
let user = UserApi.userById(id);
switch(user) {
| Some(user) => {
let comments = CommentsApi.fetchUserComments(id);
Some({ user.name, comments });
}
| None => None
}
};
};
We can turn MyService into a functor and construct an instance by passing module types for UserApi
and CommentsApi
.
module MyService = (Users: UserApi, Comments: CommentsApi) => {
let userAndComments = id => {
let user = Users.userById(id);
switch(user) {
| Some(user) => {
let comments = Comments.fetchUserComments(id);
Some({ user.name, comments });
}
| None => None
}
};
};
Now, this module is easier to unit test. You can pass in stubbed versions of the modules in order to test the flow of the userAndComments
function.
module StubUserApi = {
let userById = id => switch(id) {
| 1 => Some({ name: "Test User"})
| _ => None
};
};
module StubCommentsApi = {
let fetchUserComments = id =>
{ title: "Test Comment", message: "Hello" };
};
Now it's easy to test the service using these simple stubbed modules:
module UnderTest = MyService(StubUserApi, StubCommentsApi);
let result = UnderTest.userAndComments(1); // Some({ ...
let anotherResult = Undertest.userAndComment(2); // None
I started using this technique partway through building Bouken. As previously stated, I initially imported modules from anywhere as that seemed to be the way things were done and worked for my frontend logic. Shortly afterwards, the game loop became unwieldy and needed breaking up.
Over time the more complex modules were broken down into smaller modules for easier testing and code management. This eventually turns into a dependency tree of module types. For example, the main game builder is defined as follows:
module CreateGame: (
Types.GameLoop,
Types.World,
Types.WorldBuilder
) => (Types.Game) = (
GL: Types.GameLoop,
W: Types.World,
WB: Types.WorldBuilder
) => { };
// Which is built from a
module CreateGameLoop = (
Pos: Types.Positions,
EL: EnemyLoop
) => { };
module CreateEnemyLoop = (
Pos: Types.Positions,
Places: Types.Places,
World: World
) => { };
Note that I chose to prefix the module names with CreateX
, as they create a module that matches a module type name. I don't know what the preference is for calling these modules in Reason/OCaml, but it was a long-running debate in Java and C#...
So as demonstrated, you can use Reason's advanced module system for dependency injection. Whilst DI is often mentioned in object-oriented development – you do not need to use objects! Instead, use modules for organising your code and leave objects alone until you absolutely require open types or inheritance. In fact, DI is used in functional programming, often by using the Reader Monad or some construct derived from the Reader Monad such as RIO.
Based on a post initially published at itazura.io
Top comments (1)
Great article! Re:
I've typically seen them called
Make
, similarly to value-levelmake
functions. And typically the signature is in the same module but calledS
. E.g.