Skip to main content

Clean Architecture in Java Spring Boot 3.4: 10 Production-Ready Patterns (and 3 Anti-Patterns to Avoid)

The four layers of Clean Architecture in Spring Boot 3.4, three model traps run for real, and an honest account of how the backend applies the rule.

What do you think about this article?

Clean Architecture in Java Spring Boot 3.4: 10 Production-Ready Patterns (and 3 Anti-Patterns to Avoid)
Clean ArchitecturePhoto:RDNE Stock projectPexels

At a Glance

The four layers of Clean Architecture in Spring Boot 3.4, three model traps run for real, and an honest account of how the backend applies the rule.

A Spring Boot monolith with no internal boundaries works as long as it is small. Then a database change touches the controller, a test requires the whole context, and nobody knows any more where the rule that limits registrations lives. Clean Architecture is not a library: it is a rule about the direction of dependencies, applied to four layers. This article is the pillar of the series. It gives you the overall picture, the experiments I ran (including three mistakes that are often made with records, amounts and metrics) and an honest assessment of how well the rule holds in this site's backend, which applies it with documented exceptions.

The four layers and the rule that holds them together

Robert C. Martin describes the Dependency Rule like this: source code dependencies can only point inward, and nothing in an inner circle can know anything about a circle in an outer one. In this site's backend the circles are four folders. The domain contains entities, value objects and ports, without a framework. The application contains the use cases and depends on the domain. The infrastructure implements the ports with JPA, Redis, HTTP and depends on domain and application. The presentation exposes the REST controllers and talks to the use cases.

That is the scheme, and the project adds a second level to it: next to the four folders there are vertical modules (for example projecthub, partitaiva, lms) with a structure of their own. The following command and its measurements were run in backend/ on 2026-11-12; the share of files that import Spring per layer is computed with grep -rlE '^import org.springframework'.

cd backend
ls src/main/java/dev/federicocalo/portfolio
find src/main -name "*.java" | wc -l
find src/test -name "*.java" | wc -l
ls src/main/resources/db/migration | wc -l
grep -rhE "^\s+@(Get|Post|Put|Delete|Patch)Mapping" src/main/java | wc -l
for d in domain application infrastructure presentation; do
  base=src/main/java/dev/federicocalo/portfolio/$d
  echo "$d: $(grep -rlE '^import org.springframework' --include=*.java $base | wc -l) of $(find $base -name '*.java' | wc -l) files import Spring"
done

# Output:
# PortfolioApplication.java
# application
# crm
# domain
# ecosystemportal
# infrastructure
# knowledgehub
# lms
# partitaiva
# presentation
# projecteconomics
# projecthub
# revenue
# tutoringreview
# urbex
# 3741
# 783
# 228
# 1008
# domain: 3 of 146 files import Spring
# application: 486 of 1666 files import Spring
# infrastructure: 238 of 324 files import Spring
# presentation: 147 of 152 files import Spring

The numbers describe the project better than a diagram. The domain has 3 files out of 146 that import Spring: almost pure, and not entirely. The application layer has 486 out of 1666 and that is not a defect: the use cases use @Service and @Transactional, a conscious choice that moves purity to the domain alone. The infrastructure and the presentation import Spring almost always, as is natural. The four folders contain 2288 files, while the Java files under src/main are 3741: the rest lives in the vertical modules. So when I say "the backend applies the rule" I am talking about a perimeter, not about everything.

Ports and adapters: changing technology without touching the use case

The concrete benefit of the Dependency Rule is replaceability. A use case depends on an interface (the port), and whoever builds it decides which implementation (the adapter) to pass in: this is the Ports and Adapters scheme, also called hexagonal architecture, described by Alistair Cockburn. In the example, the welcome use case does not know whether the message ends up on the console or in a queue.

public interface EmailSender {
    void sendWelcome(String to);
}

public class WelcomeUseCase {

    private final EmailSender emailSender;

    public WelcomeUseCase(EmailSender emailSender) {
        this.emailSender = emailSender;
    }

    public String execute(String to) {
        emailSender.sendWelcome(to);
        return "welcome sent to " + to;
    }
}

public class ConsoleEmailSender implements EmailSender {
    @Override public void sendWelcome(String to) {
        System.out.println("[console] welcome message for " + to);
    }
}

import java.util.ArrayList;
import java.util.List;

public class OutboxEmailSender implements EmailSender {
    private final List<String> queue = new ArrayList<>();

    @Override public void sendWelcome(String to) {
        queue.add(to);
    }

    public List<String> queued() { return List.copyOf(queue); }
}

public class DemoSwap {

