Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4
This commit is contained in:
@@ -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] ------------------------------------------------------------------------
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user