Skip to content

12.2 Dependency Rules: How Clean Architecture Translates to Code

Architecture diagrams draw neat concentric circles, but in code, business modules still directly import databases and web frameworks. Ah Hua decides to walk through each dependency in the direction of dependency flow.

While onion architecture, hexagonal architecture, and Clean Architecture differ in their visual representations, they share a core constraint: business policies reside in the inner layers, and source code dependencies can only point to more stable, inner layers.

This dependency rule governs relationships recognized at compile time, not runtime invocation. A domain layer may trigger a database save at runtime, but it does so through its own ports. Thus, the source code remains independent of database implementations.

Four Types of Code, Not Four Required Folders

A common concentric structure in Clean Architecture can be understood as:

  1. Enterprise Business Rules: Stable entities and business rules that transcend use cases;
  2. Application Business Rules: The orchestration specific to individual use cases;
  3. Interface Adapters: Controllers, presenters, gateways, and mappings;
  4. Frameworks & Drivers: Web frameworks, database drivers, and message middleware.

The number of layers can be adjusted. The key point is that outer-layer details must not serve as prerequisites for inner-layer policies.

text
frameworks → adapters → application → domain
     External details                        Core policies

What Crosses the Boundary Is Stable Data

Do not let the inner layer receive HttpServletRequest, JPA Entity, Kafka ConsumerRecord, or supplier SDK objects. The data at the boundary should be ordinary structures that the inner layer can safely own:

java
public record SettleMatchCommand(MatchId matchId, PlayerId winnerId) {}

public sealed interface SettleMatchResult {
    record Settled(ScoreDelta delta) implements SettleMatchResult {}
    record AlreadySettled(Instant settledAt) implements SettleMatchResult {}
}

This confines protocol upgrades, schema changes, and SDK replacements to the adapter layer. The need for mappings on both sides of the boundary does not indicate a design failure, it reflects an explicit payment of coupling cost.

When Output Boundaries Are Valuable

Simple use cases can directly return a result object. Output ports should only be considered when a use case needs to actively control multi-stage presentation, or when the same output supports multiple presentation strategies:

java
interface SettleMatchOutput {
    void settled(ScoreDelta delta);
    void alreadySettled(Instant settledAt);
    void rejected(Rejection reason);
}

Avoid adding a Presenter interface to every return value just to mimic architectural diagrams. Abstractions should serve real variation axes, not surface-level design patterns.

Enforce Rules with Build Boundaries

Directory conventions rely on developer discipline, but module dependencies are more reliably enforced by the build system:

text
domain                 does not depend on other business modules
application            depends on domain
adapters:web           depends on application
adapters:persistence   depends on application and domain
bootstrap              depends on all modules and is responsible for assembly

Architectural tests can also prevent regressions:

java
@ArchTest
static final ArchRule domain_must_not_depend_on_adapters =
    noClasses().that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("..adapter..", "org.springframework..", "jakarta.persistence..");

Architectural tests can only verify codifiable rules. They cannot determine whether a class named DomainService silently performs protocol translation, nor can they replace peer code reviews.

Define Test Strategies by Layer

ScopeKey ValidationCommon Tests
DomainInvariants, state transitions, computationsFast unit tests without frameworks
ApplicationUse case orchestration, failure semantics, port interactionsUse case tests with trusted stubs
AdapterMapping, SQL, serialization, protocol compatibilityDatabase, message, and HTTP integration tests
AssemblyDependency injection, configuration, critical pathsLimited startup and end-to-end tests

"Core testability" doesn't mean testing only the core. Adapters contain the code most vulnerable to real-world protocol discrepancies and must be validated on infrastructure closely resembling production.

Typical Failure Modes

Domain Anemia: All Rules Stay in Application Services

Correct dependency direction does not guarantee that models have behavior. Invariant constraints that are stable across multiple use cases should be enforced by domain objects, not duplicated across every entry point. Otherwise, each use case may implement its own version of the rules, leading to inconsistency and fragility.

Repository Interfaces Mirror ORM APIs

save(Entity), findAll(Pageable) If repository interfaces expose framework-level abstractions directly, the core logic is merely renaming an external API. Ports should instead express the actual query semantics and consistency requirements needed by the domain.

Every Change Requires a New Type Layer

Clean Architecture is not a competition for boilerplate code. Simple CRUD operations can remain minimal and straightforward. However, at boundaries with high external dependency, complex business rules, and long lifespans, greater isolation and abstraction are required.

Core Domain Events Are Decided by Adapters

The fact that "the match has ended" should be determined by the domain or use case layer. Details such as Kafka topic names, retry headers, and serialization versions belong to the message adapter. Never let the message format dictate domain facts, this reverses responsibility and undermines domain integrity.

Architecture Validity Acceptance Questions

  • Can core rules run without starting the web server or database?
  • When replacing the persistence implementation, do the domain and use case logic remain unchanged?
  • Are external errors translated into core-understandable failures at the adapter boundary?
  • Can module dependencies and architectural tests prevent inner layers from importing outer ones?
  • Can the team identify the mapping and abstraction costs paid for flexibility?

The next chapter moves from dependency boundaries within a single application to deployment boundaries: first constructing modular monoliths, then evaluating whether extracting modules into independent services is justified.

References

Built with VitePress | Software Systems Atlas