    public static void main(String[] args) {
        var console = new WelcomeUseCase(new ConsoleEmailSender());
        System.out.println(console.execute("giulia@example.com"));

        var outbox = new OutboxEmailSender();
        var queued = new WelcomeUseCase(outbox);
        System.out.println(queued.execute("marco@example.com"));
        System.out.println("queued: " + outbox.queued());
    }
}

// Output:
// [console] welcome message for giulia@example.com
// welcome sent to giulia@example.com
// welcome sent to marco@example.com
// queued: [marco@example.com]

The WelcomeUseCase is the same in both runs: only the constructor argument changes. In the first case the adapter prints, in the second it queues the recipient, and the "queued" line shows that the message ended up there. In a Spring application the "who builds it" is the dependency container, but the principle is identical, and it is also what makes it trivial to test the use case with a fake adapter. How to write and test a complete use case is in Use Case Pattern in Spring Boot Clean Architecture, the persistence and cache adapters are in Infrastructure layer: JPA and Redis.

Entity and value object: three traps the code shows

A value object is identified by its value (a sum of money, an email); an entity is identified by an id that stays the same while its data changes. Java records are convenient for value objects, and they tempt you to use them for entities too, which is where you trip. The following program shows three traps with real output.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("different currencies: " + currency + " and " + other.currency);
        }
        return new Money(amount.add(other.amount), currency);
    }
}

import java.util.Set;

public record Email(String value) {

    private static final Set<String> DISPOSABLE_DOMAINS = Set.of("temp-mail.com");

    public Email {
        int at = value.indexOf('@');
        if (at <= 0 || at == value.length() - 1) {
            throw new IllegalArgumentException("invalid email: " + value);
        }
        if (DISPOSABLE_DOMAINS.contains(value.substring(at + 1).toLowerCase())) {
            throw new IllegalArgumentException("domain not allowed: " + value.substring(at + 1));
        }
    }
}

public record UserRecord(String id, String name, String role) { }

import java.util.Objects;

public final class UserEntity {
    private final String id;
    private String role;

    public UserEntity(String id, String role) {
        this.id = Objects.requireNonNull(id);
        this.role = role;
    }

    public String id() { return id; }
    public String role() { return role; }
    public void promoteTo(String newRole) { this.role = newRole; }

    @Override public boolean equals(Object o) {
        return o instanceof UserEntity other && id.equals(other.id);
    }

    @Override public int hashCode() { return id.hashCode(); }
}

import java.math.BigDecimal;
import java.util.Currency;

public class DemoModel {

    public static void main(String[] args) {
        Currency eur = Currency.getInstance("EUR");
        Currency usd = Currency.getInstance("USD");

        System.out.println("BigDecimal 10 and 10.00 equal: " + new BigDecimal("10").equals(new BigDecimal("10.00")));
        Money ten = new Money(new BigDecimal("10"), eur);
        Money tenAgain = new Money(new BigDecimal("10.00"), eur);
        System.out.println("10 and 10.00 EUR equal: " + ten.equals(tenAgain));
        System.out.println("sum: " + ten.add(new Money(new BigDecimal("5.5"), eur)));
        try {
            ten.add(new Money(BigDecimal.ONE, usd));
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
        try {
            new Money(new BigDecimal("1.005"), eur);
        } catch (ArithmeticException e) {
            System.out.println("1.005 EUR rejected: " + e.getMessage());
        }

        try {
            new Email("someone@temp-mail.com");
        } catch (IllegalArgumentException e) {
            System.out.println("email: " + e.getMessage());
        }

        var before = new UserRecord("u-1", "Giulia", "USER");
        var after = new UserRecord("u-1", "Giulia", "ADMIN");
        System.out.println("record, same id and different role, equal: " + before.equals(after));

        var a = new UserEntity("u-1", "USER");
        var b = new UserEntity("u-1", "USER");
        b.promoteTo("ADMIN");
        System.out.println("entity, same id and different role, equal: " + a.equals(b));
    }
}

// Output:
// BigDecimal 10 and 10.00 equal: false
// 10 and 10.00 EUR equal: true
// sum: Money[amount=15.50, currency=EUR]
// rejected: different currencies: EUR and USD
// 1.005 EUR rejected: Rounding necessary
// email: domain not allowed: temp-mail.com
// record, same id and different role, equal: false
// entity, same id and different role, equal: true

The first line is the decimals trap: two BigDecimal values with different scale (10 and 10.00) are not equals, because the method also compares the scale. The compact constructor of Money normalizes the scale to the currency's digits with RoundingMode.UNNECESSARY, and so the two amounts turn out equal, while an amount like 1.005 EUR is rejected instead of being silently rounded. Adding different currencies throws an exception: the rule lives in the type, not in the code that uses it.

The second trap is in the disposable-domains rule, which lives inside Email. If the rule were in a service, every place that creates an email would have to remember to call it: inside the value object you cannot forget, because an invalid Email does not exist. The third is the subtlest: UserRecord is a record, so equals compares all fields, and two objects with the same id but a different role come out different. For an entity this is not what you want, and the UserEntity class with an id-based equals solves the problem at the price of controlled mutability. The domain model is covered in depth in Domain layer in Clean Architecture.

Transactions and exceptions: where they live and how they get out

The transaction belongs to the use case, because it is the use case that knows which writes form a unit of work, and not to the domain or the controller. In the backend I measured where @Transactional appears (2026-11-12): 76 files in application, 32 in infrastructure, 0 in domain and 0 in presentation. Domain exceptions extend RuntimeException because, by default, Spring rolls back the transaction only for unchecked exceptions; a global handler in the presentation translates them into HTTP statuses, so the use case does not know what a 404 is. The detail, with the code and the Spring documentation, is in Use Case Pattern in Spring Boot Clean Architecture, and I do not repeat it here.

Persistence: Spring Data behind the port, not in place of the port

The Spring Data repository is an infrastructure detail. The domain sees a port with the methods it needs; the adapter converts between the JPA entity and the domain model. When the filters become many, the temptation is an interface with dozens of findByXAndYAndZ methods. The Spring Data JPA documentation proposes Specifications: predicates composed with the Criteria API, usable by extending the interface with JpaSpecificationExecutor. The following schema is not executed (it requires Spring Data and a database).

public final class ArticleSpecifications {

