DEV Community

Cover image for How to Actually Enforce Clean Architecture in TypeScript
Arg Software
Arg Software

Posted on Originally published at arg.software

How to Actually Enforce Clean Architecture in TypeScript

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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.

Two builders constructing a visibly misaligned brick wall with the caption: “Just keep coding. We can always fix it later.”

How does your team enforce architectural boundaries: automated tests, lint rules, or code reviews?


Originally published on ARG Software.

Top comments (2)

Collapse
 
eventuallyconsistentcode profile image
John Reed •

Your "silent killer" section is the part I'd put at the top, because it's the one nobody arrives at until it bites them…

I build cairn, a work harness for coding agents, and the TypeScript server under it has had a hand-rolled version of this since September: tracker adapters are leaves, the tracker subsystem imports none of the subsystems that read it, the docs connectors may reuse four named tracker modules and nothing else, index.ts is the composition root nobody imports back, and the whole tree has no cycles. About 170 lines of vitest walking the import specifiers, no library. The header comment on that file says almost word for word what your section says, a rule that inspected zero files and passed is worse than no rule, so every rule goes through one filesMatching helper that refuses to run on an empty set. Nice to see ArchUnitTS make that the default instead of a thing you have to remember to write.

One thing we do that I'd suggest adding to the article, since it's the same disease one step later: the allowlist entries have to earn their keep too. The docs-may-reuse-these-four-modules list has a second test that fails when any entry is no longer imported by anything, so an exception that stopped being needed gets deleted instead of sitting there as a hole for the next person. The empty pattern is a rule that guards nothing, the dead exception is a rule that permits something nobody asked for, and both read as coverage forever.

And the honest part, because reading your piece made me go check: the plugin layer around that server has one layering rule of its own, the hook scripts import no server code so the server never becomes a dependency of the thing that guards it, and that rule lived in exactly the place you warn about, a paragraph in ARCHITECTURE.md. True today, asserted nowhere. Filed the test this afternoon. So, one article, one gap found, which is about the rate I'd hope for…

The one place I'd push back a little is "stop relying on PR reviews." The import rules are the cheap tenth of architecture, the expensive nine tenths is whether the thing in the application folder is actually a use case or a controller with a different suffix, and no file-pattern test sees that. The test earns its place by making the review not have to look at imports at all, so the human spends their attention where the pattern can't…

Collapse
 
argsoftware profile image
Arg Software •

John, this is such a great addition, especially the point about stale allowlist entries. I focused on rules that silently check nothing, but exceptions that remain after they're no longer needed are just as dangerous. That's definitely worth covering.

And I entirely agree with your pushback on PR reviews. The goal isn't to replace human judgment, but to automate the mechanical checks so reviewers can focus on the things that actually require architectural reasoning. A file named CreateUserUseCase.ts doesn't magically make it a proper use case 😄

Also, I love that reading the article helped you spot a missing test in your own project. That's precisely the kind of outcome I hoped for when writing it. Thanks for sharing such a thoughtful real-world example!