Taming complexity in Functional languages with Domain-driven design
Photo by Sharon McCutcheon on Unsplash

Taming complexity in Functional languages with Domain-driven design

For quite some time I have been working in micro-services 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 doesn’t stay a micro-service, with time it does more than what it should, foreign concepts starts 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 (also hard to understand) 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

No alt text provided for this image

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 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.

  1. There was no boundary around the business domain. Business logic was scattered all around the codebase.
  2. 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.
  3. There was no separation of pure business concern from other side-effects.
  4. 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.

Taming the dragon

We decided to improve our codebase before we could do the splitting, we realized there was definitely one bounded context which can be separated out from the service.

Establishing a Ubiqoutous language of Domain

No alt text provided for this image

We knew that domain-driven design could solve most of our problem, 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:

  1. We came up with a glossary and established what each term means in our domain.
  2. 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

No alt text provided for this image

We decided to use Aggregate pattern from DDD to model our bounded context. Aggregate in DDD can be defined as below.

  1. Aggregate is a cluster of a domain object that can be treated as a single unit (a layer of abstraction).
  2. Aggregate is the protector of business rules (called invariants in DDD parlance) in a given bounded context.
  3. Aggregate makes sure that the domain model of our application is transactionally consistent.
  4. 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. are irrelevant concepts in Clojure. 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.

Another issue was that in Object-oriented languages, 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.

  1. Anything that product owners care about, for instance how and when a discount is applied or a calculation is done.
  2. 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.
  3. 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.
  4. 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.

  1. Any calculation of data-structure that POs don’t care about.
  2. Any concept which is not important in our bounded context. For instance, any derived calculation that does not represent any concept in our domain.
  3. Any calculation which does not update aggregate. For instance, calculations are done for a specific consumer of our API.

We held another domain modeling 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.

要查看或添加评论,请登录

Naveen Negi的更多文章

社区洞察

其他会员也浏览了