Thanks for bringing this up. I’ve been mulling over the topic of organising Clojure applications on and off for a couple of years now. I tried various things but most valuable insights came from watching other people’s work.
An experience report that helped me think about structuring applications is Jarppe Länsiö’s discussion on long-lived projects from ClojuTRE 2018: “First 6 years of a life of Clojure project.” The way their architecture evolved was instructive. Modelling commands as data and pure functions reminds me of domain-specific languages, recently discussed by Hanson and Sussman. I loved the gag about mocks.
Domain-driven design that Sean mentioned is a box packed with valuable tools. The building blocks, such as value objects or entities, translate without much friction into the Clojure thinking about value, identity, and state. Aggregates help find consistency boundaries. Paying attention to the language used in our domain helps identifying bounded contexts and drawing namespaces around them. Thinking domain-first can help with many design hurdles.
“Domain Modeling Made Functional” is a good book that introduced the domain-driven approach using F♯. And while we’re at other functional programming languages, “Designing for Scalability with Erlang/OTP” is a treasure trove, in particular when it comes to handling failure.
I’ve been experimenting with those and other ideas, building applications out of them, sometimes giving talks to discuss the outcomes. The majority of resulting slide decks aged poorly. I suppose I could give a whole new talk that’d be all about pointing out weaknesses in my earlier takes.
Not long ago I contributed to Spacy, my workmate’s web application for planning remote open space events. The main value proposition of that app is its focus on being responsible; an achievement for which I can take no credit. As the main author, Joy Heron, worked on the front-end architecture, I experimented with the back-end.
The result is a loose combination of an onion architecture with a dash of functional core and imperative shell. Inside you’ll find a “domain” namespace that defines schema of our open space events and exposes pure functions transforming entities from one valid state to another. It focusses on the essential domain complexity; technical jargon like “SQL”, “HTTP”, or “JSON” does not belong there.
The neighbouring namespaces require the domain one and provide all the important moving pieces. Databases, ring handlers, and lifecycle of components are external to the problem domain. If I change the database or replace the HTTP interface with a Kafka client, the domain remains untouched. That’s what I want, because over the course of a typical application’s life there’ll be enough business reasons to change the domain logic.
Now we can ask what do we do if we need to add new functionality that has little to do with the existing logic. Say, a chat between participants. Where would we introduce it? Depending on the context, “elsewhere” might be a good answer.
Mind you, I don’t mean microservices. A valid elsewhere would be another tree of namespaces in the same project. I’d keep them isolated from the rest to minimise coupling. (Isolation checks could run in CI, with an ArchUnit-esque tool that Anthony brought up.) No matter what change you introduce in the chat subsystem, it should not break the event planning one.
Having those trees structured in a similar fashion would make the entire app easier to understand. But what’s even more important is exposing a consistent API across all the domains. Say, a Ring handler. That enables composability, another aspect stressed in the aforementioned book.
Composability is a quality I look for when assessing software design. Can I take the entire Spacy and expose it under a URL path prefix next to another Ring application? How much effort would it take, how much code would have to change? Better yet: can I start two instances of the same application in the same JVM behind a single Ring adapter? Implicit dependencies and global mutable state will quickly surface.
The problem with a back-end example like Spacy is its insignificant size. The application has but a handful of use cases, operates in a small domain, and doesn’t need to be fault tolerant or high scalable. It falls into the
O(armchair) complexity class. Things start to get hand-wavy when we start talking about database transactions or error handling.
That being said, I think that’s the rough structure I’d start a new project with. The main reasons are composability and malleability. We don’t have to get it spot on the first time as long as we can adapt and change it in the future.
Looking forward to your comments.