01: Your First System

ℹ️
You can view all source for examples on GitHub

Systems, components, and signal handlers

donut.system supports you in thinking about your application as a system of interacting components, where:

  • components are well-boundaried collections of processes and state that produce behavior
  • systems are collections of components

How does the library do this? By giving you a way to express the conceptual, architectural organization of your application in code. Let’s look at a very simple example:

dev/donut/examples/tutorial/01_your_first_system.clj
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(ns donut.examples.tutorial.01-your-first-system
  (:require
   [donut.system :as ds]))

(def system
  {::ds/defs
   {:services
    {:printer {::ds/start (fn [_] (print "donuts are yummy!"))}}}})

(ds/signal system ::ds/start)
;; =>
donuts are yummy!

Going from the bottom up: on line 10 we’re calling ds/signal with two arguments, a system map and the signal ::ds/start. On line 8 you can see that there’s a map that has the key :printer with a map as its value, and that inner map includes the key ::ds/start – the same keyword that we passed to ds/signal. Its function gets called, and the result is that a true statement about donuts got printed.

How does this happen? What is the relationship between all these pieces – the ds/signal function, the structure of the system map, and the ::ds/signal keyword – that produces the behavior we saw?

Conceptually, donut.system models system behavior in terms of sending and responding to signals. (This is metaphorical: there’s no network or sockets or anything like that involved.) The system of components define signal handlers, and when you “send” a signal to a system it applies the signal by traversing each component and calling its signal handler for that signal. These signal handlers are what perform useful work in your application.

This idea manifests in your code in the way components are defined. You define components as maps that associate signal names (like ::ds/start) with signal handlers (functions like (fn [_] (print "donuts are yummy!"))). This is a component definition:

{::ds/start (fn [_] (print "donuts are yummy!"))}

So when you call (ds/signal system some-signal-name), it looks for all components definition maps that have a key of some-signal-name, and calls the corresponding function.

When I say “it looks for all component definition maps”, I mean that component definition maps must be organized in a particular way within the system map for them to be found and used. The general structure is:

{::ds/defs
 {:group-1
  {:component-1 component-def-map
   :component-2 component-def-map}
  :group-2
  {:component-3 component-def-map}}}

The value of ::ds/defs is a map whose keys are group names, and and whose values are component groups. A component group is a map whose keys are component names, and whose values are component maps.

For a map to be treated as a component definition, it must be located at the correct place within these nested maps. It must be accessible via get-into using a 3-element path like [::ds/defs :group-1 :component-1]; this won’t work:

{::ds/defs
 {:group-1
  {:sub-group-1 component-def-map}}}

There’s more to donut.system, but this is the foundation: it gives you the tools to structure your application as a system of components that produce behavior by handling signals.

How might you modify this system definition so that your component can handle other signals? Let’s look at that next.

Summary

  • Components are well-boundaried collections of processes and state that produce behavior
  • Systems are collections of components
  • You define components as maps of signal handlers
  • Signal handlers produce behavior in response to signals
  • You place components in a system maps under component groups, where component groups live under the ::ds/defs key