Skip to main content

Command Palette

Search for a command to run...

Guarding Software Architecture: From Intention to Enforcement

The Silent Decay of Software Architecture

Updated
11 min read
Guarding Software Architecture: From Intention to Enforcement
Z

As a Senior Software Engineering Consultant, I believe in mentoring and coaching others to help them reach their full potential and achieve their professional goals. I am dedicated to inspiring a culture of excellence, continuous learning, and collaboration, ensuring the delivery of high-quality software solutions. Throughout my career, I have collaborated with diverse teams to deliver successful software projects utilizing Agile methodologies. I have also led innovation initiatives to improve processes, tools, and technologies, driving efficiency and productivity. My experience and expertise have allowed me to develop a strong foundation in software engineering, project management, and innovation management.

Every software project begins with a vision. Architects draw diagrams. Teams agree on boundaries. Layers are defined, dependencies are mapped, and everyone nods along in the kickoff meeting.

Then reality sets in.

Six months later, a service class imports a controller's DTO directly. A domain entity quietly acquires a JPA annotation. A "temporary" shortcut lets the persistence layer call an HTTP client. Nobody planned these violations. Nobody approved them. They simply happened , one pull request at a time, each individually harmless, collectively devastating.

This phenomenon has a name: architectural erosion. And it's not a matter of if, it's a matter of when.


Why Architecture Erodes

Understanding why architecture degrades is more important than understanding how to fix it. The root causes are deeply human:

1. Knowledge Asymmetry

The architect who designed the system understands why the domain layer must remain free of infrastructure concerns. The developer who joined three months ago does not. They see a convenient shortcut and take it , not out of malice, but out of ignorance. Documentation exists, perhaps, but it's outdated, scattered across wikis, or simply unread.

2. Pressure Over Principle

Deadlines don't care about layer boundaries. When a feature must ship by Friday, the cleanest path is rarely the fastest one. "We'll refactor it later" becomes the team's most frequently broken promise.

3. The Broken Window Effect

Once one violation exists, the next one is easier to justify. "Well, there's already a dependency from service to controller in OrderService, so it can't be that important." Violations breed violations. Erosion accelerates.

4. Review Fatigue

Code reviewers are expected to catch functional bugs, security issues, performance problems, style violations, and architectural drift , all in the same review. Architecture violations are often subtle, and reviewers are human. Things slip through.

5. Absence of Feedback Loops

Most teams have no mechanism to detect architectural violations until someone happens to notice them. There is no alarm, no red build, no failing test. The architecture simply drifts, silently, like continental plates.


The Gap Between Decision and Enforcement

Software engineering has matured significantly in how we handle code quality at various levels:

Level Decision Enforcement Mechanism
Formatting "Use 4-space indentation" Prettier, Checkstyle, EditorConfig
Syntax/Style "No unused variables" Linters, compiler warnings
Bugs "No null dereference" Static analysis (SpotBugs, SonarQube)
Behavior "This method should return X given Y" Unit tests, integration tests
Architecture "Domain must not depend on infrastructure" ???

Notice the gap. We have automated enforcement for formatting, style, common bugs, and behavior. But architecture , arguably the most expensive thing to get wrong , is left to human vigilance: code reviews, documentation, and tribal knowledge.

This gap is where architectural erosion lives.


What Would Effective Architecture Enforcement Look Like?

Before jumping to solutions, it's worth articulating what good enforcement looks like. It should be:

  • Automated , It shouldn't depend on a reviewer remembering the rule

  • Immediate , Violations should be caught at build time, not during a quarterly architecture review

  • Explicit , Rules should be readable by any developer, not locked in someone's head

  • Incremental , It should be adoptable in legacy codebases, not just greenfield projects

  • Living , Rules should evolve with the architecture and be versioned alongside the code

  • Low-friction , It shouldn't require new infrastructure, plugins, or workflow changes

The most natural fit for all of these criteria is something developers already understand and trust: tests.


Architecture as Tests

The idea is deceptively simple: express architectural rules as automated tests that run with every build, fail when violated, and live in version control alongside the code they protect.

This is the core premise behind ArchUnit , a Java library that enables exactly this kind of enforcement through standard JUnit tests. But before exploring how the tool works, it's worth stepping back and thinking carefully about what kinds of architectural rules are worth encoding in the first place.

Categories of Architectural Rules

