Someone on your team just imported pg into src/domain/invoice.ts. The PR compiles. The tests pass. The code review is three hundred lines long, and nobody notices.

Three months later, you want to extract the domain logic into a shared package. You can’t. It depends on Postgres types, connection pooling logic, and a custom driver wrapper that only exists in the monolith. The whiteboard diagram with concentric circles is now a joke.

This is the standard decay pattern of clean architecture without automated enforcement. Architects draw arrows pointing inward. Developers write imports pointing outward. The compiler doesn’t care about your layers.

What “domain doesn’t import infrastructure” actually means

In layered architecture, dependencies flow inward. The domain layer, at the center, defines entities, business rules, and use cases. It has no knowledge of HTTP, SQL, queues, or file systems.

Infrastructure lives at the outer ring. It implements interfaces that the domain defines. The repository interface lives in domain. The Postgres implementation lives in infrastructure. The domain calls save(invoice). It never calls pool.query().

When domain code imports infrastructure directly, two things happen. First, the domain becomes untestable without the database running. Second, you can never swap Postgres for DynamoDB without rewriting business logic. The whole point of the layering, testability and swapability, evaporates.

Why code review is not enough

Every team has a senior engineer who catches bad imports in review. That engineer is also on vacation, sick, or reviewing a fifty-file PR at 5 PM on a Friday.

Humans are pattern matchers with limited RAM. Automated dependency rules are deterministic validators with infinite patience. The right place to enforce architectural boundaries is the same place you enforce syntax errors: the build pipeline. If it compiles but violates the dependency graph, it should fail.

How dependency-cruiser enforces layer boundaries

For TypeScript and JavaScript codebases, dependency-cruiser turns your architecture diagram into a testable rule set. It parses your import graph and fails the build when a forbidden edge appears.

Install it:

npm install --save-dev dependency-cruiser
npx depcruise --init

The init script generates a .dependency-cruiser.js config. You add a rule that forbids domain from touching infrastructure:

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: "domain-cannot-import-infrastructure",
      comment:
        "Domain code must not depend on infrastructure. Move the import to an adapter or repository.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        path: "^src/infrastructure",
      },
    },
    {
      name: "domain-cannot-import-node-modules",
      comment:
        "Domain code must not depend on external I/O libraries.",
      severity: "error",
      from: {
        path: "^src/domain",
      },
      to: {
        dependencyTypes: ["npm"],
        // Allow only pure logic libraries like date-fns or lodash
        pathNot: "^(date-fns|lodash|ramda)",
      },
    },
  ],
  options: {
    doNotFollow: {
      path: "node_modules",
    },
    tsPreCompilationDeps: true,
    tsConfig: {
      fileName: "tsconfig.json",
    },
  },
};

This rule says: any file under src/domain that imports anything under src/infrastructure breaks the build. The second rule catches the pg or axios import that lands via node_modules.

Wire it into your CI:

# .github/workflows/ci.yml
jobs:
  architecture:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx depcruise src

Now the pg import in src/domain/invoice.ts fails before a human even opens the PR.

What the failure looks like

When a developer breaks the rule, they get output like this:

error domain-cannot-import-infrastructure: src/domain/invoice.ts → src/infrastructure/database/pool.ts
  Domain code must not depend on infrastructure. Move the import to an adapter or repository.

error domain-cannot-import-node-modules: src/domain/invoice.ts → pg
  Domain code must not depend on external I/O libraries.

✖ 2 dependency violations (2 errors, 0 warnings). Showing only the first 2.

The error message tells them what to do. Move the database call to a repository in the infrastructure layer. Pass the repository into the use case as an interface. The domain code stays pure.

Alternatives for other ecosystems

Not everyone uses TypeScript. The principle is the same everywhere. Only the tooling changes.

Java: ArchUnit is the gold standard. You write a test, not a config file:

@ArchTest
static final ArchRule domain_should_not_access_infrastructure =
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..infrastructure..");

This runs as a JUnit test, so it integrates with Maven and Gradle without any new CI plumbing.

C#: NetArchTest provides the same API style for .NET. Write a unit test, run it in CI.

Go: There is no mature equivalent. Most teams enforce this with a simple shell script:

#!/bin/bash
# scripts/check-domain-imports.sh

if grep -r "infrastructure/" internal/domain/ ; then
  echo "Domain code imports infrastructure. Fix the dependency direction."
  exit 1
fi

It is crude, but it works. Many Go teams also use custom go vet analyzers or the go/ast package to build proper checks.

Build systems: Bazel, Gradle, and Nx all support module dependency graphs natively. If you define your domain and infrastructure as separate modules with explicit dependency declarations, the build tool enforces the boundary for free. The catch is that you must buy into the build system first.

Trade-offs: false positives, migration pain, and team friction

Automated rules are not free. You should know the costs before you turn them on.

False positives happen when you have legitimate shared utilities that sit between layers. A string slugifier or a custom error base class might live in src/shared. If both domain and infrastructure import it, the rule is fine. If you put it in src/infrastructure/utils and domain imports it, dependency-cruiser will complain. The fix is usually moving the shared code, which is often the right move anyway.

Migration pain is real. If your codebase already violates the rule in thirty places, you cannot flip the switch without a heroic refactoring. The pragmatic approach is to start with warnings, fix the existing violations over a sprint, then promote to errors. Alternatively, use pathNot exceptions for known legacy files and forbid new violations from day one.

Team friction is the hidden cost. Developers who have never worked with strict layer enforcement will push back. They will argue that one small import is harmless, that the rule is bureaucratic, that it slows them down. This is a cultural signal, not a technical one. The same developers will also be the ones who import express into a domain model at 11 PM to ship a feature. The rule exists because humans under pressure make shortcuts.

How to implement this in an existing codebase

You do not need a big rewrite. You need a boundary and a way to measure it.

  1. Draw the layers. Decide which directories are domain, application, and infrastructure. Write it down in ARCHITECTURE.md.

  2. Add dependency-cruiser (or your ecosystem’s equivalent) with one rule: domain cannot import infrastructure.

  3. Run it. Count the violations. If the number is small, fix them before merging the config. If it is large, add the legacy paths to pathNot exceptions.

  4. Make it fail CI. Not optional. Not a warning. An error that blocks merge.

  5. Every time a developer hits the error, help them move the import. Do not just tell them the build is red. Explain where the code should live and why.

Within two weeks, the team will stop trying to import pg into domain files. The architecture diagram on the whiteboard will finally match the code in the repository.

FAQ

Should application layer be allowed to import infrastructure?

Usually, yes. The application layer orchestrates use cases. It can know about repositories and external services, but it should depend on interfaces, not concrete implementations. If you want strict enforcement, add a second rule: application cannot import infrastructure implementations, only their interfaces.

What about domain events?

Domain events are a common loophole. A domain event is pure data, so it belongs in the domain layer. The infrastructure event bus that publishes it belongs in infrastructure. The application layer subscribes domain event handlers to the bus. The domain itself never knows the bus exists.

Can I use this with Nx or Turborepo?

Yes. Nx has built-in module boundary rules that work at the project level. If your domain and infrastructure are separate Nx libraries, you can enforce the rule without any extra tools. The same applies to Bazel and Gradle.

What if I need a utility from infrastructure?

You don’t. Move the utility to a shared common or kernel package that has no infrastructure dependencies. If it truly needs infrastructure, it is not a utility. It is infrastructure.

Bottom line

Clean architecture without automated enforcement is a gentleman’s agreement. Gentleman’s agreements do not survive production deadlines.

Dependency-cruiser, ArchUnit, or even a shell script with grep turns your architecture from a suggestion into a guarantee. The domain stays pure. The infrastructure stays pluggable. And the next time someone tries to import pg into a business rule, the build fails before the PR ever reaches a human reviewer.

Start with one rule. Make it red in CI. Fix the first violation. The rest will follow.