Violating the Dependency rule

Posted on by Matthias Noback

I write about design rules a lot, but I sometimes forget to:

  1. Mention that these rules can't always be applied,
  2. Describe when that would be the case, and
  3. Add examples of situations where the rule really doesn't matter.

The rules should work in most cases, but sometimes need to be "violated". Which is too strong a word anyway. When someone points out to me that I violated a rule, I'm like: Wow! I violated the rule? I'm so sorry! Let's fix this immediately. Whereas in practice it should be more like: Yeah, I know that rule, but it makes more sense to follow that other rule here, because [...]. In other words, pointing out that a certain rule has been violated should not be a sufficient reason to adhere to that rule. My favorite example is "But that violates SRP!" (Single Responsibility Principle). Whoops, I wouldn't want to do that! Or would I?

Actually, I would!

The Dependency rule

The same is true for another rule that I know as Robert Martin's Dependency rule. Transposed to a layered architecture with an Infrastructure, an Application, and a Domain layer, this rule allows the following dependency directions:

  • From Infrastructure to Application
  • From Infrastructure to Domain
  • From Application to Domain

Dependencies between classes within the same layer are allowed too.

Following this rule, a domain object should never use an infrastructure class (my favorite example: an Order entity should never generate a PDF invoice). A simplistic way of verifying the rule requires that all the classes live in a namespace that match the name of the layer to which they belong. To check if we didn't "violate" the dependency rule, we'd have to iterate over all the classes and look at two things: the namespace of the class and the namespaces of the classes and interfaces that are imported in this class.

Let's take a look at an example. The following class is in the Domain layer. It only has dependencies within the Domain layer, which is great:

namespace LeanpubBookClub\Domain\Model\Member;

use LeanpubBookClub\Domain\Model\Purchase\PurchaseId;

final class Member
{
    // ...

    public static function register(
        MemberId $memberId,
        PurchaseId $purchaseId
    ): self {
        // ..
    }

    // ..
}

As soon as the class has a dependency from a "higher" layer like Infrastructure (like the Email class in the following example), we should get some kind of warning:

namespace LeanpubBookClub\Domain\Model\Member;

use LeanpubBookClub\Domain\Model\Purchase\PurchaseId;
use LeanpubBookClub\Infrastructure\Email;

final class Member
{
    // ...

    public static function register(
        MemberId $memberId,
        PurchaseId $purchaseId,
        EmailAddress $emailAddress
    ): self {
        // ..

        Email::create()
            ->setTo($emailAddress->asString())
            // ...
            ->send();

        // ...
    }

    // ..
}

Some teams use deptrac to verify the dependency rule, and it works exactly like what I've described here. This tool will warn you about using the Email class inside the Domain namespace while that class lives in the Infrastructure namespace. How to make the warning go away? We could accomplish that by moving the entity to the Infrastructure namespace:

namespace LeanpubBookClub\Infrastructure\Model\Member;

use LeanpubBookClub\Domain\Model\Purchase\PurchaseId;
use LeanpubBookClub\Infrastructure\Email;

final class Member
{
    // ...
}

Then we'll only have dependencies within the same layer, which is totally fine.

By now you should be shouting behind your screen: "No! Entities belong inside the Domain layer, they aren't Infrastructure code!" Of course, you're right. This isn't a solution at all. Heck, we could even move all of our classes to the Infrastructure namespace and be done with the dependency rule forever.

How to fool deptrac

This illustrates that validating dependency directions using deptrac is cool, but there's one thing you should be aware of: deptrac can't verify that your class is in the correct layer. In order to do that it should look at each class, figure out what type of class it is, and according to a certain rule set make a judgement. E.g. "This class looks like an entity. It is mutable, has an identity, and is saved by a repository. It should be in your Domain layer, not in the Application layer."

Maybe we could assist the analyzer by annotating certain classes, e.g.

/**
 * @DesignPatterns\Entity
 */
final class Member
{
    // ...