Dependency direction rules are the most fundamental. In a layered or hexagonal architecture, dependencies must flow in a specific direction. The domain layer must not know about persistence. Controllers must not bypass services to reach repositories. These rules define the skeleton of the architecture itself.

Package structure rules govern where classes live. An @Entity has no business being in a controller package. A REST controller found in the domain layer is a signal that something has gone wrong , either in the design or in the implementation.

Naming conventions seem superficial but matter for navigability and comprehension. When every service class ends in Service and every repository interface ends in Repository, a developer new to the codebase can build a mental model quickly. Inconsistency creates friction and confusion.

Cycle prevention addresses one of the most structurally damaging problems in large codebases. Circular dependencies between packages or modules create tight coupling that makes independent reasoning, testing, and evolution of components practically impossible.

Technology isolation ensures that framework-specific types stay confined to the layers that own them. Domain models littered with @Column and @JsonProperty are domain models that can never escape their current technical context.

Access restrictions protect internal implementation details. Some packages represent contracts. Others represent internals. Only specific layers or modules should be permitted to cross those boundaries.


Encoding Architecture with ArchUnit

ArchUnit works by analyzing compiled Java bytecode and providing a fluent API to express structural constraints. Tests are standard JUnit tests , no special agents, no build plugins, no additional infrastructure.

Setting Up

A single test dependency is all that's needed:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>

Protecting Layer Boundaries

Consider a typical layered Spring application:

com.myapp
├── controller    (REST endpoints)
├── service       (business logic)
├── repository    (data access)
└── domain        (entities, value objects)

The intended dependency flow is: controller → service → repository → domain, with domain depending on nothing above it.

Here's how to express and enforce that:

@AnalyzeClasses(packages = "com.myapp")
class LayerDependencyTest {

    @ArchTest
    static final ArchRule layer_dependencies_are_respected =
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            .layer("Domain").definedBy("..domain..")
    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
            .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
            .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers(
                "Service", "Repository"
            );
}

If a developer adds an import from domain to repository, the build breaks. Not eventually. Not when a reviewer happens to notice. Immediately, on that developer's machine, before the code ever reaches a pull request.

Protecting Domain Purity in Hexagonal Architecture

For teams practicing hexagonal architecture or domain-driven design, keeping the domain free of technical concerns is non-negotiable. The domain should have zero awareness of frameworks, persistence technology, or transport mechanisms:

@ArchTest
static final ArchRule domain_should_not_depend_on_frameworks =
    noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage(
            "org.springframework..",
            "jakarta.persistence..",
            "com.fasterxml.jackson.."
        )
        .because("Domain must remain technology-agnostic");

ArchUnit also provides first-class support for the full onion architecture structure:

@ArchTest
static final ArchRule onion_architecture =
    onionArchitecture()
        .domainModels("..domain.model..")
        .domainServices("..domain.service..")
        .applicationServices("..application..")
        .adapter("web", "..adapter.web..")
        .adapter("persistence", "..adapter.persistence..")
        .adapter("messaging", "..adapter.messaging..");

This single declaration encodes the entire dependency flow of the architecture. Adapters may depend on application services and domain, but the reverse is never allowed. Adapters may not depend on each other. Domain models depend on nothing outside themselves.

Detecting Circular Dependencies

Cycles between packages are among the most structurally damaging problems a codebase can develop. They make it impossible to reason about, test, or evolve components independently:

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

Enforcing Coding Conventions

Beyond structural rules, architecture tests can also encode team conventions that static analyzers typically cannot express:

// Constructor injection only — field injection is banned
@ArchTest
static final ArchRule no_field_injection =
    noFields()
        .should().beAnnotatedWith(Autowired.class)
        .because("Constructor injection makes dependencies explicit and improves testability");

// All domain exceptions must share a common base
@ArchTest
static final ArchRule exceptions_should_extend_base =
    classes()
        .that().haveSimpleNameEndingWith("Exception")
        .and().resideInAPackage("..domain..")
        .should().beAssignableTo(DomainException.class);

// Controllers must not expose domain entities over the wire
@ArchTest
static final ArchRule controllers_should_return_dtos =
    methods()
        .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
        .and().arePublic()
        .should().haveRawReturnType(
            resideInAPackage("..dto..")
                .or(type(ResponseEntity.class))
                .or(type(void.class))
        )
        .because("Domain entities should not be exposed through the API contract");

Custom Rules for Specific Needs

When the fluent API doesn't cover a specific constraint, custom conditions can express almost anything:

