How to know if your Domain model is properly isolated?
In this post, I’ll show a simple way to get to know if your domain model is properly isolated.
Domain model isolation
As I wrote previously, it’s a good idea to construct entities and value objects in a way that allows them not to depend on any external logic, such as the work with a database, 3rd party services, and so on. The main benefit you achieve by doing so is proper separation of concerns which in turn makes your code base easier to reason about.
In many cases, it’s pretty easy to say if this guideline is violated or not. Sometimes, however, it might not be a simple task.
Let’s take an example with a user and an email attribute. Assume you need to change the user’s email:
public class User : Entity
{
public string Email { get; protected set; }
public void UpdateEmail(string newEmail)
{
if (!string.IsNullOrEmpty(newEmail))
throw new InvalidOperationException();
Email = newEmail;
}
}
In terms of domain model isolation, this implementation is obviously good: the UpdateEmail
method is closed under primitive types and doesn’t depend on external concerns.
Now, let’s say we need to ensure email uniqueness while changing it. This requirement should not be a responsibility of the User entity but let’s implement it like this anyway for the sake of argument:
public class User : Entity
{
public string Email { get; protected set; }
public void UpdateEmail(string newEmail)
{
User user = Database.GetUserByEmail(newEmail);
if (user != null && user != this)
throw new InvalidOperationException();
Email = newEmail;
}
}
Here, the user gets an implicit dependency on the database, and this dependency breaks the isolation. Now, in order to reason about this class, we need to consider two unrelated concerns simultaneously: the domain logic itself and the work with the database.
Alright, how about this one?
public class User : Entity
{
public string Email { get; protected set; }
public void UpdateEmail(string newEmail, Database database)
{
User user = database.GetUserByEmail(newEmail);
if (user != null && user != this)
throw new InvalidOperationException();
Email = newEmail;
}
}
The external dependency is still there, we just made it explicit. The domain model is still not properly isolated.
Now, what if we replace the Database class with an interface? Doesn’t it mean we abstract that dependency out and make User pure again? Actually, no, it doesn’t. As I mentioned previously, in order for an interface to be a genuine abstraction, it should represent a concept meaningful for your domain. The work with a database is not one of such concepts, it’s just a persistence implementation detail.
Nice try, interface, but no.
Here’s another version:
public class User : Entity
{
public string Email { get; protected set; }
public void UpdateEmail(string newEmail, Func<string, bool> isEmailUnique)
{
if (Email == newEmail)
return;
if (!isEmailUnique(newEmail))
throw new InvalidOperationException();
Email = newEmail;
}
}
Okay, now this becomes confusing. Is this implementation adhering to the isolation principles? At first glance, yes, as there’s no direct dependency on the database. The delegate we pass into the method doesn’t tell us anything about the database either, it just takes a string and returns a boolean.
But is User really isolated taking into account that we do know the actual delegate we feed into the method talks to the database?
Identifying if your Domain model is properly isolated
There is a quite simple technique that helps answer this question and identify if your domain model is properly isolated.
As many other good ideas, this one comes from the world of functional programming. To apply it, we need to resort to the notion of method signature honesty. That is, we need to look at all methods involved in our domain model and explicitly state in their signatures the range of input values they accept and the possible outcomes they may produce.
Unfortunately, neither C# nor F# allows us to achieve the desired level of honesty when it comes to the work with the external world, so we need to take Haskell. Haskell’s type system is incredibly strict. If you’ve got a function that works with any kind of IO - be it the file system, network, or a database - the language enforces you to state that fact in the function’s signature. And that’s exactly what we need here.
I’m not going to write the actual Haskell code, though. Haskell is a great language but I don’t feel fluent in it, so what I’m gonna do instead is I’m going to introduce C# code which would be analogous to Haskell in terms of the work with IO. Keep in mind that C# doesn’t really have such a construct, though. Our exercise will be a purely imaginary one.
Alright, so how would the following code look like should we lift all the details regarding its inner workings to the signature level?
public class User : Entity
{
public string Email { get; protected set; }
public void UpdateEmail(string newEmail, IDatabase database)
{
User user = database.GetUserByEmail(newEmail);
if (user != null && user != this)
throw new InvalidOperationException();
Email = newEmail;
}
}
public interface IDatabase
{
User GetUserByEmail(string newEmail);
}
First of all, the IDatabase interface would look like this:
public interface IDatabase
{
IO<User> GetUserByEmail(string newEmail);
}
Note we changed the return type from User
to IO<User>
. This change is mandatory due to the fact that the implementation of this interface talks to the database and is enforced by Haskell’s type system. If we were to leave the interface as before, we wouldn’t be able to pass into UpdateEmail
any implementations of that interface that do work with the database, they simply wouldn’t be compatible.
In the world of strictly enforced method signature honesty, functions of type
string -> IO<User>
are not convertible to functions of type
string -> User
Having a dependency with such a method signature, the User class now must enter a special IO context in order to get a user from the database:
public IO<unit> UpdateEmail(string newEmail, IDatabase database)
{
User user = IO { database.GetUserByEmail(newEmail); }
if (user != null && user != this)
throw new InvalidOperationException();
Email = newEmail;
}
As you can see, the impurity in our domain model becomes obvious: the presence of the special IO context tells us about that. Moreover, the signature of the UpdateEmail
method itself also reveals the impurity. Note that the return type has been changed from void
to IO<unit>
. In Haskell, only impure methods can work with other impure methods, so any method that uses the IO context must also state that fact in their signatures.
This kind of chain reaction is often referred to as "impurity infection". A single sub-function that employs any type of IO infects the whole wrapping function and makes it impure as well. This process is very similar to what we have with async operations in C#. If you’ve got an async method, you have to make the method calling it async too and change its return type from T
to Task<T>
. Admittedly, though, in C#, we have a couple of hacks that allow us to break out of the Task monad.
By the way, if you wonder what this unit
from the return type is, it’s an FP analog for void
. Unlike void
, however, you can pass unit
further to other methods, and that fosters composability.
Alright, introducing an interface doesn’t help us with adhering to the domain model isolation principles. But that’s something we knew already. What about the delegate? Here it is again:
public void UpdateEmail(string newEmail, Func<string, bool> isEmailUnique)
{
if (Email == newEmail)
return;
if (!isEmailUnique(newEmail))
throw new InvalidOperationException();
Email = newEmail;
}
To see if this code doesn’t bring any external dependencies, we need to "haskellify" it too. How would the delegate’s signature look like should we bring honesty to it? It would look like this:
Func<string, IO<bool>> isEmailUnique
Once again, if we leave the delegate as before, we wouldn’t be able to use a method that actually refers to the database in place of that delegate, Haskell simply forbids us from doing so. We must specify the work with IO explicitly in the delegate’s signature.
Therefore, in order to invoke the delegate, we should enter the IO context here too:
public IO<unit> UpdateEmail(string newEmail, Func<string, IO<bool>> isEmailUnique)
{
if (Email == newEmail)
return;
bool unique = IO { isEmailUnique(newEmail); }
if (!unique)
throw new InvalidOperationException();
Email = newEmail;
}
And that, as you can see, once again makes the UpdateEmail
method impure which is explicitly manifested by its return type.
Conclusions
So back to the initial question regarding the delegate and the domain model isolation. Although C# allows us to hide the work with the IO behind the scenes, it doesn’t really change the fact that such delegate introduces impurity to our domain model. And we can clearly see that if we make the delegate’s signature completely honest using the haskellifying technique. If after applying this technique, any method in your domain entities and value objects contain IO in their signatures, your domain model is not properly isolated.
Another way to answer this question is to re-state it like this: are you able to unit test a method without involving any sort of a test double? If the answer is no, it also means your domain model is not protected from the external influence. In the above example with the delegate (the initial version of it), we have to use a stub in order to substitute the actual call to the database.
Unfortunately, there’s no way around this. The versions with the interface and the delegate are merely hacks and don’t help with achieving genuine domain model isolation. They rely on the mildness of the C#'s type system which allows you to hide the work with external dependencies from the eyes of the client code.
UPDATE
I need to add a remark here. There is a dichotomy between the domain model "wholeness" and its purity. On one hand, the isEmailUnique
delegate talks the language of the domain, and excluding it from the domain entity can potentially result in incompleteness (although this can be mitigated by extracting that logic into a domain service). On the other hand, the delegate introduces impurity. It’s up to the developers to decide where they want to lean on this spectrum. I personally lean towards purity but I wouldn’t say it’s always justified and I understand arguments for the other part of this spectrum.
Summary
To see if your domain model isn’t influenced by the external world, you need to bring honesty to all methods inside your entities and value objects.
-
Apply the haskellifying technique: if any method works with any kind of IO (database, file system, etc.) state it in the method’s return type. Methods that work on top of it become impure too.
-
After haskellifying your code, look at entities and value objects. Do methods inside them have
IO
in their signatures? If yes, it means your domain model is not properly isolated.
Related articles
Subscribe
Comments
comments powered by Disqus