You wrote the docs. You did the PR reviews. You explained it during onboarding. And yet, six months later, a bunch of controllers are directly importing a repository, and nobody noticed.
The Real Problem With Architecture
Clean Architecture, Hexagonal Architecture, layered architecture, or whatever approach you follow: the rules often exist in only two places: your head and a README nobody reads.
The moment a deadline hits, someone takes a shortcut. A controller calls a repository directly. A domain model imports a NestJS decorator. An infrastructure class leaks into the application layer.
It is not malicious. It is invisible.
Nobody gets a red light. The tests still pass. The application still ships.
Six months later, you have a codebase that looks structured but behaves like a mess.
The fix is not more pull request comments or awareness. It is making the rules enforceable.
Architecture Tests: Rules That Enforce Themselves
Architecture tests are automated tests that do not test your business logic. They test the structure of your application.
They answer questions such as:
- Does anything in the domain layer import from infrastructure?
- Is any controller talking directly to a repository?
- Do all use cases follow the naming convention we agreed on?
- Does the project contain circular dependencies?
If the answer is ever yes, the build fails. No human intervention is required.
We are going to use ArchUnitTS, an actively maintained architecture-testing library for TypeScript inspired by ArchUnit from the Java ecosystem.
npm install archunit --save-dev
Setting the Rules
Imagine you are running a NestJS application with this layered structure:
src/
domain/ # Entities, value objects, domain errors
application/ # Use cases, service interfaces
infrastructure/ # Repositories, ORM, external services
http/ # Controllers, DTOs, HTTP concerns
Here are the architectural rules you might want to enforce.
The Domain Must Not Depend on Application or Infrastructure
import { projectFiles } from 'archunit';
it('domain should not depend on application or infrastructure', async () => {
const rule = projectFiles()
.inFolder('src/domain')
.shouldNot()
.dependOnFiles()
.inFolder('src/application')
.or()
.inFolder('src/infrastructure');
await expect(rule).toPassAsync();
});
Controllers Must Not Import Repositories Directly
it('controllers should not import repositories directly', async () => {
const rule = projectFiles()
.inFolder('src/http')
.shouldNot()
.dependOnFiles()
.inFolder('src/infrastructure/repositories');
await expect(rule).toPassAsync();
});
Application Must Not Depend on Infrastructure
it('application should not depend on infrastructure', async () => {
const rule = projectFiles()
.inFolder('src/application')
.shouldNot()
.dependOnFiles()
.inFolder('src/infrastructure');
await expect(rule).toPassAsync();
});
Enforcing Naming Conventions
Beyond dependency direction, you can enforce the conventions your team agreed on: the ones currently living in a Notion document nobody opens.
All Use Cases Must End With UseCase
it('use cases must follow the naming convention', async () => {
const rule = projectFiles()
.inFolder('src/application/use-cases')
.should()
.matchPattern('*UseCase.ts');
await expect(rule).toPassAsync();
});
Domain Interfaces Must Be Prefixed With I
it('domain interfaces should be prefixed with I', async () => {
const rule = projectFiles()
.inFolder('src/domain')
.that()
.areInterfaces()
.should()
.haveNameMatching(/^I[A-Z]/);
await expect(rule).toPassAsync();
});
Infrastructure Interfaces Must Be Prefixed With I
it('infrastructure interfaces should be prefixed with I', async () => {
const rule = projectFiles()
.inFolder('src/infrastructure')
.that()
.areInterfaces()
.should()
.haveNameMatching(/^I[A-Z]/);
await expect(rule).toPassAsync();
});
The Project Must Not Contain Circular Dependencies
it('should have no circular dependencies', async () => {
const rule = projectFiles()
.inFolder('src/**')
.should()
.haveNoCycles();
await expect(rule).toPassAsync();
});
The Silent Killer Other Libraries Miss
Here is something worth knowing before selecting an architecture-testing library: a passing test that inspected zero files is worse than no test at all.
Imagine you write this rule:
const rule = projectFiles()
.inFolder('src/doamin') // Typo: this should be "domain"
.shouldNot()
.dependOnFiles()
.inFolder('src/infrastructure');
Many libraries will match zero files, perform zero checks, and report that the test passed.
Your architectural rule silently does nothing forever.
ArchUnitTS fails empty tests by default. The test will not pass until the pattern matches actual files.
It seems like a small detail, but this is exactly the kind of false confidence that causes real incidents.
Why This Changes Everything
The usual ways of enforcing architecture are expensive:
- Pair programming is valuable, but it does not scale to every change.
- Pull request reviews catch violations late and introduce friction.
- Documentation gets ignored when deadlines approach.
- Verbal agreements are forgotten by the next sprint.
Architecture tests reverse the model.
You write the rule once, and it runs on every push. A developer cannot accidentally violate a dependency rule without the CI pipeline immediately identifying the offending file.
This also makes onboarding easier.
Instead of explaining, “We do not import repositories in controllers here,” you can let the test demonstrate the rule. The tests become living documentation that remains synchronized with the codebase.
The Takeaway
Your architecture is only as strong as your ability to enforce it.
Documentation decays, memory fades, and deadline pressure is real. Architecture tests turn structural rules into executable constraints that run on every push.
Write these tests when you establish an architectural rule, include them in your CI pipeline, and stop discovering structural violations during pull request reviews months later.
How does your team enforce architectural boundaries: automated tests, lint rules, or code reviews?
Originally published on ARG Software.

Top comments (0)