ArchCondition<JavaClass> haveMatchingTestClass =
    new ArchCondition<>("have a corresponding test class") {
        @Override
        public void check(JavaClass javaClass, ConditionEvents events) {
            String testClassName = javaClass.getName() + "Test";
            boolean testExists = javaClass.getPackage()
                .getClassesInPackageTree().stream()
                .anyMatch(c -> c.getName().equals(testClassName));
            if (!testExists) {
                events.add(SimpleConditionEvent.violated(javaClass,
                    javaClass.getName() + " has no corresponding test class"));
            }
        }
    };

Adopting Architecture Tests in Legacy Codebases

The most common objection is practical: "Our codebase already has hundreds of violations. We can't fix them all before we start."

This is a legitimate concern, and it's why ArchUnit provides freezing rules:

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

A frozen rule captures the current state of violations and stores them in a baseline file checked into version control. From that point forward:

  • New violations fail the build , no new architectural debt is accepted

  • Fixed violations update the baseline , preventing regression

  • Existing violations are tolerated , giving the team space to address them incrementally

This transforms the adoption story from "fix everything first" to "stop the bleeding now, heal gradually." Teams can introduce architecture tests into a ten-year-old codebase on a Tuesday afternoon without committing to a six-month remediation project first.


Architecture Tests as Living Documentation

One of the most underappreciated aspects of encoding architecture rules as tests is what it does to documentation.

Consider the typical lifecycle of an architecture decision:

  1. It's made in a meeting

  2. It's written down in a wiki or an ADR

  3. The wiki page drifts out of date as the system evolves

  4. New team members never find it, or find an outdated version

  5. The decision is effectively lost from institutional memory

Architecture tests break this cycle. They live in the repository, alongside the code they govern. They are precise , there is no ambiguity about what "domain must not depend on infrastructure" means when it is expressed as a failing test. They are always current , if the architecture evolves and the rules aren't updated, they either fail or are explicitly changed, which itself triggers review and discussion.

A well-organized set of architecture tests tells a new team member more about how the system is designed and why than any amount of wiki prose:

src/test/java/com/myapp/architecture/
├── LayerDependencyRulesTest.java      
├── DomainPurityRulesTest.java        
├── NamingConventionRulesTest.java     
├── CodingStandardsTest.java          
└── ModuleBoundaryRulesTest.java       

Each file is a chapter. Each rule is a sentence. Together they describe an architecture that enforces itself.


The Boundaries of This Approach

Architecture tests are a genuine improvement over manual enforcement, but they come with real limitations worth understanding honestly.

They verify structure, not behavior. A test can confirm that a service class doesn't import a controller, but it says nothing about whether that service implements the right business logic. Structural and behavioral tests answer different questions and neither replaces the other.

They operate at compile-time boundaries. Runtime interactions , a service calling another service over HTTP, a message published to the wrong topic , are invisible to bytecode analysis. Architecture tests see what the compiler sees, nothing more.

They require team buy-in to remain meaningful. A rule that the team doesn't understand or agree with will be worked around, ignored, or deleted. Architecture tests imposed without discussion create friction rather than discipline. The rules are only as strong as the consensus behind them.

Too many rules erode trust. A test suite full of trivial or pedantic constraints trains developers to distrust the feedback they're getting. Rules should protect genuinely important boundaries , the ones that, when violated, make systems harder to understand, test, or change. Not every convention needs a test.


A Philosophy of Architectural Discipline

There is a broader principle underneath all of this: make the right thing easy and the wrong thing visible.

Every software team operates under pressure , time pressure, cognitive pressure, organizational pressure. When architectural rules exist only as documentation or convention, following them requires active effort and awareness. Violating them requires none. Architecture tests quietly invert this. Following the rules costs nothing. Violating them requires a deliberate act , removing or modifying a test , which naturally surfaces a conversation that should happen anyway.

This is not about distrust of developers. It's about recognizing that human attention is finite and should be reserved for decisions that genuinely require judgment. The boundary between domain and infrastructure is not a judgment call made fresh each sprint. It was decided once, deliberately, for good reasons. Automating its enforcement frees reviewers to focus on the things that actually need their eyes.

The best architectures are not just well-designed. They are well-protected , not by the vigilance of individuals, but by the structure of the system itself.


ArchUnit is available at archunit.org. The examples in this article use ArchUnit 1.3.0 with JUnit 5.