Architecture validation as an integral part of the software development process
How can care for architecture become a natural component of software development? Do we pay the same attention to maintaining the architecture as to the quality of our code? It is worth asking yourself how the automation of this process can support us in our daily development. In this article we will answer these questions and present the tools available for the Node.js environment.
Need for modularization
The design and implementation of modular systems has been a standard in our industry for a long time. The reusability and independence of components as well as the ease of testing and maintaining such applications mean that more and more programmers pay attention to proper decomposition and modularization. What if our software grows and the frequency of changes as well as the number of new functionalities increases exponentially? How can we facilitate the introduction of new developers to the team and at the same time protect ourselves against architecture erosion in the short and long term?
Modularity can be defined as the decomposition of the entire system into smaller components, each of which defines the public interface (rules on which we can interact with a given module). A good module is characterized by several features — it is hermetic, independent of others (to some extent), and thanks to the defined abstraction, we are able to replace its implementation. We compose modules with each other, coordinating the work between them, thus creating higher-level modules from which we build entire systems. The modules resulting from vertical slicing are high-level components and make up the overall shape of the system. This division results from the separation of such parts of the system that should function independently. In turn, each of these modules consists of horizontal layers, and there is a clearly defined direction of dependence between them.
Common errors in implementation
The most common errors in the implementation of a modular system are:
- Breaking the principles of hermetization and encapsulation (referring to the internal part of the module directly, omitting the defined abstraction),
- Circular dependency — cyclical dependency of modules,
- Reference to a component that is not part of our domain module (e.g. crossing the boundaries between two bounded contexts in Domain-Driven Design),
Pic 1. Example architecture
At the same time, it is worth asking yourself what is the reason for the need to break architectural rules — the most common one is high coupling which can be eliminated by selecting the appropriate type of cohesion (functional, informational, etc.), and remodeling the component boundaries. So, how can we validate the whole in everyday development, having already developed the framework of our system?
Modularization in Node.js
The difficulty is that applications written in Node.js create a set of files with “public” access to types and classes — we can refer to another file and the types defined in it from anywhere in the code. Platforms such as .NET or Java provide encapsulation at the component level (dll / jar) thanks to the internal / private access level. This way, each type defined as public can be considered an API of our module. Node.js provides two options to choose from — the matter of trusting ourselves and believing in our team (it comes down to “manual” validation of the architecture, e.g. during code review), or the use of static code analysis tools.
Dependency cruiser
One of the libraries used in our company is dependency-cruiser. The tool plays a double role — firstly, it helps us define the rules on which dependencies operate in our system, and secondly — it enables us to visualize them. From a technical point of view, this tool scans the imports in our system files, and then compares them with our defined or predefined rules.
The tool itself can be installed globally or as dev-dependency for a given project:
npm install --save-dev dependency-cruiser
Dependency-cruiser has a CLI through which we create a configuration file (.dependency-cruiser.js):
depcruise --init
The last step is to add the custom command to our package.json file (where src is our source code folder):
"depcruise:check": "npx dependency-cruiser src --config .dependency-cruiser.js",
Let’s create an example rule saying that the domain layer should not be dependent on the application layer. The configuration of such a rule is defined in the .dependency-cruiser.js file and looks like this:
Listing 1. Rule configuration for dependency-cruiser
{
name: 'not-from-application-to-domain-layer',
severity: 'error',
comment: 'Domain should not depend on application layer',
to: {
path: '^src/application',
},
from: {
path: '^src/domain',
},
},
Creating any dependency in the domain (importing any file from the application layer) and running dependency-cruiser will return us the following error, as the dependency direction has been broken:
> npx dependency-cruiser src --config .dependency-cruiser.jserror not-from-application-to-domain-layer:
src/domain/domain.module.ts
→ src/application/application.module.ts✖ 1 dependency violations (1 errors, 0 warnings).
2 modules, 20 dependencies cruised.
In addition to controlling the direction of dependencies, the tool protects us against breaking other rules:
- Referencing dev-dependencies or dependencies not defined in package.json.
- Dependence of Source Code on Tests.
In addition to controlling dependencies, dependency-cruiser has the ability to generate graphs based on them. While in the case of larger systems, the graph becomes unreadable and its analysis will not bring us any effects, generating a graph for a part of the system makes sense — it allows us to visualize only a part of the whole and analyze it.
Pic 2. Exemplary project graph
Or maybe unit tests?
Another useful tool is TSArch. It is a solution that allows you to write unit tests for your architecture. The concept of such tests is also known in other technologies (ArchUnit for Java or ArchUnitNet for .NET). The undoubted advantage of this library is the fact that it integrates with most popular testing frameworks — this way we ensure a low entry threshold, simple syntax, understandable for every developer, and easy integration with Continuous Integration solutions.
An example of a test for a rule similar to the one presented earlier for dependency-cruiser (using Jest.js as a test bootstraper):
Listing 2. TSArch unit test
describe("Architecture dependency unit test", ()=> {
it("domain layer should not depend
on application layer",
async ()=> {
const rule = filesOfProject()
.inFolder("domain")
.shouldNot()
.dependOnFiles()
.inFolder("application") await expect(rule).toPassAsync()
})
})
Conclusion
The constant evolution of systems means that the initial architectural assumptions often disappear in the rush of everyday work. Modular software, which was supposed to bring benefits, turns out to be another big ball of mud — due to inattention and the lack of systematized solutions. So, it’s worth implementing tests and architecture validation as part of our work culture and Continuous Integration process. Once written tests give developers immediate feedback and allow them to maintain software modularity.