Back to Testing Microservices
Q1Question 1 of 1

How do you enforce architectural guardrails as automated tests rather than wiki rules?

Key Concepts
  • ArchUnit encodes structural rules as executable JUnit tests that fail the build on violation.
  • Forbid controllers calling repositories directly so the service layer is never bypassed.
  • Forbid synchronous remote calls inside @Transactional methods to avoid holding DB connections across network calls.
  • Forbid dual writes (repository + KafkaTemplate in same class) without an outbox.
  • layeredArchitecture() enforces the entire dependency direction in one rule.
  • FreezingArchRule records current violations as a baseline so new violations fail while legacy is burned down.
  • Codified rules prevent drift because they apply identically to every change by every author forever.

Definition

Architectural rules on a wiki decay silently, but the same rules as ArchUnit tests in CI fail the build on the first violation, so the architecture is enforced by the machine instead of by human memory.
  • ArchUnit – a Java library that reflects over compiled bytecode and lets you write JUnit tests asserting structural rules.
  • Layer enforcement – layeredArchitecture() declares which layers may access which; violations fail the build.
  • Dual-write guard – a custom condition flags any class depending on both a repository and a broker producer unless it is the outbox relay.
  • Transaction guard – methods annotated @Transactional must not call remote clients.
  • FreezingArchRule – records existing violations as a baseline; only NEW violations fail, enabling incremental adoption.
  • Why it works – an executable rule applies identically to every change forever; human review is inconsistent.

National Bank of India has rules: tellers never open the vault directly, no clerk both updates the ledger and mails a letter without an outbox slip. If those rules live only on a laminated poster, new clerks forget them. Instead, the bank installs automatic turnstiles that physically refuse the violation. ArchUnit is that turnstile for code.

Understanding the Concepts

What is ArchUnit?

ArchUnit loads compiled classes and gives a fluent API to express structural rules as JUnit tests. A violation fails the build exactly like a failing test.

How do you enforce layered architecture?

JAVA
package com.bank.archtest;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.bank")
public class LayerRulesTest {

    @ArchTest
    static final ArchRule layers = layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy("..controller..")
        .layer("Service").definedBy("..service..")
        .layer("Repository").definedBy("..repository..")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service")
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller");
}
TEXT
Expected output on violation:
java.lang.AssertionError:
Architecture Violation: Layer 'Repository' is accessed by layer 'Controller'
  com.bank.controller.TransferController -> com.bank.repository.AccountRepository

How do you forbid controllers calling repositories?

JAVA
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@ArchTest
static final ArchRule controllersDoNotTouchRepos = noClasses()
    .that().resideInAPackage("..controller..")
    .should().dependOnClassesThat().resideInAPackage("..repository..");

How do you forbid sync calls inside @Transactional?

A custom ArchCondition inspects method call targets.

JAVA
@ArchTest
static final ArchRule noRemoteCallsInTransactions = methods()
    .that().areAnnotatedWith(Transactional.class)
    .should(notCallRemoteClients());

// Custom condition checks if any call target resides in ..client.. package

How do you forbid dual writes without an outbox?

JAVA
@ArchTest
static final ArchRule noDualWrites = noClasses()
    .that().areNotAssignableTo(OutboxRelay.class)
    .should(dependOnBothRepositoryAndBroker());
// Flags any class using both JpaRepository AND KafkaTemplate

How do you adopt rules on a large codebase?

Use FreezingArchRule to record current violations as a baseline. Only NEW violations fail.

JAVA
import com.tngtech.archunit.library.freeze.FreezingArchRule;

@ArchTest
static final ArchRule frozenLayers = FreezingArchRule.freeze(layers);
// First run: records 47 existing violations as baseline
// Next runs: only NEW violations fail the build

Why does this prevent architectural drift?

Humans forget, rotate, and shortcut under deadline pressure. Code review catches structural violations inconsistently. An executable rule applies identically to every change by every author forever, documents the intent precisely, and fails fast at the cheapest moment (before merge).

What are common mistakes?

JAVA
// WRONG: relying on a wiki page to enforce architecture
// "Controllers must not call repositories" → forgotten within a month

// CORRECT: ArchUnit test in CI → build fails on first violation
// The rule IS the specification, machine-enforced

What should a senior engineer know?

  • Adopt rules incrementally with FreezingArchRule; do not block on a giant upfront cleanup.
  • ArchUnit tests run as normal JUnit tests: mvn test or gradle test.
  • Naming conventions (every @Service class, every final field) are also enforceable.
  • The failure message names the exact offending class and dependency.
  • Over a year across many engineers, this is the difference between a clean system and an accidental big ball of mud.

Visual Overview

DIAGRAM
  ArchUnit in CI Pipeline

  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
  │ Developer    │────►│ Pull Request │────►│    CI        │
  │ writes code  │     │ created      │     │ mvn test     │
  └──────────────┘     └──────────────┘     └──────┬───────┘
                                                   │
                                            ArchUnit checks:
                                            ✓ Layer rules
                                            ✓ No dual writes
                                            ✓ No sync in tx
                                            ✗ Controller→Repo
                                                   │
                                              BUILD FAILS
                                            "Fix before merge"

  FreezingArchRule
  ┌────────────────────────────────────────────┐
  │ Baseline: 47 existing violations (frozen)  │
  │ New code: 1 new violation → BUILD FAILS    │
  │ Goal: burn down baseline over time         │
  └────────────────────────────────────────────┘

Key Takeaway

Architectural guardrails as tests means encoding structural rules as ArchUnit assertions in CI that fail the build on violation, rather than wiki pages that people forget. The concrete rules are high-value: forbid controllers from depending on repositories, enforce the layered dependency direction, forbid synchronous remote calls inside transactional methods, and forbid dual writes without an outbox.

At National Bank of India, the team had grown to forty engineers and the design was drifting: a controller started calling a repository directly, a transactional method started calling a remote service, and someone did a dual write without an outbox. Each would have been caught inconsistently in code review, but as ArchUnit rules they failed the build the moment they were introduced.

For large codebases, FreezingArchRule records existing violations as a baseline so new violations fail while legacy is burned down incrementally. The rules run as normal JUnit tests in mvn test, and the failure message names the exact offending class.

Remember one sentence: codify architectural rules as ArchUnit tests in CI so the build fails on the first violation, because executable rules apply identically to every change by every author forever, while wiki rules decay silently.

---

Share