    public static Specification<ArticleJpaEntity> byAuthor(String authorId) {
        return (root, query, cb) -> cb.equal(root.get("authorId"), authorId);
    }

    public static Specification<ArticleJpaEntity> published() {
        return (root, query, cb) -> cb.isTrue(root.get("published"));
    }

    public static Specification<ArticleJpaEntity> titleContains(String text) {
        return (root, query, cb) ->
            cb.like(cb.lower(root.get("title")), "%" + text.toLowerCase() + "%");
    }
}

interface JpaArticleRepository
        extends JpaRepository<ArticleJpaEntity, String>, JpaSpecificationExecutor<ArticleJpaEntity> { }

// in the adapter: filters are composed only when needed
Specification<ArticleJpaEntity> filter = Specification.where(published());
if (authorId != null) filter = filter.and(byAuthor(authorId));
if (text != null)     filter = filter.and(titleContains(text));
List<ArticleJpaEntity> rows = jpa.findAll(filter);

Each specification is a small, composable filter, and the adapter combines them only if the value is present. The trade-off is that attribute names are strings: a typo ("autorId") fails at runtime, not at compile time, unless you use the JPA metamodel. It is not a pattern that this site's backend applies: in the src/main/java code no file uses Specification< or JpaSpecificationExecutor (measured with grep -rl). My position is that it is only worth it when the combinable filters are really many; with three or four fixed queries, derived methods are more readable. The migrations and the JPA adapter are in JPA and Flyway in Clean Architecture.

Cache and tests: two topics with an article each

The cache, with the comparison between decorator and @Cacheable and the behaviour when Redis does not answer, is in Infrastructure layer: JPA and Redis. The backend uses Redis as a cache with individual TTLs for each cache, and there is also Caffeine: the Javadoc of RedisCacheConfig mentions a 30-minute Caffeine cache in the currency-exchange use case (FxEurUseCaseImpl). The test pyramid and the cases that mocks do not see are in Complete Testing in Clean Architecture, and the use of Testcontainers in Testing Spring Boot with Testcontainers. The SOLID principles underlying these patterns are in SOLID Principles in Practice with Java and TypeScript.

Metrics: counting what happens, without dirtying the domain

A metric is a side effect and therefore belongs on the infrastructure side, or in a use case decorator. Micrometer is the library Spring Boot uses for this; the Spring Boot documentation lists prometheus among the Actuator endpoints. With the SimpleMeterRegistry you can see in a few lines what a counter, a timer and a gauge record, without Spring.

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.util.ArrayList;
import java.util.List;

public class DemoMetrics {

