Interesting, Shantanu, thank you
I find parallels between your post and the Clean Architecture as well. It looks like in the discipline of software engineering and architecture the same ideas keep getting hit on from different directions, similar to the blind men and an elephant parable.
This makes me wonder, too, if we’re still missing an essential truth which we have not reached yet, something which definitively “solves” this problem.
The fact we have 4-5 if not N+1 dependency injection and state / application management frameworks hints at this problem.
As of now, and I don’t think this has been properly solved in other languages, either, there is no way or model to enforce what we “know” is the correct layered structure of:
- domain model
- business logic
- scaffolding / conveyance (actually moving data from A to B)
Where every layer only uses the one above it.
If we look at Component, for example, it looks like the “correct” way to construct and model a system is with zero knowledge of business logic, i.e. the components are completely generic and are parametrized with their behavior.
This leaves us, however, with very funny looking and obtuse components, which should also be very small:
(defrecord Server [server handler options]
Lifecycle
(start [this]
(assoc this :server (start-server handler options)))
(stop [this]
(stop-server server)
this))
This gets interesting where the handler itself depends on some states and dependencies. It’s cleaner to separate it out to its own component:
(defrecord Handler [handler make-handler options]
Lifecycle
(start [this]
(assoc this :handler (make-handler this options)))
(stop [this] this)
clojure.lang.IFn
(invoke [this req]
(handler req))
(invoke [this req resp raise]
(handler req resp raise)))
Where make-handler
knows how to extract the dependencies injected to this
and construct the handler correctly.
We can take it further, even, and look at how Aero and Component can work together, leading to the question - why does every component need to take options
as an argument? Since records are open and we can merge the configuration into each component in the system map, we can strip down the components like so:
(defrecord Server [server make-server]
Lifecycle
(start [this]
(assoc this :server (make-server this)))
(stop [this]
(stop-server server)
this))
(defrecord Handler [handler make-handler]
Lifecycle
(start [this]
(assoc this :handler (make-handler this)))
(stop [this] this)
clojure.lang.IFn
(invoke [this req]
(handler req))
(invoke [this req resp raise]
(handler req resp raise)))
This creates components which are completely and intentionally devoid of business logic, and frankly, of almost all implementation details. What makes the Server
record special now? Or the Handler
? Nothing:
(defrecord State [state]
Lifecycle
(start [this]
(let [make-state (:make-state this)]
(cond-> this
make-state (assoc this :state (make-state this)))))
(stop [this]
(let [stop-state (:stop-state this)]
(cond-> this
stop-state (assoc this :state (stop-state this))))))
(defrecord Callback [state]
Lifecycle
(start [this]
(let [make-state (:make-state this)]
(cond-> this
make-state (assoc this :state (make-state this)))))
(stop [this]
(let [stop-state (:stop-state this)]
(cond-> this
stop-state (assoc this :state (stop-state this)))))
clojure.lang.IFn
(invoke [this a]
(state a))
(invoke [this a b]
(state a b))
(invoke [this a b c]
(state a b c)))
Then they have meaning only by way of how the system is organized, while components are defined solely in terms of behaviors they implement:
(configure
(make-system
:server (using (State. nil) {:make-state make-server
:stop-state stop-server
:handler :handler})
:handler (using (Callback. nil) {:make-state make-handler
:connection ,,,})))
(defn make-server
[{:keys [handler] :as options}]
(start-server handler options))
(defn stop-server
[{:keys [state timeout]}]
(server-stop state timeout))
(defn make-handler
[{:keys [connection]}]
(ring/handler ,,,))
But this seems like over engineering to the umpteenth degree.
On top of that, you still have to make sure your functions are pure (read: don’t transfer any component to a function which is not a “constructor”) and still have no enforcement of correct order of dependencies.
Polylith tries to enforce this correct organization by other means, with the cost of forgoing any approach or enforcement towards state management.
The more I think about this subject, I just end up with more questions.
Would love to hear everyone’s thoughts on this.