Domain-Driven Design in Clojure
This article is a work in progress. |
Domain-Driven Design is a fascinating approach to tackle the complexity in software design. In particular, the idea of explicitly modelling one’s domain and cultivating an ubiquitous language in the team resonate with me.
Unfortunately, most DDD resources, in particular the books by Evans and Vernon, are quite focused on object-oriented programming, and therefore not appealing to Clojure programmers. In this article, I discuss how to translate DDD concepts into a Clojure mindset.
Before we dive into the DDD building blocks, let us first look at how Clojure designs programs.
The Epochal Time Model
In his talk Are we there yet?, Rich Hickey discusses his view on how OOP went wrong, and describes Clojure’s approach to identity and state as the Epochal Time Model.
Instead of mutating objects, Clojure prefers to implement logic with pure functions operating on immutable data structures. An identity is a succession of states: Immutable values at a point in time. Rather than interacting with constantly changing objects, observers of an identity receive a snapshot of the identity in form of an immutable value.
Clojure offers several tools to represent identities in a thread-safe manner, such as Refs, Agents, and Atoms.
To do: Bigger picture: This model also applies to distributed systems. Here, identities need to be coordinated in a database. |
Building Blocks
In the second part of his DDD Reference, Evans summarizes the building blocks for model-driven design. Let’s see how they translate to Clojure!
Layered Architecture
The domain model implements all business logic and should be isolated from infrastructure, user interface, and application logic.
The idea of separating concerns into "layers", and to manage dependencies between them, is pretty popular and advocated by different architectural patterns such as Hexagonal Architecture or Clean Architecture. While the details vary, common layers include:
Layer | Description |
---|---|
Infrastructure |
Adapters to third-party libraries or frameworks, such as databases, external services, and so on. |
Presentation |
Handling of outside requests e.g. via UI, HTTP, command-line interfaces, or similar. Displaying or providing information to the user or client system. |
Application |
Use cases — how the application interacts with the domain model. Handles concerns such as transactions and security. |
Domain |
Business logic implemented in the domain model, independent of deployment considerations. |
Since this concept is independent of the underlying programming paradigm, it can be applied to Clojure programs directly, by assigning namespaces to layers and agreeing on allowed dependencies. Conventionally, namespaces can represent the layer they belong to using prefixes:
-
<project>.infra
-
<project>.web
(or<project>.ui
, etc.) -
<project>.application
(or<project>.app
,<project>.api
) -
<project>.domain
We can even enforce the architecture by writing tests that use the Namespaces API or the dependency graph provided by tools.namespace to verify that namespaces only depend on allowed namespaces.
Finally we can "physically" separate layers by splitting our code into separate projects (deps.edn) or modules (Leiningen) with dedicated classpaths.
Value Objects
Immutable objects that are identified by their attributes only.
This is a no-brainer for Clojure programmers because Clojure embraces immutable values. There is a wide range of immutable primitives, from numbers and strings to more specialized types such as UUIDs, dates and times.
Custom domain values will typically be represented by built-in Clojure types such as maps, vectors or keywords, rather than custom types.
It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures.
It makes sense to define specs describing the type and putting it in a namespace along pure functions operating on the values. This keeps related concepts close together.
(ns project.domain.money
(:require [clojure.spec.alpha :as s]))
(s/def ::amount int?)
(s/def ::currency keyword?)
(s/def ::money (s/keys :req [::amount ::currency]))
(defn money
"Creates a money value."
[amount currency]
{::amount amount
::currency currency})
(defn m+
"Adds to money values."
[a b]
(if (= (::currency a) (::currency b)
(update a ::amount + (::amount b)
(throw (ex-info "Currency mismatch" {:lhs a, :rhs b}))))))
Consider using generative testing for custom values.
Entities
Entities are classical "reference objects" whose identity is important for the domain, but whose attributes change over time. When persisted in a database, these have a unique ID representing the identity. They encapsulate data and expose behavior.
As described in The Epochal Time Model, Clojure prefers to separate identity and state. For the state, it is most natural to represent entities as persistent maps, essentially treating them the same as values:
(ns project.domain.bank-account
(:require [clojure.spec.alpha :as s]
[project.domain.money :as money]))
(s/def ::account-number int?)
(s/def ::balance ::money/money)
(s/def deposit [account amount]
(update account ::balance money/m+ amount))
In other words, entities are values, with the additional constraint that they
are maps and contain some form of identifying attribute, like ::account-number
in the example above.
We will discuss ways of how to represent changing states with stable identity in Aggregates.
Domain Events
To do…
Services
To do…
Modules
To do…
Aggregates
Aggregates define units of consistency, by clustering entities and value objects that require strong consistency guarantees. When using a database, aggregates are persisted as one unit, in one transaction, to guarantee consistency.
Aggregates are represented by one entity in the cluster, named the root. As such, we will represent their state the same as other entities in Clojure: as maps. Since aggregates form a unit, their map representation will include all nested entities and values. All external access to the values in an aggregate go through the aggregate root.
Changing an entity entails changing the whole aggregate. Functions operating on aggregate values can delegate to entity-changing functions:
(ns project.domain.aggregate
(:require [clojure.spec.alpha :as s]
[project.domain.entity :as ent]))
(s/def ::id string?)
(s/def ::children (s/coll-of ::ent/entity))
(s/def ::aggregate (s/keys :req [::id ::children]))
(defn update-child [aggregate index]
(update-in aggregate [::children index] ent/update-entity))
A simple single-process application without data persistence could use Clojure’s state primitives to represent an aggregate’s identity, such as atoms or refs.
Usually aggregates will be stored in a database, and accessed using Repositories.
Repositories
To do…
Factories
To do…
Application
To do…
References
-
Cockburn, Alistair. Hexagonal architecture
-
Evans, Eric (2004). Domain-Driven Design: Tackling the Complexity in the Heart of Software. Boston: Addison-Wesley.
-
Evans, Eric (2015). Domain-Driven Design Reference
-
Hickey, Rich (2009). Are we there yet?
-
Martin, Robert C. (2012). The Clean Architecture
-
Vernon, Vaughn (2013). Implementing Domain-Driven Design. Upper Sadle River, NJ: Addison-Wesley