    public static void main(String[] args) {
        MeterRegistry registry = new SimpleMeterRegistry();

        Counter published = Counter.builder("articles.published").tag("category", "java").register(registry);
        Timer publishTime = Timer.builder("articles.publish.duration").register(registry);
        List<String> drafts = new ArrayList<>(List.of("draft A", "draft B"));
        registry.gaugeCollectionSize("articles.drafts", io.micrometer.core.instrument.Tags.empty(), drafts);

        for (int i = 0; i < 3; i++) {
            publishTime.record(() -> published.increment());
        }
        drafts.add("draft C");

        System.out.println("published: " + published.count());
        System.out.println("timer measurements: " + publishTime.count());
        System.out.println("drafts: " + registry.get("articles.drafts").gauge().value());
        System.out.println("counter tags: " + published.getId().getTags());
    }
}

// Output:
// published: 3.0
// timer measurements: 3
// drafts: 3.0
// counter tags: [tag(category=java)]

The counter has the tag category=java, and its value is a double (3.0). The timer records three measurements. The gauge follows the list through gaugeCollectionSize and, after a third draft is added, is worth 3: the gauge reads the value at the moment of observation. The trap lies in the kind of gauge that accepts any object: the Micrometer documentation states that all forms of gauge keep only a weak reference to the observed object, so if you pass a temporary object (a string created on the fly) nothing prevents the garbage collector from removing it, and the gauge loses the object it was supposed to observe. For a gauge on a changing number use an AtomicInteger that you keep in a field.

In the backend, Micrometer is present with micrometer-registry-prometheus in the pom.xml and application.yml exposes health, info, metrics and prometheus under management.endpoints.web.exposure.include. The registry appears in 5 files of src/main/java (grep -rln MeterRegistry), so the instrumentation is targeted and not widespread. A counter's label must be kept low-cardinality: a tag with unbounded values (a user id) generates a data series for each value.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

How clean it really is: the honest assessment

An article about an architecture that claims "zero violations" is hiding something. Here is what I measured in the backend (2026-11-12), with the commands reported above or in the files cited.

  • ArchUnit rules with a frozen baseline. CleanArchitectureTest freezes two global rules: presentation does not depend on infrastructure, domain without Spring or JPA. The store holds 224 violation lines for the first and 6 for the second.
  • Coverage. The JaCoCo check with an 80% threshold on instructions is limited to the partitaiva packages; there is no global coverage that can be declared.
  • Mocks. 55 test files still use @MockBean and 5 use @MockitoBean (grep -rl on backend/src/test): the migration to the new name is incomplete.
  • Transactions. 32 infrastructure files declare @Transactional, 22 of which are repository adapters: it is not the ideal separation, but with the default propagation they take part in the use case's transaction instead of opening another.

The picture is that of a project that chose to introduce the checks without stopping to fix the past: the existing violations are visible and fixed in place, the new ones make the build fail. It costs a debt that can be forgotten, and a trap in the text of the rule, which changes the store key and resets the baseline (the proof is in Complete Testing in Clean Architecture). The least comfortable figure is the first: 224 lines of controllers importing infrastructure classes, referring to about thirty classes, say that the rule "the presentation does not touch the infrastructure" holds for new code, not for all of it.

When it is not worth it

This is my assessment, not a measurement: for a prototype you will use for three months and throw away, four layers, ports and mappers cost more than they return, and a simple layered architecture with Spring everywhere is a reasonable choice. Clean Architecture pays off when the code lives for years, when the technologies around the business change (database, provider, channel) or when several people work on the same code and need a boundary that nobody breaks by distraction. The price is a much larger number of files: this site's backend has 3741 Java files in src/main, and finding your way requires naming conventions and the IDE's search by name.

Frequently asked questions

How do I move an existing monolith toward this structure?

Feature by feature, not all at once: pick the next one you have to touch and write it according to the layers, leaving the rest until it needs maintenance. There is no time that is valid in general, because it depends on how often the code changes. Freezing the violations with ArchUnit, as the backend does, stops the debt from growing while you reduce it.

Can I use Spring Data JPA without dirtying the domain?

Yes, by confining Spring Data to the infrastructure: the port lives in the domain, the adapter with the Spring Data repository and the JPA entity lives in infrastructure, and a mapper converts between the two models. The cost is the mapper and the risk of divergence between the two models, which is covered by a round-trip test like the one described in the article on the infrastructure.

How do I check that the domain does not import Spring?

With an ArchUnit rule that fails when a domain class depends on org.springframework or jakarta.persistence, as the backend's frozen test does. A rule like that must be seen failing at least once: the experiment I ran is in Infrastructure layer: JPA and Redis. For the language features that simplify the domain (records, pattern matching) there is Java 21 for Spring Boot Developers.

What you can do now

You can explain the four layers through the direction of dependencies and you can measure how closely real code respects it, with commands you can repeat. You can recognize the three traps of the model (decimals with different scale, records used as entities, rules outside the value object), you know the limit of gauges and you know what it means to freeze an ArchUnit rule. And you can judge honestly when this architecture is worth its cost.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects