Your test suite verifies that calculateTotal returns 42 when given the right input. It does not verify that src/domain/Invoice.ts is allowed to import src/infrastructure/Database.ts. The compiler is happy with both. Your unit tests are happy with both. But one of them is an architectural violation that will cost you a week of refactoring six months from now.

This is the blind spot. We write tests for logic and assume structure takes care of itself. It doesn’t.

What an architecture test actually is

An architecture test is an assertion about the structure of your code, not its behavior. It checks that the dependency graph matches the design you agreed on. It fails the build when a developer imports the wrong layer, creates a circular dependency, or names a repository class without the suffix you standardized on.

These are not lint rules. Linting catches style violations. Architecture tests catch structural violations. The difference matters because structure has semantics. A module that imports its parent is not a style problem. It is a design problem.

Most teams document these rules in a wiki, a README, or a Slack message pinned by the tech lead. Documentation is useful for onboarding. It is useless for enforcement. An architecture test moves the rule from human memory into the build pipeline, where forgetting it costs a red build instead of a production incident.

What you can enforce with a test

The obvious use case is dependency direction. Domain should not import infrastructure. UI should not import data access directly. These rules map cleanly to package or directory boundaries.

But architecture tests can check more than imports. Here are patterns that actually matter in production codebases.

Cyclic dependencies. A package that imports itself through a chain of three other packages is still cyclic. Your eyes won’t catch it in a code review. A test that traverses the import graph will.

Naming conventions. If your team decided that every repository implementation must end in Repository, a test can enforce that. It sounds pedantic until someone creates UserDao and UserRepo in the same codebase and new engineers can’t tell which one to use.

Forbidden dependencies on specific libraries. Maybe your domain layer is not allowed to depend on axios, pg, or fs. Maybe your frontend is not allowed to import lodash because you are standardizing on native methods. An architecture test can assert that a specific package never appears in a specific module’s dependency tree.

Annotation and inheritance rules. In Java, you can test that no class in ..domain.. uses @Autowired. In C#, you can test that no class in Infrastructure implements an interface from Application. These are structural constraints that static analysis alone cannot express without domain knowledge.

How to write one

The best architecture tests look like ordinary unit tests. They run in your existing test runner. They appear in the same CI job as your other tests. The only difference is what they assert on.

Java with ArchUnit:

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.mycompany")
class ArchitectureTest {

    @ArchTest
    static final ArchRule no_cycles =
        slices()
            .matching("com.mycompany.(*)..")
            .should().beFreeOfCycles();

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

This is a JUnit test. It runs with mvn test. It fails the build. No special CI job required.

C# with NetArchTest:

using NetArchTest.Rules;
using Xunit;

public class ArchitectureTests
{
    [Fact]
    public void Domain_Should_Not_Depend_On_Infrastructure()
    {
        var result = Types.InCurrentDomain()
            .That()
            .ResideInNamespace("MyApp.Domain")
            .ShouldNot()
            .DependOnAny(Types.InNamespace("MyApp.Infrastructure"))
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

Python with import-linter:

Python does not have a mature ArchUnit equivalent, but import-linter gives you a declarative config that functions like a test:

# .importlinter
[importlinter:contract:domain-independent]
name = Domain does not import infrastructure
type = forbidden
source_modules =
    myapp.domain
forbidden_modules =
    myapp.infrastructure

Run it with lint-imports in CI. It exits nonzero on violation.

Go: write your own

Go has no mainstream architecture testing library. Most teams write a small test that walks the AST:

import (
    "strings"
    "testing"

    "golang.org/x/tools/go/packages"
)

func TestDomainDoesNotImportInfrastructure(t *testing.T) {
    cfg := &packages.Config{
        Mode: packages.NeedImports | packages.NeedFiles,
    }
    pkgs, err := packages.Load(cfg, "./domain/...")
    if err != nil {
        t.Fatal(err)
    }

    for _, pkg := range pkgs {
        for path := range pkg.Imports {
            if strings.Contains(path, "/infrastructure/") {
                t.Errorf("domain package %s imports infrastructure: %s", pkg.PkgPath, path)
            }
        }
    }
}

It is twenty lines. It lives in your test suite. It runs on every PR. That is the point. You do not need a framework. You need an assertion.

The trade-offs nobody talks about

Architecture tests are not free. They introduce a new category of build failure, and new categories of build failure always create friction.

They are slower than unit tests. An architecture test that scans every import in a 500,000-line codebase takes time. Not hours. Seconds, sometimes tens of seconds. But that is an order of magnitude slower than a typical unit test. If you run them in the same job as your fast unit tests, you lose the feedback loop that makes unit testing valuable.

The pragmatic split is to run architecture tests in a dedicated CI job, or tag them as integration tests and run them after the fast suite passes. The rule still blocks merge. It just does not slow down your local npm test equivalent.

False positives happen when the rule is too broad. If you forbid all imports from node_modules in your domain layer, you will break legitimate uses of date-fns or zod. Rules need exceptions, and exceptions need maintenance. A rule with thirty pathNot entries is not enforcing architecture. It is encoding your current mess.

They can give false confidence. Passing an architecture test does not mean your design is good. It means your design matches the rules you wrote. If the rules are wrong, the tests are just automated cargo culting.

How to add them without breaking CI

Do not start with ten rules. Start with one. Pick the dependency direction that has caused you the most pain. Maybe it is domain importing infrastructure. Maybe it is your frontend importing backend code directly.

Write the test. Run it locally. Count the failures. If the count is zero, you are either very disciplined or the rule is not catching what you think it is. Verify with a deliberate violation.

If the count is fifty, you have a choice. Fix all fifty in one heroic PR, or add exceptions for existing violations and forbid new ones. The second option is less satisfying and more sustainable.

Make the test fail CI. Not warn. Fail. A warning is a rule that engineers learn to ignore.

FAQ

Do architecture tests replace code review?

No. They automate the parts of code review that humans are bad at, like spotting transitive imports across twenty files. Humans are still better at judging whether a new dependency makes sense.

What about microservices?

Architecture tests work best within a single deployable unit. Across services, you enforce boundaries with API contracts and deployment isolation, not import graphs.

Should I test naming conventions?

Only if inconsistency causes real confusion. A test that enforces Repository suffixes is useful in a ten-developer team. It is probably noise in a solo project.

Can I use this with monorepos?

Yes. Nx, Bazel, and Turborepo all have module boundary enforcement. If you are already using one, use its built-in rules. They run faster and integrate with the dependency graph. If you are not, a standalone architecture test is the lightweight entry point.

Start with one rule

Your codebase already has implicit architecture rules. They live in the head of your senior engineer. They are enforced in code review when that engineer is not on vacation. They are violated at 11 PM before a deadline.

Write one of them down as a test. Make it red. Fix the violations. Make it block CI.

The next time someone asks, “Are we allowed to import infrastructure from the domain layer?” the answer will not be in a wiki. It will be in a failing build.