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,98 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ----------------------< com.ankurm:hexagonal-app >----------------------
[INFO] Building hexagonal-app 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-app ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/app/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-app ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-app ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-app ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/app/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-app ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ hexagonal-app ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.hexagonal.app.HexagonalApplicationTest
01:55:11.470 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.app.HexagonalApplicationTest]: HexagonalApplicationTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:55:11.578 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.app.HexagonalApplication for test class com.ankurm.hexagonal.app.HexagonalApplicationTest
01:55:11.662 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.app.HexagonalApplicationTest]: HexagonalApplicationTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:55:11.666 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.app.HexagonalApplication for test class com.ankurm.hexagonal.app.HexagonalApplicationTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-04T01:55:12.046+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : Starting HexagonalApplicationTest using Java 25.0.4.1 with PID 14110 (started by root in /home/claude/spring-boot-demo/hexagonal/app)
2026-10-04T01:55:12.052+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : No active profile set, falling back to 1 default profile: "default"
2026-10-04T01:55:12.587+05:30 INFO 14110 --- [hexagonal] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-04T01:55:12.628+05:30 INFO 14110 --- [hexagonal] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 30 ms. Found 1 JPA repository interface.
2026-10-04T01:55:13.334+05:30 INFO 14110 --- [hexagonal] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 0 (http)
2026-10-04T01:55:13.349+05:30 INFO 14110 --- [hexagonal] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-10-04T01:55:13.351+05:30 INFO 14110 --- [hexagonal] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-10-04T01:55:13.393+05:30 INFO 14110 --- [hexagonal] [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1302 ms
2026-10-04T01:55:13.615+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-04T01:55:13.651+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-10-04T01:55:13.939+05:30 INFO 14110 --- [hexagonal] [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-04T01:55:13.962+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-10-04T01:55:14.080+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:04350710-e63b-41db-99c0-21493685b159 user=SA
2026-10-04T01:55:14.081+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-10-04T01:55:14.125+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:04350710-e63b-41db-99c0-21493685b159]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 04350710-E63B-41DB-99C0-21493685B159/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-04T01:55:14.798+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-04T01:55:14.854+05:30 INFO 14110 --- [hexagonal] [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-04T01:55:14.951+05:30 INFO 14110 --- [hexagonal] [ main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-10-04T01:55:15.106+05:30 WARN 14110 --- [hexagonal] [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2026-10-04T01:55:15.580+05:30 INFO 14110 --- [hexagonal] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 44067 (http) with context path '/'
2026-10-04T01:55:15.588+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : Started HexagonalApplicationTest in 3.859 seconds (process running for 4.989)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-10-04T01:55:16.239+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-10-04T01:55:16.240+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-10-04T01:55:16.241+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
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
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.208 s -- in com.ankurm.hexagonal.app.HexagonalApplicationTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 8.913 s
[INFO] Finished at: 2026-10-04T01:55:16+05:30
[INFO] ------------------------------------------------------------------------
+73
View File
@@ -0,0 +1,73 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>hexagonal-app</artifactId>
<packaging>jar</packaging>
<!-- The only module that depends on both adapters, and the only place @SpringBootApplication
appears in this whole article. Wiring WalletService into both ports' beans happens here,
in plain @Bean methods, because the core itself carries no component-scanning annotation
for Spring to find. -->
<dependencies>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-core</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-persistence-adapter</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-web-adapter</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- TestRestTemplate itself survived Boot 4 (it's deprecated in favour of RestTestClient,
not removed), but it moved out of spring-boot-test into its own artifact, package
org.springframework.boot.resttestclient - confirmed by unzipping
spring-boot-resttestclient-4.1.1.jar, which is where TestRestTemplate.class and its
TestRestTemplateTestAutoConfiguration now live. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-resttestclient</artifactId>
<scope>test</scope>
</dependency>
<!-- TestRestTemplateTestAutoConfiguration's @ConditionalOnMissingBean reflectively
introspects a RestTemplateBuilder-returning @Bean method, so RestTemplateBuilder
itself (the production-side builder, confirmed in spring-boot-restclient-4.1.1.jar)
has to be resolvable even though nothing in this module calls it directly. Without
this, context startup fails with NoClassDefFoundError before a single test runs. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-restclient</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}