How to Actually Enforce Clean Architecture in TypeScript

typescript dev.to

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.

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


Originally published on ARG Software.

Source: dev.to

arrow_back Back to Tutorials