A Component is a data structure to managing the lifecycle, dependencies
and data flow of a program. A Component is the smallest unit of execution which is reusable and
easy to reason about.
A component is similar in spirit to the definition of an object in Object-Oriented Programming.
This does not alter the primacy of pure functions and immutable data structures in Clojure as
a language. Most functions are just functions, and most data are just data. Components are intended
to help manage stateful resources within a functional paradigm.
Large applications often consist of many stateful processes which must be started and stopped in
a particular order. The component model makes those relationships explicit and declarative, instead
of implicit in imperative code.
Components provide some basic guidance for structuring a HellHound application, with boundaries
between different parts of a system. Components offer some encapsulation, in the sense of grouping
together related entities. Each component receives references only to the things it needs, avoiding
the unnecessary shared state. Instead of reaching through multiple levels of nested maps, a component
can have everything it needs at most one map lookup away.
Instead of having mutable state (atoms, refs, etc.) scattered throughout different namespaces, all
the stateful parts of an application can be gathered together. In some cases, using components may
eliminate the need for mutable references altogether, for example, to store the "current" connection
to a resource such as a database. At the same time, having all state reachable via a single
[system](./README.md#overview) map makes it easy to reach in and inspect any part of the application
from the REPL.
The component dependency model makes it easy to swap in stub or mock implementations of a component
for testing purposes, without relying on time-dependent constructs, such as with-redefs or binding, which are
often subject to race conditions in multi-threaded code.
Having a coherent way to set up and tear down all the state associated with an application enables rapid
development cycles without restarting the JVM. It can also make unit tests faster and more independent,
since the cost of creating and starting a system is low enough that every test can create a new instance
of the system.
For small applications, declaring the dependency relationships among components may actually be more work than
manually starting all the components in the correct order or even not using component model at all. Everything
comes at a price.
The [system](./README.md#overview) map produced by HellHound is a complex map and it is typically too
large to inspect visually. But there are enough helper functions in hellhound.system`
namespace to help
you with it.
You must explicitly specify all the dependency relationships among components: the code cannot discover these
relationships automatically.
Finally, HellHound system forbids cyclic dependencies among components. I believe that cyclic dependencies
usually indicate architectural flaws and can be eliminated by restructuring the application. In the rare case
where a cyclic dependency cannot be avoided, you can use mutable references to manage it, but this is outside
the scope of components.
Components are the main parts of HellHound systems. Basically, each component is an implementation of IComponent
protocol. The protocol which defines a component functionality. By default HellHound implements IComponent
protocols for hashmaps only. So we can define components in form of maps.
In order to define a component, a map should contain the following keys (All the keys should be namespaced
keyword under hellhound.component
):
A Stupid component which does nothing
(def sample-component
{:hellhound.component/name :sample.core/component-A (1)
:hellhound.component/start-fn (fn [component] component) (2)
:hellhound.component/stop-fn (fn [component] component) (3)
:hellhound.component/depends-on [:sample.core/component-B]}) (4)
1 | Description of the component name which is :sample.code/component-A |
2 | The function which is responsible for starting the component. Which does nothing in this case. |
3 | The function which is responsible for stapping the component. Which does nothing in this case. |
4 | Description of the dependencies of :sample.code/component-A . In this case this component
depends on another component called :sample.core/component-B . |
As you can see creating a component is fairly simple and nothing special.
As you already know, start-fn of a component takes two arguments.
The first one it the component map itself and second one is a hashmap called context map.
The purpose of a context map is to provides co-effects for the component, Such as dependency
components. For example HellHound inject a key called :dependencies
to the context map which
is a vector containing those components that this component is depends on. The order of this
vector would be exactly the same as :depends-on value in component
description map. So you can get all you need from the context map. You can think of it as some
sort of dependency injection for you component.
But context map contains more data that you might need them in action. Here is the list of keys
of a context map:
-
:dependencies
: A vector of running components which the current component is depends on.
All the components in this vector already started and the order is the same as the vector
provided to :depends-on in component map.
-
:dependencies-map
: A hashmap containing all the components of the current component dependencies.
keys of this map are the components names and the values are the running components. The purpose
of this map is to access dependency components by name.