    public static function register(
        MemberId $memberId,
        PurchaseId $purchaseId,
        EmailAddress $emailAddress
    ): self {
        // ..

        Email::create()
            ->setTo($emailAddress->asString())
            // ...
            ->send();

        // ...
    }

    // ..
}

Then the analyzer could say: you can't send an email inside an entity.

Now imagine that `Email::send()`` doesn't really send emails? What if it's just a harmless builder?

final class Email
{
    private ?string $to = null;

    public static function create(): self
    {
        return new self();
    }

    public function setTo(string $to): self
    {
        $copy = clone $this;

        $copy->to = $to;

        return $copy;
    }

    public function send(): int
    {
        return 0;
    }
}

This kind of juggling with objects and values is pretty much indistinguishable from the use of value objects which is quite common in domain objects. Should the analyzer still tell us that we're Doing It Wrong? It's only the reader who can recognize that "mailing" is an infrastructure-level activity and shouldn't happen here.

Analyzing code for IO activities

In order to properly designate each class to the correct layer an analyzer should have the ability to go into a class and find out if it's doing any of these infrastructural activities, which could be for instance:

  • Talking to a database or any other service over the network
  • Doing file system operations
  • Retrieving the current time
  • Retrieving random data

All of these activities can be summarized as "doing IO". There are also activities that don't require IO at all. For these activities the application only has to look at the data that is already in its memory. It then performs some transformations on this data to get a certain result.

For functional programmers the concept of IO is very familiar. Functions will be divided into pure and impure functions. The impure functions are the ones that need IO, the pure functions don't. The interesting thing about this is the asymmetry: an impure function can call a pure function, but as soon as a pure function calls an impure function it becomes impure (a nice read on this topic is Functional Architecture - a definition, by Mark Seemann).

The distinction between pure and impure functions is where rules like the Dependency rule come from. We want to ensure that the core of our application contains pure functions only, and that it is surrounded by impure functions that facilitate the communication with external actors for us. Having a core of pure functions is great for our testing abilities, and it leaves our domain logic decoupled from infrastructural concerns. However, the Dependency rule is defined for object-oriented programs, not for functional ones. So we need some kind of definition of "object-pure". I've written about this in more detail in my latest book, but let's summarize it here.

Classes can be considered object-pure if they don't contain code that requires IO to run. On top of that, every dependency recursively also needs to be object-pure.

We need an analyzer that can recognize whether a class is object-pure. If a class isn't pure, it should definitely go into the Infrastructure layer. If it is, it can be in either the Application or the Domain layer, depending on what types of classes you prefer to have in either of these layers (by the way, make sure to document this for your team). Only then would it make sense to say: Application or Domain classes can't have dependencies on Infrastructure classes.

Common violations of the dependency rule

This finally brings us to some common "violations" of the Dependency rule that readers have pointed out in my code. In the read-with-the-author project I'm using TalisORM for saving my entities to a relational database. This requires the entity to have some mapping code, including a fromState() method that can reconstruct an entity based on the database record provided to it. I like to use a Mapping trait in my entities to make that work easier, type-safe, and explicit about treatment of null values:

namespace LeanpubBookClub\Domain\Model\Member;

use LeanpubBookClub\Infrastructure\Mapping;
use TalisOrm\Aggregate;
use TalisOrm\AggregateBehavior;
use TalisOrm\AggregateId;
use TalisOrm\Schema\SpecifiesSchema;

final class Member implements Aggregate, SpecifiesSchema
{
    use AggregateBehavior;
    use Mapping;

    // ...

    public static function fromState(array $aggregateState, array $childEntitiesByType): self
    {
        $instance = new self();

        $instance->memberId = LeanpubInvoiceId::fromString(self::asString($aggregateState, 'memberId'));

        // ...

        $instance->wasGrantedAccess = self::asBool($aggregateState, 'wasGrantedAccess');

        // ...

        return $instance;
    }

    // ...
}

The problem is: use LeanpubBookClub\Infrastructure\Mapping. A tool like deptrac would trip over it and say: a class in the Domain layer can't depend on a class (or trait) in the Infrastructure layer. This is where things become subtle. Yes, the Mapping trait is inside the Infrastructure namespace, but it doesn't have infrastructure characteristics. It looks like this:

trait Mapping
{
    // ...

    /**
     * @param array<string,mixed|null> $data
     */
    private static function asString(array $data, string $key): string
    {
        if (!isset($data[$key]) || $data[$key] === '') {
            return '';
        }

        return (string)$data[$key];
    }

    // ...
}

All it does is take out a value from an array and make sure it is of the expected type. There are other approaches that could be used, like assert that the value is in fact a string, instead of casting it. But the point here is that these are just simple in-memory transformations of values. These functions aren't sending emails or anything!

Two options: either you ignore that the dependency goes from Infrastructure to Domain, because it isn't harmful. Or you move the class/trait to another namespace which would make the warning go away.

A third option is to say: maybe the dependency rule doesn't hold here. What if the rule isn't adequate, or specific enough?

Let's see another example, this time from the mail-comments project. Here I let certain domain events implement a LoggableEvent interface. A generic event subscriber listens to all domain events. When a domain event implements the LoggableEvent interface it will use the interface methods to assemble a log message:

namespace MailComments\Domain\Model\Post;

use MailComments\Infrastructure\LoggableEvent;
use MailCommentsCommon\Domain\PostId;
use Psr\Log\LogLevel;

final class PostWasCreated implements LoggableEvent
{
    // ...


    public function logMessage(): string
    {
        return 'Post was created';
    }

    public function logLevel(): string
    {
        return LogLevel::INFO;
    }

    public function logContext(): array
    {
        return [
            'postId' => $this->postId->asString(),
            'url' => $this->url
        ];
    }
}

Logging usually involves a call to some external element like the file system or a logging server. Yet, the interface itself nor the implemented methods involve actual IO. They only provide values that are already available in memory.

Again, we have two options: we can ignore this dependency, or we can move the LoggableEvent interface to the Domain layer. Both options are fine, but I think the more insightful conclusion is: the dependency rule actually doesn't apply to this situation.

Although in code the LoggableEvent interface and the Mapping trait are both dependencies, they are pretty harmless because they don't require IO.

In both of the previous cases where deptrac would trip over an import from the Infrastructure namespace, using the dependency has no impact on our ability to run the code in isolation. Using the Member entity or the PostWasCreated doesn't require a database or a log file, so this code can safely be called in a unit- or isolated test suite. Talking about logging or column mapping doesn't make the code "impure" at all, since it doesn't use IO.

I'm bringing up these examples to challenge existing ideas about the Dependency rule, and when we'd be violating it. Trying to generalize these examples using the terminology that I introduce in "Object Design Style Guide":

  • An object can be: 1. A service 2. Some other object (i.e. entity, value object, domain event, DTO)
  • If an object needs IO, it has to be service (e.g. an entity can never use IO directly).
  • Any object can depend on a service, but if that service uses IO, it needs to depend on an abstraction (at the very least an interface).
  • If a service uses IO it immediately belongs to the Infrastructure layer.

On top of these rules you can add layering or whatever, but following these rules is more important than following layering rules and the Dependency rule.

Conclusion

Summarizing what we've covered in this article:

  • Dependencies between layers should go only from Infrastructure to Application/Domain or from Application to Domain.
  • This can be verified by a tool, but the tool can't decide if a class is in the right layer.
  • If a class relies on IO it should be in the Infrastructure layer.
  • We could have tooling that helps us determine if code uses IO or if it doesn't (in that case it can be called "object-pure")
  • Some apparent violations of the dependency rule are harmless since they don't rely on IO.

The alternative to letting tools enforce the rules is of course discipline. Even though I'm pretty sure we could use PHPStan or Psalm as a platform to build the tooling described here, we don't need it if everybody understands the rules and applies them. It does mean we'll have to keep reflecting on what we're doing with our code (although I don't think that's bad per se!). It's easy to mess up if you don't keep checking that the rules will be applied.

PHP design object-oriented programming
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Roman Dukuy
Thanks, for a nice article!