Domain-driven design (DDD) provides many techniques and patterns to tame complexities in software applications – even when those are written in functional languages. Unfortunately, there are very limited resources when it comes to implementing DDD in functional languages. Even if you manage to find one, it often lacks the true essence of functional programming.
As a result, DDD is often considered as something only meant for object-oriented programming. For example, it is often argued that a lot of ideas from domain-driven design can be discarded because functional languages use immutable data structures by default.
While making the state immutable made what code was affecting the state more visible, the end result is still multiple pieces of code directly affecting what is essentially a global state (perhaps, stored in a database). Sure, a copy is passed from one function to the next, but there is still one 'current' state and everything is messing with it directly.
In a way, the problem is not the mutability of the state, it is the ownership of it. Who is responsible for keeping the state internally consistent?
Domain-driven design provides a set of patterns that address many issues like this. In this post, we will explore what makes domain-driven design a good fit even for functional languages.
Strategic vs tactical patterns
Domain-driven design (DDD) is divided into strategic patterns and tactical patterns. The strategic pattern consists of things like bounded context, ubiquitous language, and context map; the tactical pattern consists of concepts like value types, entities, and aggregate.
Strategic patterns are easy to map to any language. They mostly cover a higher-level design of software like bounded context, context map, anti-corruption layer, patterns for bounded context integration. These patterns do not depend on the programming language or framework used.
However, the tactical pattern relies on programming language constructs and paradigms. We will further explore how some of these tactical patterns can be applied in functional languages without losing the true essence of functional programming.
Aggregates
The idea behind aggregate is to enforce consistency and invariants. An aggregate is a place where invariants are enforced and act as a consistency boundary. When one part of the aggregate is updated, other parts might also need to be updated to ensure consistency.
One misconception that I had while mapping concepts like aggregate in functional programming (FP) from object-oriented (OO) was to only think in terms of only because data and behavior always co-exist in OO. However, in FP you tend to keep your data and functions separate.
Ubiquitous language is not only the collection of nouns in any domain but also the verbs, processes and constraints. Nouns correspond to data structure and verbs correspond to operation in your domain. Identifying verbs was also an important part because it decides which operation should be in the domain.
The distinction between value types and entities in functional programming
Classical (read object oriented) implementation of DDD differentiates between value types and entity types, based on their mutability and notion of identity. Value types are immutable and do not convey enough information on their own, for example, color could be a value type where color type does not hold any meaning in itself but when attached to an entity like a shirt or a car (for example a red shirt or a black car) they mean something in the domain.
On the contrary, an entity has a lifecycle. These are the types that are mutable and go through changes through different lifecycle events. For instance, order could be an entity that goes through different lifecycle events such as item added to order or item removed from order. Each lifecycle event mutates the entity.
In functional programming, everything is immutable by default, which leads us to wrongly believe that we don’t need a distinction between value types and entities. But the concept of value and entity types are based on the lifecycle of the domain model and therefore can be equally applied in functional languages.
Modeling aggregates
When a software application grows, you might end up partitioning your database or using a distributed database, which essentially means that the entities/aggregates which used to live in the same machine now live on different machines. Any assumption about the location of an entity in the codebase may not be valid anymore; any attempt to update more than one entity in a single transaction will enter the shaky realm of distributed transactions. So to avoid these pitfalls follow these three rules.
The aggregate as transaction boundaries: Each aggregate serves as a transaction boundary. This uniquely identified aggregate is the scope of the transaction, do not try to put more than one aggregate in one single transaction scope, as you can’t guarantee the success of a transaction if those aggregates move to different machines.
Messages are addressed to aggregate: Whether you are building microservices or a monolith application, you should not make any assumption about where the other aggregate lives. Each aggregate talks to another aggregate by sending a message to its address — a unique identity of the aggregate.
Aggregate represents the disjoint set of data: Don’t let different aggregates share models because they look the same or it is convenient. Don’t build a persistence layer to connect these disjoint aggregates just because you can. Don’t create a library to share models from different aggregates because it’s DRY, instead go the extra mile, to make sure that these aggregates represent a disjoint set of data.
More functional patterns in domain driven design:
- Lenses to update aggregates: In functional programming, it could be annoying to update a deeply nested aggregate since data is immutable. This is where lenses come into play. Lens allows you to update a deeply nested value, and get the whole updated aggregate back.
- Use monoids to represent value objects: This document does a great job of explaining monoids in DDD context.
- Use property based testing for testing domain invariants.
- If you want to go fancy, Use reader monad for dependency injection.
- Keep side effects at the edge by following Imperative shell and functional core pattern or using free monads.
DDD design principles might appear to conflict with some functional programming good practices but it is an important tool to model complex business domains. I think the key is to understand the essence of the DDD patterns and then find an appropriate construct/abstraction to represent them.
Disclaimer: The statements and opinions expressed in this article are those of the author(s) and do not necessarily reflect the positions of Thoughtworks.