Enforcing Architectural Constraints
Enforcing architectural constraints within codebases can be achieved easily with static analysis tooling.
This article will demonstrate how to implement a set of architectural constraints in a Python codebase.
Table of Contents
Introduction
Let’s take a look at a very simple codebase:
module_a/
api/
domain/
data/
module_b/
module_a_interface
tests/
In this codebase, we have 2 discrete modules along with a directory for tests.
Framing rules
The first thing for us to do here is to spell out some rules to ourselves in understandable terms.
These rules should describe a variety of conditions which we want to ensure our codebase to abide by. We can later transcribe these rules to our static analysis tooling.
This will allow us to execute these rules as part of our code quality checks. In essence, we can then treat them as we would our automated test suite.
Rule 1: Layered horizontal architecture
We can see that module_a
takes a layered architectural approach within itself.
module_a
has 3 main horizontal layers, from top to bottom:
- api/
- domain/
- data/
In layered architectures, we would typically want to enforce layers of isolation whilst also constricting dependencies.
For example here we would want to ensure that any code within the data
layer does not depend on the api
or domain
layers.
In other words, we are permitted to reach downwards but not upwards.
So our api
layer can pull and import code from the layers below it i.e. domain
and data
,
but not the other way around. The data
layer should not know anything about the domain
and api
layers above it.
Rule 2: Discrete modules
In keeping with a modular monolithic approach.
We would also determine that we want to keep module_a
and module_b
separate from each other.
The only exception to this is a dedicated interface file which we use solely for explicit cross-module communication
between module_b
to module_a
.
Note that this interface is the only point at which we want to permit boundaries between the 2 modules to be crossed.
Rule 3: Independent tests
The rules that we enforce don’t necessarily have to be of the more high-level architectural type. We can also enforce rules that might not be immediately obvious to engineers with less experience.
We have the ability to enforce rules like this for a low cost. This can also remove this burden from any code review process. In other words, we don’t need to rely on a reviewer commenting on a Pull Request describing this rule. Instead, we can rely on automated tooling to take care of that for us.
For example, we want to ensure that our source code does not depend on our tests/
directory.
This might be useful in scenarios in which we may not want to package our tests/
directory into deployments.
If we have source code which depends on test code, that could be bad news!
So we could describe a rule which says that module_a
and module_b
shall not depend on anything within the tests
directory.
Declaring our rules
For our Python codebase, we can use import linter, which allows us to define a set of contracts which will be applied to import statements within our codebase.
To start with, we will need to create an .importlinter
file at the root level of our project.
Within the top of this new file, we will need to declare the root packages for our import linter to handle:
[importlinter]
root_packages =
module_a
module_b
tests
Contract 1: Layered horizontal architecture
We know we want to ensure that our module_a
has a layered architecture within itself.
So let’s write this out in our specification:
[importlinter:contract:Module A has a layered architecture]
name=Module A enforces a clear layered architecture within itself.
type=layers
layers=
module_a.api
module_a.domain
module_a.data
Here we are describing a contract of the type layer
.
Immediately below that we describe a list of packages which represent the layers.
Note here that the layers are arranged from top to bottom in the order in which the horizontal layers are applied.
Running the following command:
lint-imports
Gives us the following:
=============
Import Linter
=============
---------
Contracts
---------
Analyzed 7 files, 6 dependencies.
---------------------------------
Module A enforces a clear layered architecture within itself. KEPT
Contracts: 1 kept, 0 broken.
So far so good. We’ve verified that our first contract is in place and is behaving as it should.
Contract 2: Discrete modules
We also now that module_a
and module_b
should not depend on each other.
[importlinter:contract:Independent modules]
name=Modules A & B are independent of each other, with the exception of designated interfaces
type=independence
modules=
module_a
module_b
Running the command to execute our contracts:
lint-imports
Gives us the following output:
=============
Import Linter
=============
---------
Contracts
---------
Analyzed 7 files, 6 dependencies.
---------------------------------
Module A enforces a clear layered architecture within itself. KEPT
Modules A & B are independent of each other, with the exception of designated interfaces BROKEN
Contracts: 1 kept, 1 broken.
----------------
Broken contracts
----------------
Modules A & B are independent of each other, with the exception of designated interfaces
----------------------------------------------------------------------------------------
module_b is not allowed to import module_a:
- module_b.module_a_interface -> module_a.api (l.1)
So we can see that our 2nd contract is being enforced. But we need to allow for an exception for our designated interface:
[importlinter:contract:Independent modules]
name=Modules A & B are independent of each other, with the exception of designated interfaces
type=independence
modules=
module_a
module_b
ignore_imports=
module_b.module_a_interface -> module_a.api
Executing our contracts again:
lint-imports
Will result in the following:
=============
Import Linter
=============
---------
Contracts
---------
Analyzed 7 files, 6 dependencies.
---------------------------------
Module A enforces a clear layered architecture within itself. KEPT
Modules A & B are independent of each other, with the exception of designated interfaces KEPT
Contracts: 2 kept, 0 broken.
So we now have 2 of our contracts in place. And we have allowed for our designated interface.
Contract 3: Independent tests
The final rule we wanted to apply was to ensure that our source code was not dependent on any test code.
[importlinter:contract:Source code does not depend on tests]
name = Source code does not depend on any test code
type = forbidden
source_modules=
module_a
module_b
forbidden_modules=
tests
Running our contracts one last time:
lint-imports
Will give us this:
=============
Import Linter
=============
---------
Contracts
---------
Analyzed 7 files, 6 dependencies.
---------------------------------
Module A enforces a clear layered architecture within itself. KEPT
Modules A & B are independent of each other, with the exception of designated interfaces KEPT
Source code does not depend on any test code KEPT
Contracts: 3 kept, 0 broken.
Summary
So long as we can articulate our desired architectural constraints and rules to ourselves,
then we can use tools like .importlinter
to enforce these rules for us.
Hooking them up to our CI pipeline can ensure that the rules are applied across the board for everyone too. We can then continuously apply a set of contracts as architectural fitness functions in an inexpensive way.
You can pull the example code which contains the .importlinter
specification used for this article here.
Related articles: