Skip to content
Back EndJava

Enforcing DDD Tactical Patterns with ArchUnit in Spring Boot 4

5 min readsalahtobok
Abstract Architecture with Shield symbolizing ArchUnit and Domain Driven Design

Domain-Driven Design (DDD) is a powerful methodology for building complex enterprise software. However, enforcing its rules in a Spring Boot application can be difficult. Over time, developers often make mistakes: entities leak into controllers, repositories return database objects instead of Aggregate Roots, and the domain layer becomes coupled to the framework.

To solve this, we can use ArchUnit. ArchUnit is a free Java testing library that checks your architecture rules. In this article, you will learn how to enforce DDD tactical patterns in Spring Boot 4 using Java 25.

The 4 standard Domain-Driven Design layers: Domain, Application, Infrastructure, and Presentation
The 4 standard Domain-Driven Design layers: Domain, Application, Infrastructure, and Presentation.

1. What are DDD Tactical Patterns?

DDD has two main parts: Strategic Design and Tactical Design. Strategic Design focuses on bounded contexts and subdomains. Tactical Design focuses on the actual Java code blocks you write every day.

The core DDD tactical patterns are:

  • Entities: Objects with a unique identity that changes over time.
  • Value Objects: Immutable objects defined only by their attributes (no identity).
  • Aggregate Roots: The main entity that controls access to a group of related entities.
  • Repositories: Interfaces that save and load Aggregate Roots.
  • Domain Events: Events that describe something important that happened in the domain.

If we do not enforce these patterns, the architecture quickly becomes a “Big Ball of Mud”. Developers might try to save a child entity directly instead of going through the Aggregate Root. ArchUnit prevents these mistakes automatically during the Maven build.

2. Setting Up ArchUnit in Spring Boot 4

First, you need to add ArchUnit to your Spring Boot 4 project. Open your pom.xml and add the following dependency:


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

This imports ArchUnit and its JUnit 5 integration. Next, create a test class in your src/test/java directory. ArchUnit uses the @AnalyzeClasses annotation to scan your compiled Java bytecode.

ArchUnit radar scanning Java modules and enforcing rules
ArchUnit automatically scans your bytecode during testing to find architectural violations.

3. Enforcing the Aggregate Root Pattern

The most important rule in DDD tactical design is that only Aggregate Roots can be saved or loaded directly. Repositories should never return a child entity directly.

Let’s say we have an Order (Aggregate Root) and an OrderLine (child entity). To enforce this rule, we can create a custom Java annotation called @AggregateRoot.


package com.webcodein.workshop.architecture.archunitenforceddd.domain.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface AggregateRoot {
}

Now, we apply this annotation to our Order class.

Diagram showing an Aggregate Root protecting internal entities
An Aggregate Root acts as the gatekeeper for all internal entities within its boundary.

Next, we write an ArchUnit rule. We want to ensure that any interface named “Repository” only uses classes annotated with @AggregateRoot as return types or parameters.


package com.webcodein.workshop.architecture.archunitenforceddd;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

@AnalyzeClasses(packages = "com.webcodein.workshop.architecture", importOptions = ImportOption.DoNotIncludeTests.class)
public class AggregateRootArchTest {

    @ArchTest
    static final ArchRule repositories_must_only_return_aggregate_roots =
        classes()
            .that().haveNameMatching(".*Repository")
            .should().dependOnClassesThat().areAnnotatedWith(com.webcodein.workshop.architecture.archunitenforceddd.domain.annotations.AggregateRoot.class)
            .because("Repositories must only deal with Aggregate Roots in DDD.");
}

If a developer accidentally creates an OrderLineRepository, this ArchUnit test will immediately fail the Maven build and explain the reason.

4. Keeping the Domain Framework-Agnostic

In clean DDD, the Domain layer must not know about the framework (Spring Boot) or the database (Hibernate/JPA). The domain should contain pure Java classes.

We can use ArchUnit to ban all Spring and Jakarta JPA annotations inside the domain package.


@ArchTest
static final ArchRule domain_must_not_depend_on_spring =
    noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
        .because("The Domain layer must be pure Java and agnostic to frameworks or databases.");

This rule ensures that @Entity, @Table, and @Autowired never pollute your business logic. You must map your domain objects to JPA entities in the infrastructure package instead.

5. Enforcing Value Object Immutability

Value Objects represent concepts like Money, Address, or Email. They have no identity, and they must be completely immutable. If you change a value, you create a new Value Object.

With Java 25, we can use the record keyword to easily create immutable objects. However, we can also use ArchUnit to verify that any class annotated with @ValueObject only has final fields.


@ArchTest
static final ArchRule value_objects_must_be_immutable =
    classes()
        .that().areAnnotatedWith(com.webcodein.workshop.architecture.archunitenforceddd.domain.annotations.ValueObject.class)
        .should().haveOnlyFinalFields()
        .because("Value Objects must be completely immutable.");

6. Protecting Layer Boundaries

Finally, we must ensure that the Presentation layer never accesses the Infrastructure layer directly. The Presentation layer must only talk to the Application or Domain layers.


import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@ArchTest
static final ArchRule layer_dependencies_are_respected = layeredArchitecture()
    .consideringAllDependencies()
    .layer("Presentation").definedBy("..presentation..")
    .layer("Application").definedBy("..application..")
    .layer("Domain").definedBy("..domain..")
    .layer("Infrastructure").definedBy("..infrastructure..")

    .whereLayer("Presentation").mayNotBeAccessedByAnyLayer()
    .whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation", "Infrastructure")
    .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer()
    .because("We must follow strict Hexagonal / Clean Architecture boundaries.");
Automated testing dashboard showing green checkmarks for passing ArchUnit rules
Integrating ArchUnit into your CI/CD pipeline ensures no architectural violations are merged to production.

7. Conclusion

Domain-Driven Design tactical patterns bring structure and safety to complex Spring Boot 4 applications. However, manual code reviews are not enough to stop developers from breaking these rules.

By integrating ArchUnit, you turn your architectural guidelines into automated, executable tests. If a rule is broken, the build fails immediately. This guarantees that your DDD boundaries stay clean forever.

💻 Companion Workshop Code

Want to run this code locally? The complete, compilable Java 25 Spring Boot 4 project is available on our GitHub repository.


View Workshop Code on GitHub

References

Comments