Skip to main content

Hexagonal Architecture (Ports and Adapters) in Spring Boot 4

A 4-module Maven reactor that proves “the core has no Spring dependency” with mvn dependency:tree instead of a promise, tests the business rule with zero Spring context, and hits (then fixes) three real Spring Boot 4 surprises along the way: @DataJpaTest and @WebMvcTest moved to new artifacts and packages, TestRestTemplate stopped auto-configuring itself, and Spring Data JPA’s repository scan silently ignores scanBasePackages in a multi-module layout.

Most Spring Boot tutorials put @Service, @Repository, and @RestController on three classes that all talk to each other directly, and that works right up until you need to test the business rule without a database, or swap H2 for Postgres, or discover that half your “domain” code is actually quietly calling EntityManager. Hexagonal architecture (also called ports and adapters) is a way of writing the code so that can’t happen by accident: the business rules live in a class that has never imported anything from the org.springframework package, and this post proves that with a Maven command, not just a promise.

The example is a wallet that can be deposited into and withdrawn from, and refuses an overdraft. That’s deliberately small — the point of this post isn’t the wallet, it’s the four module boundaries around it, and what Spring Boot 4 actually does (and occasionally refuses to do) when you draw them for real.

Versions used in this post. Spring Boot 4.1.1 (verified GA against Maven Central’s maven-metadata.xml — 4.2.0-M2 is the newest entry there but is a milestone, not a release) · Spring Framework 7.0.9 · Hibernate ORM 7.4.5.Final · H2 2.4.240, the version Boot 4.1.1’s BOM manages · JDK 25 LTS. Every piece of output quoted below came out of a real mvn test run against this post’s companion repository, including the dependency tree that is this article’s main exhibit.

Keep the business rules in a class that has never heard of Spring

Start with the rule itself, in the plainest form it can take: a wallet holds a balance; you can add to it; you can take from it as long as there’s enough there. Nothing about “how do I save this” or “how does this arrive over HTTP” belongs in that sentence, so it doesn’t belong in the class either:

public final class Wallet {

    private final String id;
    private long balanceCents;

    public Wallet(String id, long balanceCents) {
        this.id = id;
        this.balanceCents = balanceCents;
    }

    public void deposit(long amountCents) {
        if (amountCents <= 0) {
            throw new IllegalArgumentException("deposit amount must be positive");
        }
        this.balanceCents += amountCents;
    }

    public void withdraw(long amountCents) {
        if (amountCents <= 0) {
            throw new IllegalArgumentException("withdraw amount must be positive");
        }
        if (amountCents > balanceCents) {
            throw new InsufficientFundsException(id, balanceCents, amountCents);
        }
        this.balanceCents -= amountCents;
    }
}

core/src/main/java/com/ankurm/hexagonal/domain/Wallet.java

No import of jakarta.persistence, no import of org.springframework.* — this class could run on a JVM from fifteen years ago. The term for that is the domain, or the core, and the whole discipline of hexagonal architecture is one rule applied consistently: everything the core needs from the outside world is declared as an interface the core owns, and the outside world implements it — never the other way around.

Dependencies point inward, always core Wallet, WalletService + the ports it declares web-adapter WalletController persistence-adapter JpaWalletRepositoryAdapter An adapter depends on the core’s ports to work at all. The core’s pom.xml has no idea either adapter exists — it cannot point outward even if someone tried, because there is nothing to import.

In this post’s companion repository that rule isn’t just followed, it’s enforced by Maven: hexagonal is laid out as its own small 4-module reactor (core, persistence-adapter, web-adapter, app) rather than the single self-contained project every other article in this repo uses, specifically so the claim “the core has no Spring dependency” is something mvn dependency:tree can prove rather than something a reviewer has to remember to check by reading imports.

A port is just an interface the core owns

The core needs two things from the world outside it: a place to save and load a wallet, and a way for something to ask it to deposit or withdraw. Both become plain interfaces, declared inside the core, with no implementation anywhere near them:

public interface WalletRepository {
    Optional<Wallet> findById(String walletId);
    Wallet save(Wallet wallet);
}

core/src/main/java/com/ankurm/hexagonal/ports/WalletRepository.java — an outbound port: the core calling out

public interface DepositMoney {
    Wallet deposit(String walletId, long amountCents);
}

core/src/main/java/com/ankurm/hexagonal/ports/DepositMoney.java — an inbound port: the world calling in

The names are the usual terms for this, and worth keeping straight: an inbound port (sometimes “driving” port) is a use case the outside world triggers — DepositMoney, WithdrawMoney. An outbound port (sometimes “driven” port) is something the core needs and delegates to — WalletRepository. The direction is always named from the core’s point of view.

The class that implements both inbound ports is the one piece of actual business logic in this whole project, and it carries no Spring annotation at all:

public class WalletService implements DepositMoney, WithdrawMoney {

    private final WalletRepository wallets;

    public WalletService(WalletRepository wallets) {
        this.wallets = wallets;
    }

    @Override
    public Wallet deposit(String walletId, long amountCents) {
        Wallet wallet = wallets.findById(walletId)
                .orElseThrow(() -> new WalletNotFoundException(walletId));
        wallet.deposit(amountCents);
        return wallets.save(wallet);
    }

    @Override
    public Wallet withdraw(String walletId, long amountCents) {
        Wallet wallet = wallets.findById(walletId)
                .orElseThrow(() -> new WalletNotFoundException(walletId));
        wallet.withdraw(amountCents);
        return wallets.save(wallet);
    }
}

core/src/main/java/com/ankurm/hexagonal/WalletService.java

No @Service. No @Autowired. WalletService is constructed with a plain new, handed whatever implements WalletRepository. Spring never has to be involved for this class to work correctly, which is exactly what the next section tests.

  • Going deeper on naming inbound vs. outbound ports: Alistair Cockburn’s original write-up, Hexagonal Architecture

Testing the business rule with no Spring context at all

Because WalletService only needs something that implements WalletRepository, its test can hand it a hand-written fake — a HashMap wearing the port’s interface — instead of a real database or a mock framework:

class InMemoryWalletRepository implements WalletRepository {
    private final Map<String, Wallet> store = new HashMap<>();

    void seed(Wallet wallet) { store.put(wallet.id(), wallet); }

    @Override
    public Optional<Wallet> findById(String walletId) {
        return Optional.ofNullable(store.get(walletId));
    }

    @Override
    public Wallet save(Wallet wallet) {
        store.put(wallet.id(), wallet);
        return wallet;
    }
}

core/src/test/java/com/ankurm/hexagonal/InMemoryWalletRepository.java

class WalletServiceTest {

    private final InMemoryWalletRepository wallets = new InMemoryWalletRepository();
    private final WalletService service = new WalletService(wallets);

    @Test
    void withdrawBeyondBalanceIsRefused() {
        wallets.seed(new Wallet("w1", 1000));

        assertThatThrownBy(() -> service.withdraw("w1", 1001))
                .isInstanceOf(InsufficientFundsException.class)
                .hasMessage("wallet w1 has 1000 cents, cannot withdraw 1001 cents");

        // The refusal happened before any write -- the balance is exactly where it started.
        assertThat(wallets.findById("w1").orElseThrow().balanceCents()).isEqualTo(1000);
    }
}

core/src/test/java/com/ankurm/hexagonal/WalletServiceTest.java (4 tests total)

Run just this module and watch what doesn’t happen — no banner, no “Starting WalletServiceTest using Java 25”, no embedded database spinning up:

$ mvn -pl core test
[INFO] --- surefire:3.5.4:test (default-test) @ hexagonal-core ---
[INFO] Running com.ankurm.hexagonal.WalletServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.164 s -- in com.ankurm.hexagonal.WalletServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time:  2.794 s

Full transcript: core/output/00-core-tests-no-spring.txt

0.164 seconds for four tests, with no context to start, is the entire business case for keeping the core Spring-free: every other module in this project pays a multi-second Spring Boot startup cost per test class (you’ll see the exact numbers later in this post), and this one never does.

A dependency tree, not a promise

It’s one thing to say “the core has no framework dependency” in a README; it’s another to make Maven itself incapable of proving otherwise. Here is the complete, unedited output of asking Maven what hexagonal-core actually depends on:

$ mvn -pl core dependency:tree
[INFO] com.ankurm:hexagonal-core:jar:1.0
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.0.3:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-api:jar:6.0.3:test
[INFO] |  |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] |  |  +- org.junit.platform:junit-platform-commons:jar:6.0.3:test
[INFO] |  |  +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] |  |  \- org.jspecify:jspecify:jar:1.0.1:test
[INFO] |  +- org.junit.jupiter:junit-jupiter-params:jar:6.0.3:test
[INFO] |  \- org.junit.jupiter:junit-jupiter-engine:jar:6.0.3:test
[INFO] |     \- org.junit.platform:junit-platform-engine:jar:6.0.3:test
[INFO] \- org.assertj:assertj-core:jar:3.27.7:test
[INFO]    \- net.bytebuddy:byte-buddy:jar:1.18.11:test

Full transcript: core/output/01-core-dependency-tree.txt

Eleven artifacts, every one of them JUnit, AssertJ, or a transitive of those two, every one scoped test — nothing compiles against the core’s main source set except the JDK itself. The reason this is even possible is in core/pom.xml: it imports the Boot BOM for version management (so its test dependencies stay aligned with the rest of the repo) but declares only junit-jupiter and assertj-core, both test-scoped. Importing a BOM pulls in nothing by itself — it only supplies version numbers for artifacts a module actually asks for, and this module never asks for a Spring one.

This is the test the companion repository is built to pass. If a future change to this module ever adds a Spring import by accident, this exact command, run against this exact module, will show it — that is a stronger guarantee than a code review catching an errant import, and it is the entire reason this one article uses a multi-module reactor instead of the single self-contained project every other post in this repo uses.

Plugging in a real database

The core declared WalletRepository; something has to actually implement it against a database, and that something lives in its own module, persistence-adapter, which is the only place in this project allowed to know JPA exists.

First, a JPA-shaped class that is deliberately not the domain’s Wallet:

@Entity
@Table(name = "wallets")
public class WalletEntity {

    @Id
    private String id;
    private long balanceCents;

    protected WalletEntity() { }

    public WalletEntity(String id, long balanceCents) {
        this.id = id;
        this.balanceCents = balanceCents;
    }
    // getters, setBalanceCents
}

persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/WalletEntity.java

Mixing Wallet and WalletEntity into one class would be the easiest mistake to make here, and it would quietly drag Hibernate’s requirements (a no-arg constructor, mutable setters, an identity column) back into the class the first section went out of its way to keep clean. Keeping them separate is one extra class in exchange for the core staying honest.

Next, the real adapter — the only class in the whole application that translates between the two:

@Repository
public class JpaWalletRepositoryAdapter implements WalletRepository {

    private final SpringDataWalletRepository jpa;

    public JpaWalletRepositoryAdapter(SpringDataWalletRepository jpa) {
        this.jpa = jpa;
    }

    @Override
    public Optional<Wallet> findById(String walletId) {
        return jpa.findById(walletId)
                .map(entity -> new Wallet(entity.getId(), entity.getBalanceCents()));
    }

    @Override
    public Wallet save(Wallet wallet) {
        WalletEntity entity = jpa.findById(wallet.id())
                .orElseGet(() -> new WalletEntity(wallet.id(), 0));
        entity.setBalanceCents(wallet.balanceCents());
        jpa.save(entity);
        return wallet;
    }
}

persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.java

SpringDataWalletRepository itself is the plain one-liner you’ve written a dozen times (interface SpringDataWalletRepository extends JpaRepository<WalletEntity, String>), package-private on purpose — nothing outside this module has any business calling it directly, since the only contract anything else should depend on is WalletRepository.

Testing this adapter against a real, if temporary, database is @DataJpaTest‘s whole job — it boots just enough Spring to wire an EntityManager and a repository, against an embedded H2 database, and nothing else:

@DataJpaTest
class JpaWalletRepositoryAdapterTest {

    @Autowired
    SpringDataWalletRepository jpa;

    @Test
    void savingThenFindingRoundTripsThroughTheRealDatabase() {
        JpaWalletRepositoryAdapter adapter = new JpaWalletRepositoryAdapter(jpa);

        adapter.save(new Wallet("w1", 2500));
        Wallet reloaded = adapter.findById("w1").orElseThrow();

        System.out.println("Reloaded wallet w1 balance from H2: " + reloaded.balanceCents());
        assertThat(reloaded.balanceCents()).isEqualTo(2500);
    }
}

persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapterTest.java

Hibernate: drop table if exists wallets cascade
Hibernate: create table wallets (balance_cents bigint not null, id varchar(255) not null, primary key (id))
Hibernate: select we1_0.id,we1_0.balance_cents from wallets we1_0 where we1_0.id=?
Reloaded wallet w1 balance from H2: 2500
findById("ghost").isPresent(): false
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.284 s

Full transcript: persistence-adapter/output/00-jpa-adapter-tests.txt

2500 cents, read back from an actual SQL select against an actual H2 table that Hibernate actually created and dropped in the same run — not a Mockito stub standing in for the database and quietly agreeing with whatever the test expects.

Going deeper: where @DataJpaTest actually lives in Spring Boot 4.1.1

This one cost a real compile cycle while writing this repository. In Spring Boot 3, @DataJpaTest lived in package org.springframework.boot.test.autoconfigure.orm.jpa, inside the spring-boot-test-autoconfigure artifact. In Boot 4.1.1 that package no longer exists — unzipping spring-boot-test-autoconfigure-4.1.1.jar and grepping its file listing for “jpa” (case-insensitive) returns nothing at all. Spring Boot 4 split most test-slice annotations out into their own small artifacts, each under a new package, not just a new jar carrying the old one. @DataJpaTest now lives in artifact spring-boot-data-jpa-test, package org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest — confirmed the same way, by downloading the jar from Maven Central and unzipping its file listing before writing a single line of pom.xml. @WebMvcTest went through the identical move, to artifact spring-boot-webmvc-test, package org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest — see the web-adapter section below for that one in context. If you are migrating an existing Boot 3 test suite to Boot 4, this is very likely the first compile error you hit, and the fix is a new dependency coordinate plus a new import, not a code change.

Plugging in HTTP

The web-adapter module is the mirror image of the persistence one: it depends on the core’s inbound ports, DepositMoney and WithdrawMoney, and translates HTTP requests into calls against them:

@RestController
public class WalletController {

    private final DepositMoney depositMoney;
    private final WithdrawMoney withdrawMoney;

    public WalletController(DepositMoney depositMoney, WithdrawMoney withdrawMoney) {
        this.depositMoney = depositMoney;
        this.withdrawMoney = withdrawMoney;
    }

    @PostMapping("/wallets/{id}/deposit")
    public BalanceResponse deposit(@PathVariable("id") String id, @RequestParam long amountCents) {
        Wallet wallet = depositMoney.deposit(id, amountCents);
        return new BalanceResponse(wallet.id(), wallet.balanceCents());
    }

    public record BalanceResponse(String walletId, long balanceCents) { }
}

web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletController.java

Notice the constructor parameters are the interfaces, not WalletService. WalletController has never heard of WalletService, which is the point of an inbound port: swap the implementation behind DepositMoney for anything else that satisfies the contract, and this class does not change.

Turning a domain exception into an HTTP status is also this adapter’s job, never the core’s:

@RestControllerAdvice
public class WalletExceptionHandler {

    @ExceptionHandler(InsufficientFundsException.class)
    public ProblemDetail onInsufficientFunds(InsufficientFundsException exception) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, exception.getMessage());
    }
}

web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletExceptionHandler.java

@WebMvcTest loads only this layer — no persistence-adapter module on the classpath at all — and replaces the two ports with Mockito doubles:

@WebMvcTest(WalletController.class)
class WalletControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean DepositMoney depositMoney;
    @MockitoBean WithdrawMoney withdrawMoney;

    @Test
    void withdrawBeyondBalanceReturns409WithAProblemDetailBody() throws Exception {
        when(withdrawMoney.withdraw(anyString(), anyLong()))
                .thenThrow(new InsufficientFundsException("w1", 1000, 1001));

        mockMvc.perform(post("/wallets/w1/withdraw").param("amountCents", "1001"))
                .andExpect(status().isConflict())
                .andExpect(content().json(
                    "{\"status\":409,\"detail\":\"wallet w1 has 1000 cents, cannot withdraw 1001 cents\"}"));
    }
}

