Afaan Ashiq

Software Engineering

Enforcing Architectural Constraints

September 23, 2023 6 min read

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: