Using OCaml Functors for Plug‑in Architectures – A Practical Guide
Discover how OCaml signatures and functors enable clean, type‑safe plug‑in architectures. A step‑by‑step example shows swapping production and test services without touching the rest of the code.
03 Oct 2026, 23:43 UTC

Why Plug‑in Architecture Matters in OCaml
When a service needs to be swapped out—say, a real database client for a fake one in tests—most languages rely on dependency injection frameworks or global mutable state. OCaml offers a different, type‑safe path: its module system. Signatures (.mli files) expose only the parts you want, and functors let you build new modules from existing ones. The result is a clean separation of concerns that compiles away at build time, giving you both safety and performance.
Key Concepts at a Glance
- Signature (.mli): a contract that lists the types and values a module promises to provide.
- Structure (.ml): the implementation that satisfies a signature.
- Functor: a module‑level function that takes one or more modules as arguments and returns a new module.
- Include and module type of: tools for re‑using and extending signatures.
Concrete Example: A Service Plug‑in
Let’s walk through a minimal but realistic scenario. We want a SERVICE that can be wired with either a production or a test implementation.
# service.mli
module type SERVICE = sig
val execute : string -> string
end
# prod.ml
module Prod : SERVICE = struct
let execute input = "Prod: " ^ input
end
# test.ml
module Test : SERVICE = struct
let execute input = "Test: " ^ input
end
The functor that stitches the service into the application:
# app.ml
module Make (S : SERVICE) = struct
let run () =
let result = S.execute "hello" in
Printf.printf "Result: %s\n" result
end
Now the main program chooses which implementation to use:
# main.ml
module ProdApp = Make (Prod)
module TestApp = Make (Test)
let () =
(* In production, run the real service. *)
ProdApp.run ()
(* In tests, run the stub. *)
(* TestApp.run () *)
end
Compile everything:
ocamlc -c service.mli prod.ml test.ml app.ml main.ml
./a.out
Changing the line that wires the functor swaps the implementation without touching the rest of the code. The compiler guarantees that the chosen module satisfies SERVICE, so you’ll get a clear error if the implementation is incomplete.
How the Compiler Enforces Contracts
When the compiler sees module ProdApp = Make (Prod), it checks that Prod matches the SERVICE signature. If Prod omitted execute or had a wrong type, the error points directly to the offending line. This static guarantee means you can refactor the service implementation freely as long as the interface stays intact.
Trade‑offs and Limitations
- Compilation Time & Binary Size: Each functor application generates specialized code. In large codebases, applying a functor to many modules can bloat the binary and slow compilation. If you need runtime swapping, consider first‑class modules or a small wrapper record of functions.
- Recursive Dependencies: If two modules depend on each other, you must use
module recand carefully design signatures to avoid cycles that the compiler can’t resolve. - Runtime Overhead: First‑class modules (packing a module as a value) introduce a small runtime cost and lose some static type guarantees. Use them sparingly, e.g., for dynamic plug‑in loading.
- Complex Type‑Level Computation: The module system isn’t Turing‑complete. For heavy compile‑time type logic, PPX or GADTs are often the better choice.
Practical Checklist for Real‑World Projects
- Define clear signatures for each public abstraction.
- Use functors to parameterize implementation details.
- Keep functor applications to a manageable number; for many runtime variants, switch to first‑class modules.
- Use
includeandmodule type ofto extend or refine existing contracts without duplication. - Run
ocamlc -i file.mlto inspect inferred signatures and verify that your .mli files expose exactly what you intend. - Benchmark binary size and startup time if you plan to use many specialized functor instances.
Actionable Takeaway
Adopt the module system early. Start by wrapping any component that might need swapping (database clients, logging backends, external APIs) behind a signature. Then expose a functor that builds the rest of your application from that component. This pattern gives you compile‑time safety, clear boundaries, and zero‑cost abstraction when you’re ready to ship to production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.