Enforce architecture rules with Deptrac
Rubén Rubio
Posted on October 31, 2023
Introduction
When we use some architecture in our applications, there are dependencies between layers that we must enforce. This happens, for instance, when we apply hexagonal architecture. In this case, for this architecture, we use these layers, from the most inner to the most outer:
-
Domain
: the elements which represent concepts of our domain: value objects, aggregates, entities, domain events, domain services... The code in this layer is pure PHP without external dependencies. -
Application
: the use cases of our applications: commands (that modify our system), and queries (that consume data from our system). In this layer, the code is pure PHP too, without external dependencies. -
Infrastructure
: the code that interacts with external services (database connections or APIs), libraries... We also include in this layer the framework we use and the user interface layer, which includes the controllers.
The dependency between these three layers is from the inside to the outside:
-
Domain
must only use elements from within itself. It does not depend on any other layer, nor can it use external libraries. -
Application
may use elements from itself and from theDomain
layer. It must not use external libraries, either. -
Infrastructure
may use elements from theApplication
andDomain
layers, besides being allowed to use external libraries.
We can see the dependencies between layers in the following diagram:
However, PHP does not offer any mechanism to enforce these dependencies between layers. This means that, even if we want to apply hexagonal architecture, we could violate any dependency and not notice.
Fortunately, PHP's ecosystem is rich, and there are different tools to validate and enforce our architecture:
- Deptrac
- PHP Architecture Tester (as a PHPStan plugin)
- PHPArkitect
In this post, we will see how to set Deptrac up to enforce the rules of hexagonal architecture that we described.
Deptrac
Deptrac is a command-line tool that validates dependencies defined in a configuration file. If there is any violation of those rules, it returns an exit code different of 0
. Thus, it is a useful tool to add to the continuous integration of our application.
Concepts
- Layers: groups of tokens (classes, functions...). An example would be all the classes in our domain layer. Deptrac offers different collectors to select these tokens: by directory, by namespace, by class name, by function name...
- Rulesets: rules that define the allowed communications between layers. For instance, the application layer can use elements from the domain layer. By default, there is no allowed dependency between layers. All dependencies must be explicit.
- Violations: dependency errors between not-allowed layers. For example, if our domain layers access the application layer, which is not allowed.
Definition
We can now write our configuration file in YAML format:
deptrac:
paths:
- ./src
layers:
# Layer definition
- name: Domain
collectors:
- type: directory
regex: src/Domain
- name: Application
collectors:
- type: directory
regex: src/Application
- name: Infrastructure
collectors:
- type: directory
regex: src/Infrastructure
# Vendor
- name: DomainVendor
collectors:
- type: className
regex: ^(Brick\\Math|Brick\\Money|Doctrine\\Common\\Collections|Ramsey\\Uuid)\\.*
- name: Vendor
collectors:
- type: className
regex: ^(Symfony|CuyZ\\Valinor|League\\ConstructFinder|League\\Tactician)\\.*
ruleset:
Domain:
- DomainVendor
Application:
- Domain
- DomainVendor
Infrastructure:
- Domain
- Application
- DomainVendor
- Vendor
First, we need to define the folders with the code to analyze in the paths
key. In this case, I used a Symfony project, where the application code usually lives under the src
folder.
Next, we define the layers within the layers
key. We have one layer for each of our layers, with a directory
collector
.
Theoretically, our domain should only contain pure PHP code without any external dependencies. Nonetheless, there are some libraries that we want to use in our domain, such as ramsey/uuid
to generate UUIDs, or brick/math
to work safely with numbers. Another usual example of an allowed external library in our domain is doctrine/collections
when we use Doctrine as an ORM, because the relationships between entities must be of type Doctrine\Common\Collection
.
The domain should only contain pure PHP code, as we stated, but there are some occasions when we have to make concessions: Is it worth it to reimplement a UUID generator only to keep our domain pure? It is just not worth it. Most of the time, we do not need to reinvent the wheel. We can say no to having external code in our domain and, at the same time, say yes to allowing the libraries we need in our domain. It must always be a whitelist, i.e., we need to choose which libraries we allow in our domain and not allow them all by default. We need to make these decisions consciously and critically.
Thus, we define a layer named VendorDomain
, where we specify the libraries we allow in our domain. In this case, we use a collector
by namespace.
At the same time, in the infrastructure layer, we allow access to third-party libraries. We could be tempted to include the whole vendor
in the paths
to analyze. However, this would slow the execution down, and it would also analyze the whole vendor
and the dependencies within it.
Instead, if we specify the libraries that we allow in our infrastructure layer, we not only simplify and accelerate the analysis, but we also prevent transitive dependencies, i.e., depending on our code on third-party libraries we did not explicitly allow.
Therefore, we define another, more general layer, named Vendor
, where we define third-party libraries we allow in our infrastructure layer. In this case, we use a collector
by namespace.
We can now define the rulesets between layers:
-
Domain
: it can useDomainVendor
, as we explained. -
Application
: it can accessDomain
and its allowed layers, i.e.,DomainVendor
. -
Infrastructure
it can accessDomain
,Application
andVendor
.
Execution
We can now execute Deptrac:
deptrac analyse --config-file=hexagonal-layers.depfile.yaml --cache-file=.deptrac.hexagonal-layers.cache --report-uncovered --fail-on-uncovered
By default, Deptrac uses the configuration file deptrac.yaml
, and the cache file .deptrac.cache
. However, we pass both arguments when executing it to avoid conflicts in case we have more Deptrac configurations.
We also specify the --report-uncovered
option, so it fails if there is any uncovered dependency, i.e., dependencies that exist and we did not define. Thus, the validation is stricter.
The output is:
-------------------- -----
Report
-------------------- -----
Violations 0
Skipped violations 0
Uncovered 0
Allowed 222
Warnings 0
Errors 0
-------------------- -----
Deptrac offers several formatters that we will not cover in this post.
Simplified configuration
We saw that we had to specify all the dependencies for all layers. For example, for the Application
layer, we needed to specify both dependencies Domain
and DomainVendor
as allowed. It is a bit redundant, as we would like to allow access from the Application
to the Domain
layer, and every other layer the latter depends on.
Fortunately, Deptrac offers this functionality: it consists of prepending +
to the dependencies. Thus, we could rewrite our configuration file as follows:
deptrac:
paths:
- ./src
layers:
# Layer definition
- name: Domain
collectors:
- type: directory
regex: src/Domain
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Application</span>
<span class="na">collectors</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">directory</span>
<span class="na">regex</span><span class="pi">:</span> <span class="s">src/Application</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Infrastructure</span>
<span class="na">collectors</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">directory</span>
<span class="na">regex</span><span class="pi">:</span> <span class="s">src/Infrastructure</span>
<span class="c1"># Vendor</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">DomainVendor</span>
<span class="na">collectors</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">className</span>
<span class="na">regex</span><span class="pi">:</span> <span class="s">^(Brick\\Math|Brick\\Money|Doctrine\\Common\\Collections|Ramsey\\Uuid)\\.*</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Vendor</span>
<span class="na">collectors</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">className</span>
<span class="na">regex</span><span class="pi">:</span> <span class="s">^(Symfony|CuyZ\\Valinor|League\\ConstructFinder|League\\Tactician)\\.*</span>
<span class="na">ruleset</span><span class="pi">:</span>
<span class="na">Domain</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">DomainVendor</span>
<span class="na">Application</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">+Domain</span>
<span class="na">Infrastructure</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s">+Application</span>
<span class="pi">-</span> <span class="s">Vendor</span>
Conclusions
With Deptrac set up, everyone who collaborates on our project will follow the architecture we defined. To enforce it, the best way is to integrate Deptrac into our continuous integration pipeline.
The configuration of layers we saw is applicable to the use of Bounded Contexts within the same project to prevent contexts from accessing other contexts, which is not allowed when using this pattern.
Summary
- We saw a possible definition of layers for applying hexagonal architecture.
- We explained that PHP does not offer any mechanism to enforce any architecture.
- We listed utilities to validate the architecture of our application, with Deptrac among them.
- We briefly explained the concepts that Deptrac uses internally.
- We saw a possible Deptrac configuration to enforce hexagonal architecture.
- We executed Deptrac in strict mode to ensure that all our code was analyzed.
- Finally, we saw a way to simplify the configuration file.
Posted on October 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.