Taming Complexity in functional languages with Domain driven design
For quite some time I have been working with microservices in Clojure. I feel Clojure works very well with micro-services architecture because code with micro-services is small and well contained and most of the Clojure best practices can be applied effectively. However, things get interesting when micro-services don’t stay a micro-service, with time it does more than what it should, foreign concepts start to creep in in the core domain. Services started combining multiple bounded contexts into one.
There is a lot of resources around writing good code in object-oriented languages, but for functional languages, there exists a spectrum, on one side lives the pure functional paradigm and drives most of its inspiration from lambda calculus and category theory and on the other side of spectrum lives object-oriented code in guise of functional language. Most of the functional code that I see lives somewhere in between, neither here nor there (which I believe is worst).
Big Ball of Mud
Our codebase also lived somewhere in between. This was the point where we decided that we need to split the service, but it turned out to be super hard as there were multiple bounded contexts coupled with each other. Clojure's ability to manipulate and transform data is powerful but at the same time this power without proper design was causing us more harm, code complexity and maintainability were getting out of hand. In more concrete words below issues were plaguing us before we could even think about splitting this service.
- There was no boundary around the business domain. Business logic was scattered all around the codebase.
- Most of the time we were doing these data transformation on the fly wherever needed, as a result, there was no guarantee that all of the business rules were respected.
- There was no separation of pure business concern from other side-effects.
- Foreign concepts which did not belong to our domain were creeping in. Many of these calculations were being done because a consumer needed it, every time we tried to introduce a new feature there was no clear answer whether our service should do it or the consumer should do it.
Establishing the Ubiquitous language of Domain
We decided to improve our codebase before we could do the splitting, we realised there was definitely one bounded context which can be separated out from the service.
So we started with an event-storming session with our product owners in order to established a ubiquitous language of our domain and identify which concepts belonged to our domain and which ones should be moved out. After this event-storming session:
- We came up with a glossary and established what each term means in our domain.
- We decided to model only those concepts that truly belonged to our domain and remove all ad-hoc concepts which were there to only support consumers of our API. This essentially meant separating the core domain from the representation/API layer.
DDD Aggregate for transactional consistency
We decided to use Aggregate pattern from DDD to model our bounded context. Aggregate in DDD can be defined as below.
- Aggregate is a cluster of a domain object that can be treated as a single unit (a layer of abstraction).
- Aggregate is the protector of business rules (called invariants in DDD parlance) in a given bounded context.
- Aggregate makes sure that the whole aggregate is transactionally consistent.
- Single source of truth for what business rules are.
However, implementing the Aggregate pattern in our service turned out to be rather difficult. Because most of the DDD resources assumes that you are using an object-oriented language. We were skeptical about implementation details, as many of the best practices in OO are diametrically opposite to best practices in Clojure.
Modeling Aggregate in Clojure
Clojure is immutable by default and as a result, you can discard a lot of the ideas about restricting change to data (for instance data-encapsulation, information hiding, etc. in Object-oriented languages ). what this means is you are free to change the data-structure anywhere as it does not modify the original data.
However, this philosophy goes against how aggregates are implemented in DDD. DDD aggregate restricts the changes to data and stipulates that any changes to the domain model must go through the aggregate root.
I think many Clojure best practices work well where-in you don’t have a lot of complex business rules, However, In our case, the domain was complex and any single update to our domain model required many other parts to be updated while still maintaining all business invariants.
Team also raised concerns that this style of code feels very OOish, It took some time to convince the team that the aggregate pattern is not about data-encapsulation or information hiding. Methods in the aggregate are not some random methods but they are part of the ubiquitous language of our domain, these are the operation that product owners care about, and if you change anything here, you better consult the PO first.
In OO, data and function and always live together, however, this is not the case in functional languages. This is important, because if data and function don’t live together than what do you call domain, data or functions or both.
One realization for us was that Ubiqoutous language is not only the collection nouns in any domain but also the verbs. Nouns correspond to data-structure and verbs corresponds to operation in your domain. Identifying verbs was also an important part because it decides which operation should be in domain.
Clojure and DDD
So in the end, we came up with the following rules on what goes inside the aggregate boundary.
- Anything that product owners care about, for instance how and when a discount is applied or a calculation is done.
- Any update to the aggregate model which involves enforcing business invariants/rules. This is necessary in order to make sure that when aggregate is updated, all business invariants are maintained.
- Any update that affects other parts of aggregate. This is to ensure transactional consistency of aggregate. So that aggregate is never in an inconsistent state.
- Anything that is part of the ubiquitous language of our domain, this includes both data-structure and operation/function.
Having decided what goes inside aggregate, We outlined main scenarios wherein any function or data-structure could live outside the boundary of aggregate.
- Any calculation of data-structure that POs don’t care about.
- Any concept which is not important in our bounded context. For instance, any derived calculation that does not represent any concept in our domain.
- Any calculation which does not update aggregate. For instance, calculations are done for a specific consumer of our API.
We held another domain modelling session, this time only with devs and tried to come us with some sort of schema for data in our domain model. This was the point where we decided to use Clojure specs to define the data structure of our domain model. We also represented each domain concept as a separate namespace put related functions in that namespace.
DDD design principles might conflict with some of functional programming best practices but I think it is an important tool to model complex business domain. However, at the same time, we should also explore ways of modeling complex domain in a more functional way. Sometime back I found this talk by Conal Elliot where he talks about denotational design where he talks about modeling domain in terms of transformation and composition.