A functional domain model is made of pure functions and immutable types. As Domain Driven Design teaches us, it should be expressed in the language shared by everyone involved in the project.
Pure functions
A pure function always returns the same result given the same arguments.
Here are the fundamental properties of a pure function:
A function returns exactly the same result every time it's called with the same set of arguments. In other words a function has no state, nor can it access any external state. Every time you call it, it behaves like a newborn baby with blank memory and no knowledge of the external world.
Pure functions are calculations.
Here's an example of a pure function:
It doesn’t matter how many times we call the execute
function above, as long as we call it with the same command we will always get the same message back.
Immutable types
An immutable data structure cannot be changed after its instance was created.
Here's an example of an immutable type:
Once initialised, its properties cannot be changed:
val event = MessagePublished("Hello!")
// Won't compile: "Val cannot be reassigned"
event.message = "New message"
Since we used val
to store the reference to the object, it cannot be reassigned either:
val event = MessagePublished("Hello!")
// Won't compile: "Val cannot be reassigned"
event = MessagePublished("Bye")
Instead of modifying an immutable instance we need to create a new one:
Testing
Nothing gets close to the joy of writing tests for pure functions that work with immutable data structures.
We pass the input and then verify the output.
The good old Arrange-Act-Assert, a.k.a. Given-When-Then, is so evident.
This kind of tests are easiest to write, a joy to read, and the fastest to execute.
Reasoning scope
When considered in a narrow scope of a single function call, neither mutable types nor functions with side effects have a big impact on our reasoning or testing. They're manageable with a bit of discipline.
However, it gets more complicated in the wider context of the application.
If mutable data or its reference is shared, it could be changed from (m)any part(s) of the application. Similarly, if a function has side effects, its effects can be seen elsewhere. The order of calls starts to matter as well.
Immutable types and pure functions narrow down the scope of change. State changes are local to the place that triggered them and need to be explicitly passed up or down.
That helps with reasoning, as in our head we can now confidently replace the function call with its result (see referential transparency and equational reasoning).
Error handling
One last thing to consider in a functional domain model is our attitude to error cases.
A common approach is to use exceptions.
Unfortunately, exceptions cripple our reasoning ability, similarly to mutable types or side-effect functions, as they break referential transparency. Exceptions are in a way a form of non-local GOTO since they allow to jump up the stack out of the normal execution flow. These properties go against our functional model.
Therefore, it's good to make a distinction between domain errors, and infrastructure or panic errors (see types of errors). The first are expected while the latter are mostly exceptions.
Unchecked exceptions are not part of the function's signature, so without looking into the function's body we won't know what kind of errors to expect. The caller could be totally oblivious and ignore exceptions altogether.
Exceptions are implicit and unexpected (unless checked).
Errors, on the other hand, need to be explicitly defined as part of the function's signature.
The caller needs to either deal with the error or pass it up to the caller.
Rich domain model
Domain Driven Design teaches us to express the domain model in the language shared by everyone involved in the project.
If we make the domain types and functions rich in domain vocabulary, there's no translation needed in discussions with the business. In fact, using the same vocabulary we put into the code to talk to the stakeholders is a form of validating the domain model. They’ll catch any discrepancies in the language we use.
Furthermore, if we make illegal states unrepresentable, types become self-documenting. Limited options encoded in a rich model make for an improved ability to reason about it, and therefore maintain or change it.
Summary
The domain model is arguably the most important layer in an application. It should also be where complexity is tackled. After all, that's where business decisions live and that's what makes our application distinctive.
Making the domain model functional helps to reduce the complexity and concentrate it inside the model. Any impure operations will be pushed to the boundaries of the system. What's pushed outside might not be necessarily trivial, but it will be mostly business-decision-free code that deals with side effects.
Hopefully, we'll end up with a code base that's less prone to errors, easier to understand and maintain, and effortless to test.
I find that a functional domain model plays really well with event sourcing. That's what we're going to look at in the next post.