At JUXT, most of our projects are structured using Stuart Sierra’s component library.
Ideally, we should validate that the configuration and dependencies of our components are fully satisfied, and that they meet a component’s expectations.
Validation isn’t always necessary for smaller systems, but is useful when systems grow beyond a certain size.
A great candidate for providing the validation logic is Prismatic’s Schema library.
But what is the best way to integrate Schema?
Option A: Validate in the record’s constructor function
It is common to define a function that creates an instance of a component record. This affords us the option to declare any dependency expectations the component may have.
We could also perform validation in the function, at least for constructor arguments. This is the approach I’ve been taking for the past year or so.
(require '[schema.core :as s])
(defn new-my-component [& {:as opts}]
(s/validate {:some-parameter s/Int} opts)
(using (map->Website opts) [:some-dependency]))
However, when components are constructed, dependencies are not present. Dependencies are only injected (or rather, the component record is reconstructed with them) when the system is started.
Therefore, we can’t check our dependencies are correct in the constructor. They will always be nil.
Option B: Validate in the start phase
We could validate our component in the Lifecycle
start phase.
(defrecord MyComponent [some-parameter some-dependency]
Lifecycle
(start [component]
(s/validate {:some-parameter s/Int
:some-dependency (s/proto SomeProtocol)}
component)))
Incidentally, we can also use schema’s defrecord
for this.
(s/defrecord MyComponent
[some-parameter :- s/Int
some-dependency (s/proto SomeProtocol)]
Lifecycle
(start [component]
(s/validate MyComponent component)
component)
(stop [component] component)
However, there are a few downsides with this approach.
-
we need to add
Lifecycle
to all our components, even those that wouldn’t otherwise need it -
if we throw errors in the system start phase we can leave the system in a half-started state which often requires a JVM reboot
Option C: Validate independently, after the system has started
Alternatively, we can add a check
function to our user
namespace,
alongside the start
, stop
and reset
functions.
(defn check
"Check for component validation errors"
[]
(let [errors
(->> system
(reduce-kv
(fn [acc k v]
(assoc acc k (s/check (type v) v)))
{})
(filter (comp some? second)))]
(when (seq errors) (into {} errors))))
We can call this check any time we like.
user> (check)
{:my-component {:some-parameter (not (instance? java.lang.Integer nil))}}
We can even add the following at the end of our usual start
function
(when-let [errors (check)] (println "Warning, component integrity violated!" errors))
so that we get a warning on the REPL on any reset where any component in the system has validation errors.
user> (reset)
:reloading ()
Warning, component integrity violated!: {:my-component {:some-parameter (not (instance? java.lang.Integer nil))}}
:ok
There are some key advantages to this approach. We avoid adding validation logic to our component code, we only have to use schema declarations when we care about validation. In other words, we only validate components that have added these declarations, components without them pass the validation check as usual.
I’ve been trying this approach for a few weeks and it’s been holding up well.