12.2 Dependency Rules: How Clean Architecture Translates to Code
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. In the previous lesson, the application service calls a core-owned repository port that reaches a database adapter at runtime. Source dependencies still point from adapter to port. The rule does not require domain entities themselves to hold repositories.
Four Types of Code, Not Four Required Folders
A common concentric structure in Clean Architecture can be understood as:
- Enterprise Business Rules: Stable entities and business rules that transcend use cases;
- Application Business Rules: The orchestration specific to individual use cases;
- Interface Adapters: Controllers, presenters, gateways, and mappings;
- 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.
frameworks → adapters → application → domain
External details Core policiesWhat Crosses the Boundary Is Stable Data
These Java 21 fragments assume project-defined IDs, score changes, and rejection reasons. Their constructors must still enforce input and result invariants. Do not let the inner layer receive HttpServletRequest, JPA Entity, Kafka ConsumerRecord, or vendor SDK objects. The data at the boundary should be ordinary structures that the inner layer can safely own:
public record SettleMatchCommand(MatchId matchId, PlayerId winnerId) {}
public sealed interface SettleMatchResult {
record Settled(ScoreDelta delta) implements SettleMatchResult {}
record AlreadySettled(java.time.Instant settledAt) implements SettleMatchResult {}
record Rejected(Rejection reason) 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. Consider an output port when results need a replaceable presenter or a use case emits several stages of business results. It describes outcomes, not DOM actions or page layout:
interface SettleMatchOutput {
void settled(ScoreDelta delta);
void alreadySettled(java.time.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:
domain does not depend on this application’s outer 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 assemblyThis graph lists direct dependencies. If public signatures expose domain types, configure which dependencies are also available to consumers. Do not let an accidental test classpath hide a missing dependency.
The following JUnit 5 test uses ArchUnit 1.4.2 to check this stricter boundary. Keep the JUnit setup from 1.1 and add:
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>1.4.2</version>
<scope>test</scope>
</dependency>Save the complete test as src/test/java/ArchitectureTest.java and replace example.tournament with the real root package. It covers both domain and application code, including adapter, adapters, and this volume’s infrastructure naming:
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class ArchitectureTest {
private final JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("example.tournament");
@Test
void domainDoesNotDependOnOuterLayers() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"..application..", "..adapter..", "..adapters..",
"..infrastructure..", "..api..", "..bootstrap..",
"org.springframework..", "jakarta.persistence..")
.check(classes);
}
@Test
void applicationDoesNotDependOnAdapters() {
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAnyPackage(
"..adapter..", "..adapters..", "..infrastructure..",
"..api..", "..bootstrap..",
"org.springframework..", "jakarta.persistence..")
.check(classes);
}
}Check that valid code passes, then temporarily reference an infrastructure type from the domain and confirm failure. Removing the reference should restore the pass. The rules reject an empty checked set by default; a misspelled package must not become a clean bill of architectural health. This example also forbids Spring dependencies in application code, so the annotated service from 11.1 needs the previous lesson’s outer transaction decorator.
ArchUnit analyzes imported class-file dependencies. It cannot reliably detect reflection strings, runtime configuration, or artifacts outside the import scope, and it checks more than source import statements. It also cannot decide whether a DomainService is performing protocol translation; review remains necessary.
Define Test Strategies by Layer
| Scope | Key Validation | Common Tests |
|---|---|---|
| Domain | Invariants, state transitions, computations | Fast unit tests without frameworks |
| Application | Use case orchestration, failure semantics, port interactions | Use case tests with trusted stubs |
| Adapter | Mapping, SQL, serialization, protocol compatibility | Database, message, and HTTP integration tests |
| Assembly | Dependency injection, configuration, critical paths | Limited 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
The name save is not the problem, and an Entity that is your own domain type does not automatically violate the rule. The problem is leaking Pageable, ORM proxies, or framework constraints through an interface that makes the core understand external APIs. 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 been settled" 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
- Robert C. Martin, The Clean Architecture
- Jeffrey Palermo, The Onion Architecture
- ArchUnit, User Guide