Domain model isolation

I’ve been using the term "domain model isolation" for a long time already but just recently realized that its meaning might not be as obvious as I thought. In this post, I’ll try to describe what it means to properly isolate your domain model and why it is important.

Domain model isolation

The notion of domain model isolation is very similar to what functional architecture proposes: you make the code that comprises the core part of your application pure while pushing the side effects and the work with external dependencies (such as the database) to the edges of a business operation, either to the beginning or to the end of it. The immutable core in that sense is the domain model. The mutable shell is the application services layer.

There’s more to it, however, and the notion of immutable core in functional architecture does not fully correspond to an isolated domain model. In other words, a domain model can be fully isolated but at the same time - not entirely pure in a functional sense. The opposite situation can also be the case:

Immutable Core vs Isolated Domain Model
Immutable Core vs Isolated Domain Model

The main difference between the two is in how they regard immutability. While in functional programming side effects tend to be pushed away from the immutable core entirely, DDD in general doesn’t prohibit them as long as they are restricted by the boundary of the domain model.

To put it differently, an operation that is invoked on a domain model can modify the state of the objects in it, but should not in itself result in a change somewhere in the file system or in a 3rd party API. That’s what your application services are for. As I wrote in the previous post, the domain model is responsible for making business decisions while the app services layer converts those decisions into visible bits, such as changes in the database.

What makes a domain model isolated?

The easiest way to determine if your domain model is fully isolated is to diagram entities and value objects in it along with the classes from the surrounding world. If those entities and value objects talk to any classes other than their neighbor entities and value objects, your domain model lacks isolation:

Example of a leaking Domain Model
Example of a leaking Domain Model

Here, for example, the relationship between Person and Address is fine and doesn’t contradict the concept of domain model isolation. However, you can also see that the Person entity refers to the corresponding repository, and the Address value object uses a location API. Both of these two classes are gateways to the external world, and thus interactions with them break the domain model’s isolation.

Another way of looking at it is using the notion of onion architecture. If we put the classes above to appropriate layers in an onion, here’s what we’ll get:

Domain model isolation from an onion architecture perspective
Domain model isolation from an onion architecture perspective

The relations you see on this diagram point upwards, from the inner core of the onion to the external layers. At the same time, the onion architecture tells us that all communications should go in the backward direction only, from the upper layers to the inner ones. Violation of this guideline tells us that the domain model is leaking.

An isolated domain model is a closed one. All operations on the domain model should be closed under its entities and value objects. In other words, the arguments of those operations, be they explicit or implicit, and their return values should only consist of primitive types or the domain classes themselves.

Here’s an example illustrating this idea:

public sealed class Address : ValueObject<Address>
{
    public string AddressString { get; }
    public string ZipCode { get; }
 
    public Address(string addressString)
    {
        AddressString = addressString;
        ZipCode = new LocationApi().LookupZipCode(addressString);
    }
}

The Address value object accepts an address string and uses a Location API to look up corresponding zip codes. The LocationApi class here is an implicit argument to the constructor. It’s not a domain class, nor it is a primitive. Therefore, this implementation of the domain model is not closed under itself, it is not isolated.

Note that the notion of isolation is only applicable to entities and value objects. They are the heart of any domain model. Other domain classes, such as factories, domain services, and, obviously, repositories can and usually do refer to the external world.

Another example is the Active Record pattern:

public class User : Entity
{
    public void Save()
    {
        /* Saves itself to the database */
    }
 
    public void Load(int id)
    {
        /* Loads itself from the database */
    }
}

Entities implementing this pattern work with the database directly in order to save or load themselves to and from the database. The implicit argument they use to do that - the database - also breaks the closure.

Domain model isolation and dependency injection

Let’s look at a common practice for getting around this whole isolation issue. I sometimes see people agreeing that the following code has a smell:

public sealed class Address : ValueObject<Address>
{
    public string AddressString { get; }
    public string ZipCode { get; }
 
    public Address(string addressString, LocationApi locationApi)
    {
        AddressString = addressString;
        ZipCode = locationApi.LookupZipCode(addressString);
    }
}

And then immediately proposing a "fix" for that smell. That is, introducing an interface:

