Decoupling Dependencies in OCaml with Functors and First-Class Modules
Learn how to use OCaml functors and signatures to replace runtime configuration switches with compile-time type safety, allowing seamless switching between storage backends.
31 Oct 2025, 21:01 UTC

The Problem: Runtime Switching and Configuration Drift
When building a CLI tool or service, you often need different behaviors for different environments. A common example is storage: using an in-memory hash map for unit tests and a SQLite database for production. In many languages, this is handled via runtime string switches, environment variables, or dependency injection frameworks that rely on reflection.
These runtime approaches introduce risk. A typo in a configuration string or a missing environment variable can lead to a crash in production that wasn't caught during testing. Furthermore, relying on global mutable state to hold a "current" backend makes the code harder to reason about and test in parallel.
Thesis: Compile-Time Dependency Injection
OCaml's module system allows you to encode these dependencies at the type level. By using signatures (interfaces) and functors (functions that take modules as input and return new modules), you can turn runtime configuration choices into compile-time guarantees. This ensures that any backend you plug into your business logic is guaranteed to implement the required API before the code even runs.
Defining the Interface with Signatures
The first step is to define a module type. This acts as a contract that any storage backend must satisfy. By keeping the types abstract, the business logic remains agnostic of the underlying implementation.
module type Storage = sig
type key
type value
val save : key -> value -> unit
val load : key -> value option
end
Parameterizing Logic via Functors
A functor is essentially a template for a module. Instead of hard-coding a specific storage module, you write your service as a functor that accepts any module matching the Storage signature.
module MakeService (S : Storage) = struct
let store_user user_id data =
S.save user_id data
let get_user user_id =
S.load user_id
end
Practical Example: Switching Backends
To implement this, create two distinct modules. Note that we use with type to specify the concrete types for our specific application (strings for both keys and values).
In-Memory Implementation
module InMemory : Storage with type key = string and type value = string = struct
type key = string
type value = string
let table = Hashtbl.create 10
let save k v = Hashtbl.replace table k v
let load k = try Some (Hashtbl.find table k) with Not_found -> None
end
SQLite Implementation Sketch
module Sqlite : Storage with type key = string and type value = string = struct
type key = string
type value = string
let save k v = (* Call to sqlite3_exec for INSERT/UPDATE *)
let load k = (* Call to sqlite3_prepare for SELECT *)
end
Wiring it Together
At the entry point of your application (e.g., main.ml), you instantiate the functor with the desired module. This is where the "configuration" happens.
(* For testing: *)
let module Service = MakeService(InMemory) in
Service.store_user "user_1" "active";;
(* For production, simply change the argument: *)
(* let module Service = MakeService(Sqlite) in ... *)
Trade-offs and Limitations
While this approach provides immense safety, it comes with specific costs:
- Compile Times: Every time a functor is applied, the compiler effectively generates a new module. In very large projects with deeply nested functors, this can increase build times.
- Error Verbosity: When a module fails to match a signature, OCaml's error messages can be long and sometimes point to the functor's definition rather than the specific missing function in the implementation.
- Learning Curve: The module system is one of OCaml's most powerful but complex features; it requires a shift in thinking compared to object-oriented interfaces.
Verification and Testing
To verify the implementation, use the following workflow with the dune build system:
- Type Check: Run
dune build. If theSqlitemodule is missing aloadfunction, the compiler will flag it as a signature mismatch immediately. - Interface Inspection: Use
ocamlc -ion your compiled modules to verify that the resultingServicemodule exposes the expected API without leaking the internalStorageimplementation. - Behavioral Test: Create two test suites—one instantiating
MakeService(InMemory)and one withMakeService(Sqlite)—to ensure the business logic behaves identically regardless of the backend.
Actionable Closing
To adopt this pattern, start by identifying a single external dependency (like a database or API client) and extracting its interface into a module type. Keep your implementations small and focused. By moving your configuration from runtime strings to module parameters, you eliminate an entire class of "missing configuration" bugs and make your system significantly easier to test in isolation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.