System/component lifecycle management
Makina is a System Lifecycle Manager for Clojure. You define the components your application is made of, and the dependencies between them. And Makina takes care of starting and stopping them in the right order, and plugging them together.
Makina has two APIs, lambdaisland.makina.system is the base API. It is
unopionated and flexible, it implements the basic mechanisms for system
lifecycle management.
lambdaisland.makina.app is a
policy
namespace, it's opinionated, it assumes you follow specific conventions, and
integrates with tools.namespace
for smart code reloading. It uses Aero for
loading config files.
In addition there's lambdaisland.makina.test, meant for setting up and pulling
down a system during (unit, integration) testing.
Makina plays well with com.lambdaisland/config
To use the latest release, add the following to your deps.edn (Clojure CLI)
com.lambdaisland/makina {:mvn/version "0.4.23"}
or add the following to your project.clj (Leiningen)
[com.lambdaisland/makina "0.4.23"]
This shows the pattern of using Makina with lambdaisland/config.
(ns my.app
(:refer-clojure :exclude [get])
(:require
[lambdaisland.config :as config]
[lambdaisland.makina.app :as app]))
(def prefix "my-app")
(defonce config (config/create {:prefix prefix}))
(defonce system
(app/create
{:prefix prefix
:data-readers {'config (partial config/get config)}
:profile (:env config)}))
(def load! (partial app/load! system))
(def start! (partial app/start! system))
(def stop! (partial app/stop! system))
(def refresh (partial app/refresh `system))
(def refresh-all (partial app/refresh-all `system))
(comment
system
(start!)
(stop!)
(refresh))
Then add a resources/my-app/system.edn (make sure "resources" is on the
classpath).
{:my.app/first-component {:some "settings"}
:my.app/second-component {:dep #makina/ref :my.app/first-component}}
And define handlers for your components. You have two options for this. For
instance, for :my.app/first-component , you either define a var named
#'my.app/first-component, or #'my.app.first-component/component
(ns my.app.first-component)
(def component
{:start (fn [opts] ,,,)
:stop (fn [v] ,,,)}
The start function receives configuration for that component from system.edn, plus some additional keys map
{:makina/id :my.app/first-component
:makina/type :my.app/first-component
:makina/state :stopped
:makina/signal :start}
:stop receives the value returned from :start. If it's a map, then
:makina/id, :makina/type, :makina/state, and :makina/signal are added to
the map before calling :stop.
These additional keys allow you to write more generic handlers that can deal with multiple types of components.
Let's take a step back here, and explain Makina from the ground up.
Everything starts from a system config. The config tells us which components
there are in the system, configuration values passed in when starting each
component, and dependencies between the components. Here's a config for a very
basic web app. It has a handler (a function which takes HTTP requests and
returns HTTP responses), and a server which listens on a port, and which
receives a reference to the handler.
The dependencies are indicated with a "ref" (reference) value. You can use the
#makina/ref prefix for this (Makina ships with a data_readers.cljc), or you
can construct them explicitly with lambdaisland.makina.system/->Ref.
(def config
{:http/handler {}
:http/server {:port 1234
:handler (sys/->Ref :http/handler)}})
;;=>
{:http/server {:port 1234, :handler #makina/ref :http/handler}
:http/handler {}}
Now we'll convert that config into a "system" map. It still has one entry for each component, but now contains a bunch of extra bookkeeping.
(def system (sys/system config))
;;=>
{:http/server
{:makina/id :http/server
:makina/type :http/server
:makina/state :stopped
:makina/config {:port 1234
:handler #makina/ref :http/handler}
:makina/value {:port 1234
:handler #makina/ref :http/handler
:makina/type :http/server
:makina/id :http/server}}
:http/handler
{:makina/id :http/handler
:makina/type :http/handler
:makina/state :stopped
:makina/config {}
:makina/value {:makina/type :http/handler
:makina/id :http/handler}}}
The config key has been used both as id and type, you can explicitly set a
:makina/type as well. This is useful if you have multiple components with
identical start/stop logic, but different parameters. You can also use it with
#makina/refset :some/type. This way rather than passing one component value to
another, the component receives a collection of all components with the same
type.
The components are currently :stopped, we'll try to start them in a moment. We
track the original config. We want to be able to inspect the parameters a
component started with, and we may need them to restart this component later on.
Finally there is a :makina/value, which initially is just the configuration +
id and type. While :makina/config will stay the same over time, the
:makina/value will change when we start the components.
Now let's start the system. For this, Makina needs to know which "handlers" to use. (Note: "handler" is a bit overloaded in this example, we have the "http handler" on the one hand, which handles HTTP requests, and we have Makina "handlers", functions which start or stop components, these are not the same.)
(defn http-handler [req]
{:status 200 :body "OK"})
(defn start-web-server [{:keys [port handler]}]
;; some ring adapter, e.g. ring.adapter.jetty/run-jetty
(http/run-http handler {:port port :join? false}))
(defn stop-web-server [server]
(.close server))
(def handlers
{:http/handler {:start (constantly http-handler)}
;; or: (constantly http-handler)
:http/server {:start start-web-server
:stop stop-web-server}})
(def started-system
(sys/start system handlers))
;; later
;; (sys/stop started-system handlers)
;;=>
{:http/server
{:makina/id :http/server
:makina/type :http/server
:makina/state :started
:makina/config {:port 1234 :handler #makina/ref :http/handler}
:makina/value #object["http-ring-adapter"]
:makina/timestamp #time/instant "2025-11-28T09:29:35.702743635Z"}
:http/handler
{:makina/id :http/handler
:makina/type :http/handler
:makina/state :started
:makina/config {}
:makina/value #function[makina.repl-sessions.walkthrough/http-handler]
:makina/timestamp #time/instant "2025-11-28T09:29:35.702316410Z"}}
What happened here? Makina figured out through "topological sorting" that the
handler needed to be started first, followed by the http server. It sends the
:start signal to the handler component. To do this, it looks for a "handler"
for this component and signal. In the handlers map passed to system/start it
tries in order:
(get-in handlers [id]) ;; only if (= :start signal)
(get-in handlers [id signal])
(get-in handlers [id :default])
(get-in handlers [type]) ;; only if (= :start signal)
(get-in handlers [type signal])
(get-in handlers [type :default])
(get-in handlers [:default]) ;; only if (= :start signal)
(get-in handlers [:default signal])
(get-in handlers [:default :default])
In other words, it first tries to find a handler for this specific component's
id, if it doesn't find one, it looks for a handler based on the component type,
and otherwise it falls back to :default
For each it first checks if the the value in the handler map entry is a
function, in that case it uses that as the :start handler. So if a component
only has a start handler, you don't need to wrap it in a {:start handler} map.
You can also use vars in your handler map, and they will be derefed. This is quite useful for getting late binding when redefining things (e.g. in a REPL).
So now Makina has found a handler function that can handle the :start signal
for each component. This handler function now receives the current
:makina/value, the return value is used as the new :makina/value, and the
component transitions to :started. If the initial value is a map, then Makina
also adds the :makina/signal and :makina/state keys to it, so what the
handler receives looks like this:
{:port 1234
:handler ,,http handler function,,,
:makina/type :http/server
:makina/id :http/server
:makina/signal :start
:makina/state :stopped}
This allows you to create generic handler functions that can be shared across
components. This is especially useful for fallback :default start or stop
handlers, which can e.g. log and throw.
Makina.system needs explicit handlers for each signal, so stopping our
started-system will fail, because there is no :stop handler for
:http/handler.
Unhandled clojure.lang.ExceptionInfo
No handler found for [:http/handler :stop]
{:makina/type :http/handler, :makina/signal :stop}
It's quite common for components to not have specific shutdown logic, if you
want to ignore missing :stop handlers, you can add :default {:stop identity}
to your handlers map. When using makina.app this is done automatically.
If the handler throws, then instead of ending up in the :started state, it
will be in the :error state, and it will have a new key :makina/error
containing the exception. Its value will be unchanged. In this case Makina will
stop trying to start additional components, but components that have already
started will be left in their :started state. So you end up with a system
value where some components are :started, some are still :stopped, and one
is in the :error state. If you call start on this system again, it will
retry starting from the failed component. If you call stop, it will only stop
the components that had successfully started.
Both start and stop can optionally take a sequence of keys, to limit their
scope. For instance, you can tell it to only start a single component, as well
as its dependencies.
The system namespace contains a bunch of functions to query a system map, like
value (map from id to component value), component (a single component
value), state (returns the state of a single component), and error (returns
the exception, if any)
The lambdaisland.makina.system namespace is purely functional, it receives and
returns Clojure data structures and function references. There are no mutable
objects. It is cljc, and so can be used in both Clojure and ClojureScript.
lambdaisland.makina.app)For specific opinionated use cases it can be useful to use the system namespace
directly, but you will likely end up implementing a bunch of additional
bookkeeping, storing the system in something mutable (an atom or var), so you
can keep track of it as it changes. You need to load and hook up handlers, it
might be nice to do that automatically based on naming conventions. You probably
want to set up tools.namespace for
code reloading. These and other quality of life things are handled by
lambdaisland.makina.app, and we generally recommend using this higher-level
API.
(require '[lambdaisland.makina.app :as app])
(def app (app/create {:prefix "my-app"}))
(def app (app/create {:config {,,,}))
(def app (app/create {:config (fn [] {,,,})))
(def app (app/create {:config "app.edn"))
In this case, we start with calling app/create, it has a handful of different
options, the most important ones are to help it find your system config map. The
easiest is to give it a "prefix", and it will then locate <prefix>/system.edn
on the classpath. This is analogous to the "prefix" argument used by
lambdaisland.config, so in addition to
resources/<app-name>/config.edn|dev.edn|prod.edn you'll have
resources/<app-name>/system.edn containing your system config. This mirrors
conventions used in other libraries and projects, and will hopefully become a
recognizable point of entry as Makina gains more traction.
The system.edn file is read by Aero, you can pass a :profile to create to
influence how the file gets read. The default profile is :default. You can
pass extra :data-readers, a common pattern is to provide a config reader for
configuration values coming from separate configuration files or environment
variables.
(app/create {:prefix "my-app", :data-readers {'config read-config-value}, :profile :prod})
Alternatively, you can still pass the config map explicitly, pass a function or
var, an instance of File or URL, or you can give it a string, which will be
resolved as a resource on the classpath.
The result of app/create is an atom, containing an "application" map (hey, we
had to call it something to distinguish it from the "system" map).
(def app (app/create {:config config}))
;;=>
#<Atom@57e08b0a:
{:makina/state :not-loaded,
:makina/config
{:http/server {:port 1234, :handler #makina/ref :http/handler},
:http/handler {}},
:makina/extra-handlers nil,
:makina/data-readers
{ref #function[lambdaisland.makina.system/eval12377/->Ref--12392],
refset #function[lambdaisland.makina.system/eval12398/->Refset--12413]}}>
One of the main benefits of using the app namespace is the auto-loading. When we
call app/start!, it will try to load namespaces and resolve handlers based on
the keys used in the config.
So if you have a component named :my.app.http/handler, Makina will look for a
var named #'my.app.http/handler, or if it can't find it,
#'my.app.http.handler/component. It will then construct a handler map, using
these vars directly, and pass that on to makina.system, meaning the rules of
makina.system apply from there. So the var can contain a function (used as the
start signal), or it can contain a map with signals.
Generally it's a good idea to define each component's logic in its own namespace.
(ns my.app.http.handler)
(defn component [cfg]
,,,start handler, return value,,,)
;; OR
(def component
{:start (fn [cfg] ,,,)
:stop (fn [val] ,,,)})
You can pass a :ns-prefix to create, so in the example above, we could say
{:ns-prefix "my.app"}, and then simply use :http/handler as the component
id, and Makina will know to look for my.app.http.handler.
You can also pass additional :handlers, same as the handlers passed to
system/start, if a handler is passed explicitly for a given type or id, then
this short circuits the auto-loading.
Now we can start! this app! The return value of start! is the system value
(component keys with their associated current value). If you look inside the
atom, you can see this application is now :started, and has the system value
inside it.
(app/start! app)
;;=>
{:http/server #object[,,,]
:http/handler #function[my.app.http.handler/http-handler]}
@app
;;=>
{:makina/state :started
;; ,,, data-readers, extra-handlers, config ,,,
:makina/system
{:http/server
{:makina/id :http/server
:makina/state :started
:makina/config {:port 1234 :handler #makina/ref :http/handler}
:makina/value #object[,,,]
:makina/type :http/server}
:http/handler
{:makina/id :http/handler
:makina/state :started
:makina/config {}
:makina/value #function[my.app.http.handler/http-handler]
:makina/type :http/handler}}
:makina/handlers
{:default {:stop #function[clojure.core/identity]}
:http/handler #'my.app.http.handler/component
:http/server #'my.app.http.server/component}}
Now we can actually do a full system reload, (See Reloaded Workflow for background).
This shuts down the system, uses
tools.namespace, to unload and
then reload all namespaces, ensuring a completely fresh REPL state, before
starting the system again. This is done with app/refresh (reload namespaces
that have changed since last refresh), or refresh-all (reload ALL namespaces).
Note that you need to add the dependency to tools.namespace in your own project,
it is not an explicit dependency of Makina, to avoid it blowing up the size of
production artifacts, it's recommended to only add it in development.
app/refresh and app/refresh-all work in the same way, you pass them the name
of the var that contains your app, as a fully qualified symbol (the backtick '`'
is your friend). The reason we need to pass it as a symbol is that as part of
this process the namespace containing the app will get reloaded as well, so we
need to be able to find the new "app" var once the reload is done.
(app/refresh `app)
The rest of the app API mimics system, there's start!, stop!, value,
component, error. To inspect your system, there's print-table. It can be
nice to call this as part of your application's startup logic so you can
visually inspect that everything is up and running.
If a start handler throws, the app will transition to an :error state, and the
exception gets rethrown as well. This is different from the system behavior,
where the system containing the error is returned but no exception is thrown. In
this case since we have the atom we can stash away the failed system before we
throw, so you can inspect it, shut it down, or try again to continue the startup
process.
lambdaisland.makina.test)When writing tests, you often need at least part of the system to be available, but you don't necessarily need or want all of it. Especially components that themselves rely on external services like databases might be best avoided or mocked out. Part of the reason we created Makina is because we wanted a more elegant way to express this, overriding the startup/teardown logic of components on multiple levels.
The lambdaisland.makina.test namespace builds on system and app. It
contains a dynamic var called *app* which can be bound to a started app value
(so without the wrapping atom) during a test or test run. There are few ways to
achieve this, depending on your needs.
The most basic version is the with-app macro, it takes a settings map just
like lambdaisland.makina.app/create, i.e. :prefix, :config, :profile,
etc. It will load and start the app, bind it to *app*, run the code inside the
body, and then tear down the app again. There's an additional key, :keys, to
control which components get started. The configuration is loaded using Aero
with the :test profile, unless a profile is specified explicitly.
Note that you can pass in :handlers, which will replace the
default/auto-loaded handlers. Perfect for mocking out components during testing.
(require '[lambdaisland.makina.test :as makina-test])
(makina-test/with-app {:prefix "my-app" :keys [:http/handler] :handlers {...}}
,,, code that relies on makina-test/*app* ,,,
)
Building on that is make-fixture-fn, like its name suggest, the return value
of make-fixture-fn is a function that can be used with
clojure.test/use-fixture.
(def wrap-app (makina-test/make-fixture-fn {:prefix "my-app" :handlers {}))
(t/use-fixture :once (wrap-app [:keys :to :start]))
;; OR
(t/use-fixture :once (wrap-app {:keys [:keys :to :start] :handlers {}}))
What's nice about make-fixture-fn is that you can have a utility namespace
where you set up sensible defaults, like where to load the system config from,
and test-specific handler overrides. You can also set up accessor functions here
for components your test code might want to reference directly. Then in each
test namespace, you declare which components are needed (as a vector/set, or
with {:keys ,,,}), and if necessary additional overrides.
While working on tests, you will likely want to evaluate bits of test code from
the REPL or your editor. If these reference *app* then that's a problem, since
*app* is only bound during actual test runs (when using the fixture or hooks
approach). For this scenario there is lambdaisland.makina.test/start!. This
takes a similar settings map as with-app or make-fixture-fn, but permantly
binds *app*.
The fixture approach does mean that for each test namespace your system gets started and stopped again and again. Depending on your start/stop logic, and how many heavier components you are mocking out with handler overrides, this can take anything from milliseconds to minutes. If you have a fairly heavy system, you may prefer to start/stop it only once for the entire test run. In that case, you can use the Kaocha plugin provided with Makina.
Makina ships with a plugin for Kaocha,
this further builds upon lambdaisland.makina.test, ensuring that while tests
are running they can find a started app value in
lambdaisland.makina.test/*app*. The benefit of using this over the fixture
approach is that the system is only initialized once, potentially significantly
speeding up your test run.
With Makina listed as dependency in deps.edn (or equivalent), add the plugin
to your Kaocha setup (tests.edn). Then configure :makina/settings, either at
the top level of tests.edn, or on a specific test suite.
;; tests.edn
#kaocha/v1
{:plugins [:lambdaisland.makina/kaocha-plugin]
:makina/settings {:prefix "my-app"}
;; OR on the test suite level
:tests [{:id :unit
:makina/settings my-app.config.makina-opts}])
Now the system will be started and stopped once, and bound to *app* for the
duration of the test run. The settings in this case can be set directly in
tests.edn as a map literal, or you can provide the name of a var that contains
either the settings map, or a function that returns the settings map.
The system.edn file is loaded with Aero, which means you get a bunch of handy
reader literals, like #env, #or, and #profile. This last provides an
interesting alternative to overriding handlers explicitly during tests.
Say that by default you store HTTP session information in Redis, but during
integration tests you just want to use an in-memory store. You can do something
like this in system.edn
Effectively swapping out the redis component for an in-memory component.
{:session-store
{:makina/type #profile {:default :redis-store
:test :memory-store}}
As is often the case, there is more than one way to do it, the choice is yours.
These open source applications are built with Makina:
Built something cool with Makina? Send us a PR to add it to this list!
Thank you! makina is made possible thanks to our generous backers. Become a backer on OpenCollective so that we can continue to make makina better.
makina is part of a growing collection of quality Clojure libraries created and maintained by the fine folks at Gaiwan.
Pay it forward by becoming a backer on our OpenCollective, so that we continue to enjoy a thriving Clojure ecosystem.
You can find an overview of all our different projects at lambdaisland/open-source.
We warmly welcome patches to makina. Please keep in mind the following:
***We would very much appreciate it if you also
We recommend opening an issue first, before opening a pull request. That way we can make sure we agree what the problem is, and discuss how best to solve it. This is especially true if you add new dependencies, or significantly increase the API surface. In cases like these we need to decide if these changes are in line with the project's goals.
* This goes for features too, a feature needs to solve a problem. State the problem it solves first, only then move on to solving it.
** Projects that have a version that starts with 0. may still see breaking changes, although we also consider the level of community adoption. The more widespread a project is, the less likely we're willing to introduce breakage. See LambdaIsland-flavored Versioning for more info.
Copyright © 2024-2025 Arne Brasseur and Contributors
Licensed under the term of the Mozilla Public License 2.0, see LICENSE.
Can you improve this documentation? These fine people already did:
Arne Brasseur & Laurence ChenEdit 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 |