public sealed class Address : ValueObject<Address>
{
    public string AddressString { get; }
    public string ZipCode { get; }
 
    public Address(string addressString, ILocationApi locationApi)
    {
        AddressString = addressString;
        ZipCode = locationApi.LookupZipCode(addressString);
    }
}

This, of course, doesn’t make any difference. Introducing an interface for some external concept and injecting it into an entity or value object is just a hack to avoid the effort required to achieve genuine domain model isolation. The reason why it is a hack is that such interfaces don’t represent an abstraction, they are merely a tool to enable unit testing.

You can, of course, apply the practice of dependency injection in the domain model but those dependencies must represent an actual abstraction meaningful to your domain, not something coming from the external world. In many cases, it means that the interfaces you inject to your entities and value objects must be implemented by other entities and value objects. Also, the Principle of Reused Abstractions tells us that, in order to be considered a good abstraction, those interfaces should have more than one implementation. Some programmers even go as far as suggesting that, for each interface, there should be at least 3 classes implementing them.

That’s, by the way, the whole premise behind the Dependency Inversion Principle (DIP). While you can easily implement Dependency Injection using some arbitrarily chosen interfaces, those interfaces don’t automatically follow the DIP principle.

All this combined makes a well-designed and isolated domain model unlikely to have any interfaces "abstracting" entities and value objects. Entities and value objects in themselves represent the problem domain really well, there’s usually no need to abstract them further.

Semantics pollution

Another sign of a domain model closed under itself is when it doesn’t operate classes that come from other domains/bounded contexts. If you’ve got a class that came from an SDK of some third party service, you shouldn’t pass it directly to your domain classes, even if that class in itself is just a bag of data and doesn’t refer to the external world.

The reason is that such external concepts almost always contain data your core domain doesn’t need. This data therefore should be filtered out in order to adhere to the YAGNI principle.

Another reason is invariants. You don’t have control over the promises the external services make and even if you validated them manually once, you can’t be sure they are held at all times. To ensure validity of the data coming from external services, you need to wrap them with your own classes which would be part of your domain and which would explicitly state the invariants you expect the external service to keep.

The same is true for classes coming from other bounded contexts. The best way to isolate your domain model from them is to implement an anti-corruption layer which would translate alien notions into something meaningful to your domain classes.

Note that having an anti-corruption layer, while beneficial in many cases, is not always justified and your domain model might very well live in a partnership relation with other bounded contexts or even have a shared kernel with them.

Why isolate the domain model?

The guidelines above look like a lot of work, so it’s legit to ask the following question: why bother at all? Why put so much effort?

The answer, as always, is to battle complexity. Proper separation of concerns allows you to reduce the cognitive load needed to reason about the code base. The more complex your project gets, the more important that issue becomes. On sufficiently large code bases, it is crucial to be able to think of your domain model in isolation from all other concerns. In many cases, it is effectively the only way you can keep up with the ever-growing complexity of your project.

Another reason is testability. Having a domain model closed under itself makes the process of unit testing trivial. With such a model, you can easily apply output and state verification which give the best return on investment.

Note that full isolation of entities and value objects is not always possible to achieve, and there could be situations where you simply can’t do that. For example, in the face of tight performance requirements, you might have no option other than cutting the corners. Nevertheless, domain model isolation is a good goal to aim for as it provides substantial benefits.

Summary

  • An isolated domain model is a model operations on which are closed under its entities and value objects.

  • The notion of domain model isolation is only applicable to entities and value objects. Other domain classes can talk to the external world.

  • This concept is similar to the notion of immutable core from Functional Architecture. The main difference is that the former allows for side effects which are restricted by the boundaries of that model.

  • Replacing an external dependency with an interface does not mean you fix the leaking domain model. In order to adhere to the Dependency Inversion Principle, the interface should represent a business-meaningful abstraction in that it should help your domain classes make business decisions.

  • Semantics pollution is another sign your domain model is not properly isolated.

  • Domain model isolation brings two benefits: it reduces code complexity and enables better testability.

Subscribe


I don't post everything on my blog. Don't miss smaller tips and updates. Sign up to my mailing list below.

Comments


comments powered by Disqus