DI is built on one idea: a registry middleware — a plain function that takes one registry and returns another. Resolving a symbol to its var, overriding a key for a test, adding logging — each is a middleware that reshapes how keys resolve. Learn that one shape, and the rest of the library follows.
Before middleware, there are two smaller terms.
A factory is what a key resolves to — it makes one object and declares its dependencies.
A registry is a function from a key to a factory.
A middleware then transforms one registry into another. That is the
unit you write and compose, and di/start folds your middlewares over
the default registry.
What all this gives you is dependency wiring. You write
components and declare what each one depends on. DI works out the order
and builds every component the root needs — only those, lazily —
passing each the dependencies it asked for. Nothing is wired by hand.
The wiring is computed once, when
di/start
runs, and what you get back is the running system: the object built for
the root.
A key names a component of the system. It is one of three things, and the kind signals intent:
`my.app/server;:my.app/clock;"PORT".These are conventions, not hard rules, and a middleware can add more.
di/env-parsing, for example, reads a qualified keyword like
:env.long/PORT: the namespace selects a parser and the name is the
environment variable.
A factory is what a key resolves to. It knows three things — what it
depends on, how to build the object, and how to describe itself — one
method each on the
Factory
protocol:
(defprotocol Factory
(dependencies [this])
(build [this deps add-stop])
(description [this]))
dependencies returns the keys the factory needs, each marked
:required or :optional. A map will do. When build order matters —
as it does for side effects like migrations — return an ordered
sequence of [key dep-type] pairs instead: keys are built in the
order they are listed. It may compute the answer, but it must be pure
and stable, because DI asks more than once per di/start.build takes the resolved dependencies and returns the object.
Its add-stop argument registers cleanup. See
Building and stopping.description returns a diagnostic map and carries no behaviour.
Inspection and logging read it.You almost never need to implement the protocol yourself. Most things you write are factories already:
defn becomes a factory when DI resolves its symbol —
a component or a service;di/ref,
di/template
and
di/derive
return factories;That last rule is why a map of overrides works. When you start a system
with {:my.app/clock a-fixed-clock}, the value a-fixed-clock is used
as the factory for :my.app/clock: a factory with no dependencies that
builds to itself.
You reach for the protocol directly only when a key needs resolution those can't express — most often, to wrap another factory.
build returns the object. Its third argument, add-stop, registers a
zero-arg cleanup procedure — call it once per resource you allocate.
Registered cleanups run in LIFO order when the system stops, so a
component is torn down before the dependencies it was built from. See
Stopping components.
add-stop is what lets factories wrap cleanly. Earlier versions handled
cleanup through demolish — a separate Factory method DI called with
the built object at stop time. That made wrapping leak. A factory that
wrapped another and allocated a resource of its own had to either return
an object proxying the inner one's teardown, or take over the inner
factory's cleanup itself. Either way it had to know how the inner
factory was torn down. With add-stop each factory registers its own
cleanup the moment it allocates — the inner one included. Wrapping no
longer touches teardown:
(reify p/Factory
(dependencies [_] (p/dependencies inner))
(build [_ deps add-stop]
(let [obj (p/build inner deps add-stop) ; inner registers its own stop
resource (allocate-something obj)]
(add-stop #(release resource))
(wrap obj resource)))
(description [_] (p/description inner)))
nil means nothing here, and DI leans on that in one place after
another. The bare registry underneath everything resolves every key to
nil. An :optional dependency that resolves to nil is skipped. A
:required one that resolves to nil fails with ::missing-dependency.
The rule is uniform: a build that returns nil produced nothing.
That leaves no way to say the value is nil. Picture a component that
creates a table:
(defn create-users-table
{::di/kind :component}
[{db `db}]
(ddl db "create table users ...")
;; added during a later refactor
(when remove-legacy?
(ddl db "drop table legacy_users ...")))
When remove-legacy? is false the when returns nil, so the
component returns nil. Without help the system would fail to start
with ::missing-dependency — and only in the configuration where the
branch is not taken, so tests pass and production breaks.
So a :component that returns nil is stored as the sentinel
::di/nil. The key counts as built, satisfies a :required dependency,
and dependents receive ::di/nil:
(with-open [system (di/start `create-users-table)]
@system) ; => :darkleaf.di.core/nil
The substitution is specific to :component factories. Every other
factory returns its value unchanged, so a custom factory that builds
nil is read as nothing. Reach for di/opt-ref or an :optional
dependency when absent is what you actually mean.
The protocol's shape is what lets one factory wrap another — the
decorator pattern. A wrapper declares the inner factory's dependencies,
adds any of its own, delegates build to the inner one, and layers
behaviour around the result. Here is a wrapper that logs
when its key is built. It adds a dependency on a logger and otherwise
stays out of the way:
(defn with-logging [inner]
(reify p/Factory
(dependencies [_]
(concat (p/dependencies inner) {`logger :required})) ; inner deps plus a logger — concat, since deps may be a sequence
(build [_ deps add-stop]
(let [logger (deps `logger)
obj (p/build inner deps add-stop)] ; inner builds, registers its own stop
(logger {:built obj})
obj))
(description [_]
(p/description inner))))
This wrap-and-delegate pattern is what the library is built on. The next sections show a registry handing out factories, then a middleware applying such a wrap across the whole registry.
A registry is a function from a key to a factory:
(fn [key] factory)
A function with this shape is the most general way to express a lookup, so nothing else is needed. Even a map lookup is a function from key to value.
A registry hands back a factory, not a built object. Nothing is built
when you look a key up. Building happens later, when di/start resolves
the root, reads each factory's dependencies, and builds them in order.
Only one registry is ever written out: a bare one that resolves every
key to nil. Every other registry is a closure that a middleware
returns.
A middleware takes a registry and returns a new registry. If you have written Ring middleware, the shape is familiar: a function that wraps a handler and returns a new one, with a registry in place of the handler. Naming the two functions makes it clear:
(fn middleware [registry]
(fn new-registry [key]
(registry key)))
That body, (registry key), is a plain passthrough: the new registry
resolves every key exactly as the old one did. Every middleware starts
there. It becomes useful by returning a different factory for some keys,
or by wrapping the factory the inner registry returns.
with-clock does the first — it returns a fresh factory for ::clock
and passes the rest through:
(defn with-clock []
(fn middleware [registry]
(fn new-registry [key]
(if (= key ::clock)
(reify p/Factory
(dependencies [_] nil)
(build [_ _ _] (Clock/systemUTC))
(description [_] {}))
(registry key)))))
with-logging above does the second — it wraps the factory the inner
registry returns. This shape repeats across core: di/update-key,
di/log, di/add-side-dependency and the rest each look up a factory,
override one method, and delegate the others.
The built-in middlewares wrap the bare registry — one resolves a symbol
to its var, another resolves a string to an environment variable. That
stack is the default registry, no different from the middlewares you
write. di/start folds your middlewares over it, each wrapping the one
before — so the last one you pass is the outermost, and a key reaches it
first. For convenience, di/start also accepts a map of overrides,
nil for a no-op, or a sequence of these, and normalizes each into the
same registry transformation — see
The middleware argument for the
full list.
DI infers the wiring for you. You never write out the dependency graph —
each factory declares its own dependencies, and di/start works out
what to build and in what order. That is convenient, but implicit: from
the code alone, the shape of the running system is not obvious.
di/inspect makes it explicit, and it builds nothing. You call it like
di/start — the same key and middlewares — but it returns the graph
instead of building the system. It is not a separate mechanism: it adds
a middleware of its own that wraps every factory with another whose
build records the key, its dependencies, and its description instead
of constructing the object:
[{:key `root :dependencies {`foo :required `bar :optional}}
{:key `foo}
{:key `bar}]
The graph is walked exactly as di/start would walk it, so the report
reflects the system you would actually get — middlewares applied,
overrides in place. The only difference is that build describes
instead of constructs: no objects are built, no side effects run.
Inspection costs nothing extra because description is part of the
Factory protocol. However deeply a factory is wrapped, it can still
describe itself, so the whole system stays readable after every
middleware is applied.
di/start folds the middlewares over the default registry, then
resolves the root, then its dependencies, then theirs. That is a pull:
a component is built only because something above it asked for it. A
factory in the registry that the root never reaches is never built, and
an :optional dependency that resolves to nothing is simply skipped.
What you get back is the object built for the root, wrapped in a container. Two interfaces matter most:
IDeref — deref it to get the built object.AutoCloseable — closing it stops the whole system.The rest is convenience, so you can reach the object without a deref first:
IFn — if the root is a function, call the container directly. It
delegates across every arity.Indexed and ILookup — destructure it as a vector or a map,
matching a vector or map of root keys (see
Starting many keys).Pulling from the root is what makes feature flags cheap. A subsystem adds itself to the registry through a middleware (see Composition with update-key). Put that behind a flag, and when the flag is off the subsystem contributes nothing — nothing references its keys, so they are never built. One binary ships everywhere, each environment building only the features it turns on. See Why DI? for the payoff in full.
Step back, and the model is small. A factory makes one object and
declares its dependencies. A registry is a function from a key to a
factory. A middleware is a function from a registry to a registry.
di/start folds the middlewares over the default registry, pulls the
root, and hands back the running system.
The engine at the centre is deliberately small. Almost every feature is implemented as middleware layered on top of it — symbol resolution, environment variables, overrides, logging, inspection, memoization, and the rest.
Factories and registry middleware have been enough for the whole
library since the first release. Only the Factory protocol was refined over time to improve
composability. The model is stable now, and
there are no plans to change it.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |