Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4

This commit is contained in:
Claude
2026-10-03 20:28:16 +00:00
parent ae681f85c4
commit fd6d0fa1e7
33 changed files with 1297 additions and 2 deletions
@@ -0,0 +1,39 @@
package com.ankurm.hexagonal.app;
import com.ankurm.hexagonal.WalletService;
import com.ankurm.hexagonal.ports.WalletRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* The composition root. Everything above this point in the article -- the domain, the ports,
* both adapters -- is wired together in exactly one place, and this is it.
* {@code WalletService} carries no {@code @Service} annotation, so without the {@code @Bean}
* method below, Spring would never construct it; the wiring is explicit, not discovered.
*
* <p>{@code scanBasePackages} covers {@code @Component}-style scanning, but Spring Data JPA's
* repository scan and JPA's entity scan each use their own default -- the package of this
* class, {@code com.ankurm.hexagonal.app} -- not {@code scanBasePackages}. That default is a
* sibling of {@code com.ankurm.hexagonal.persistence}, not an ancestor of it, so without the
* two annotations below Spring Data JPA silently finds zero repositories and the
* {@code JpaWalletRepositoryAdapter} bean fails to wire: see HexagonalApplicationTest's build
* log for the "No qualifying bean of type SpringDataWalletRepository" failure this caused.
*/
@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);
}
}
@@ -0,0 +1,3 @@
spring.application.name=hexagonal
spring.datasource.url=jdbc:h2:mem:hexagonaldb;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=update
@@ -0,0 +1,80 @@
package com.ankurm.hexagonal.app;
import java.util.UUID;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
import com.ankurm.hexagonal.web.WalletController.BalanceResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The whole stack, really wired, really running: a real embedded web server, a real H2
* database through the real persistence adapter, and HTTP requests that cross every
* boundary this article draws. If any of the wiring in {@link HexagonalApplication} were
* wrong, this is the test that would fail, not a unit test of any single class.
*
* <p>{@code @AutoConfigureTestRestTemplate} is required explicitly in Spring Boot 4 -- unlike
* Boot 3, a {@code @SpringBootTest(webEnvironment = RANDOM_PORT)} no longer auto-configures a
* {@code TestRestTemplate} bean on its own.
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class HexagonalApplicationTest {
@Autowired
TestRestTemplate rest;
@Autowired
WalletRepository walletRepository;
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
() -> "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
}
@Test
void depositThenWithdrawOverHttpEndsAtTheRightBalanceThroughTheRealDatabase() {
walletRepository.save(new Wallet("w1", 1000));
ResponseEntity<BalanceResponse> afterDeposit = rest.postForEntity(
"/wallets/w1/deposit?amountCents=500", null, BalanceResponse.class);
System.out.println("After deposit: " + afterDeposit.getStatusCode() + " " + afterDeposit.getBody());
ResponseEntity<BalanceResponse> afterWithdraw = rest.postForEntity(
"/wallets/w1/withdraw?amountCents=200", null, BalanceResponse.class);
System.out.println("After withdraw: " + afterWithdraw.getStatusCode() + " " + afterWithdraw.getBody());
assertThat(afterDeposit.getBody().balanceCents()).isEqualTo(1500);
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();
System.out.println("Balance reloaded directly from WalletRepository: " + persisted);
assertThat(persisted).isEqualTo(1300);
}
@Test
void withdrawingTooMuchReturns409() {
walletRepository.save(new Wallet("w2", 100));
ResponseEntity<String> response = rest.postForEntity(
"/wallets/w2/withdraw?amountCents=101", null, String.class);
System.out.println("Overdraft attempt: " + response.getStatusCode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}