web-adapter/src/test/java/com/ankurm/hexagonal/web/WalletControllerTest.java

POST /wallets/w1/withdraw?amountCents=1001 -> 409 {"detail":"wallet w1 has 1000 cents, cannot withdraw 1001 cents","instance":"/wallets/w1/withdraw","status":409,"title":"Conflict"}
POST /wallets/w1/deposit?amountCents=500 -> 200 {"walletId":"w1","balanceCents":1500}
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.712 s

Full transcript: web-adapter/output/00-webmvctest-mockmvc.txt

That’s the real ProblemDetail JSON RFC 9457 produces, built entirely from a domain exception the web layer never constructed — it only decided what status code it means.

Two more Boot 4 moves, both easy to trip over. @MockitoBean is not where @MockBean used to be: the old org.springframework.boot.test.mock.mockito.MockBean is gone, replaced by org.springframework.test.context.bean.override.mockito.MockitoBean — note it has moved out of a Spring-Boot-specific package into spring-test itself, as part of Spring Framework 6.2’s general bean-override mechanism. And @RequestParam long amountCents with no explicit name only resolves if the class files carry parameter names, which needs -parameters passed to javac — spring-boot-starter-parent sets that for you silently, but this module’s parent POM is this article’s own small reactor, not spring-boot-starter-parent, so it has to be added by hand (as <parameters>true</parameters> on maven-compiler-plugin in hexagonal/pom.xml). Without it the failure doesn’t show up at compile time at all — it shows up as a 500 at request time, with the message “Name for argument of type [long] not specified…”

The one class that is allowed to know everything

Three modules so far have never been allowed to depend on each other. Something still has to construct a real WalletService with a real JpaWalletRepositoryAdapter inside it, and hand that to a real WalletController — that something is the composition root, and in a Spring Boot app it’s the app module, specifically the one class carrying @SpringBootApplication:

@SpringBootApplication(scanBasePackages = "com.ankurm.hexagonal")
@EnableJpaRepositories(basePackages = "com.ankurm.hexagonal.persistence")
@EntityScan(basePackages = "com.ankurm.hexagonal.persistence")
public class HexagonalApplication {

    public static void main(String[] args) {
        SpringApplication.run(HexagonalApplication.class, args);
    }

    @Bean
    public WalletService walletService(WalletRepository walletRepository) {
        return new WalletService(walletRepository);
    }
}

app/src/main/java/com/ankurm/hexagonal/app/HexagonalApplication.java

The @Bean method is the whole point: WalletService carries no @Service, so without that method Spring would never construct it at all. This is what “the wiring is explicit, not discovered” means in practice — one method, in one class, is the entire answer to “which implementation gets used.”

The two annotations above it exist because of a failure that cost real time to track down, and it’s worth walking through because it will bite anyone who copies this module layout without reading this far.

Sibling packages, not ancestor and descendant com.ankurm.hexagonal.app @SpringBootApplication lives here com.ankurm.hexagonal.persistence SpringDataWalletRepository lives here default scan Spring Data JPA’s repository scan and JPA’s entity scan each default to the main class’s own package — NOT to scanBasePackages, which only controls @Component-style scanning. A sibling package is never reached by that default, so the repository bean is never created, with no warning that scanning — rather than the bean itself — is the actual cause.

Leave those two annotations off and the application fails to start with:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.ankurm.hexagonal.persistence.SpringDataWalletRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2304)
	at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1732)

Full transcript (captured by actually removing the annotations and running the test, not reconstructed from memory): app/output/01-missing-scan-base-packages-failure.txt

which reads exactly like a missing @Repository or a missing component scan — except scanBasePackages = "com.ankurm.hexagonal" already covers that package. @EnableJpaRepositories and @EntityScan each have their own default base package, and that default is the package the main class lives in — com.ankurm.hexagonal.app — not whatever scanBasePackages says. com.ankurm.hexagonal.app and com.ankurm.hexagonal.persistence are siblings, not ancestor and descendant, so the default scan for repositories and entities simply never reaches the adapter module at all. The fix is the two explicit basePackages arguments above, pointed at the adapter’s actual package.

This only bites multi-module layouts. In a single-module Spring Boot project, the main class almost always sits at the root of the package tree everything else nests under, so the default happens to be correct by accident. The moment the @SpringBootApplication class and your @Repository interfaces live in genuinely separate, non-nested packages — which a hexagonal layout all but guarantees — the accident stops covering for you.

With both annotations in place, the full stack starts and the bean graph completes. Here’s the whole thing, over a real embedded Tomcat, a real H2 database, and real HTTP calls:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class HexagonalApplicationTest {

    @Autowired TestRestTemplate rest;
    @Autowired WalletRepository walletRepository;

    @Test
    void depositThenWithdrawOverHttpEndsAtTheRightBalanceThroughTheRealDatabase() {
        walletRepository.save(new Wallet("w1", 1000));

        ResponseEntity<BalanceResponse> afterDeposit = rest.postForEntity(
                "/wallets/w1/deposit?amountCents=500", null, BalanceResponse.class);
        ResponseEntity<BalanceResponse> afterWithdraw = rest.postForEntity(
                "/wallets/w1/withdraw?amountCents=200", null, BalanceResponse.class);

        assertThat(afterWithdraw.getBody().balanceCents()).isEqualTo(1300);

        // Reload straight from the real repository bean -- not from the HTTP response -- to
        // prove the number actually landed in H2, not just in the controller's return value.
        long persisted = walletRepository.findById("w1").orElseThrow().balanceCents();
        assertThat(persisted).isEqualTo(1300);
    }
}

app/src/test/java/com/ankurm/hexagonal/app/HexagonalApplicationTest.java

Overdraft attempt: 409 CONFLICT
After deposit: 200 OK BalanceResponse[walletId=w1, balanceCents=1500]
After withdraw: 200 OK BalanceResponse[walletId=w1, balanceCents=1300]
Balance reloaded directly from WalletRepository: 1300
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.208 s

Full transcript: app/output/00-end-to-end-http.txt

1300 cents, confirmed two independent ways — once from the HTTP response body, once by reloading straight through the port from the real database — is the strongest statement this post can make that every boundary drawn above actually holds together end to end.

Going deeper: TestRestTemplate survived Boot 4, but it moved, and it isn’t automatic anymore

@AutoConfigureTestRestTemplate on the test class above is not decoration. Spring Boot deprecated TestRestTemplate in favor of the newer RestTestClient in Boot 4, but, unlike @DataJpaTest and @WebMvcTest, it did not remove the class — it moved it to its own artifact, spring-boot-resttestclient, package org.springframework.boot.resttestclient.TestRestTemplate (the old package, org.springframework.boot.test.web.client.TestRestTemplate, is gone). The surprising part: a @SpringBootTest(webEnvironment = RANDOM_PORT) no longer auto-configures a TestRestTemplate bean on its own the way it did in Boot 3 — omit @AutoConfigureTestRestTemplate and @Autowired TestRestTemplate rest fails with a plain NoSuchBeanDefinitionException, giving no hint that an annotation, rather than a dependency, is missing. And because TestRestTemplateTestAutoConfiguration’s @ConditionalOnMissingBean check reflectively inspects a RestTemplateBuilder-returning @Bean method to decide whether to back off, the production, non-test artifact spring-boot-restclient (which is where RestTemplateBuilder itself now lives) has to be on the test classpath too, even though no test code in this module calls it directly. Leave it off and the context fails to start with a NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder buried inside autoconfiguration condition evaluation — a genuinely confusing error for a missing test-scope dependency. All three artifact coordinates above were confirmed by downloading the jar from Maven Central and unzipping its contents before writing the dependency, not by guessing from a blog post.

Should every Spring Boot app be structured this way? No. The ceremony in this post — four Maven modules, two interfaces per use case, a translation layer between every pair of adjacent concerns — buys you a core you can test without Spring and swap infrastructure underneath without touching, and that trade is worth making when the business rules are genuinely worth protecting (they outlive the current database, or a second delivery mechanism is coming, or the team is large enough that “what does this class actually depend on” needs to be enforceable rather than remembered). For a small CRUD app with one database and one UI, for its whole lifetime, @Service calling @Repository directly is not a mistake — it’s the appropriately-sized answer, and reaching for four modules and nine interfaces to add one field would be the actual over-engineering.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.