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
+85
View File
@@ -0,0 +1,85 @@
# hexagonal
Companion project for the article **[Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going
deeper" sections inside the article itself, next to the paragraph each one extends.
This is also the one project in this repo that is its own small Maven reactor rather than a
single self-contained project. That is not a style choice -- it is what makes "the core has no
Spring dependency" something Maven enforces, not a naming convention a reviewer has to remember
to check. Run `mvn -pl core dependency:tree` yourself and compare it to `core/output/01-core-dependency-tree.txt`.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
| H2 | 2.4.240 (the version Boot 4.1.1 manages) |
| Hibernate ORM | 7.4.5.Final |
## Modules
| Module | What it is | Depends on | Spring on its compile classpath? |
|---|---|---|---|
| [`core/`](core) | the domain model, the inbound/outbound ports, `WalletService` | nothing in this reactor | **no** -- `mvn -pl core dependency:tree` has zero Spring artifacts |
| [`persistence-adapter/`](persistence-adapter) | the outbound adapter: `WalletEntity`, `SpringDataWalletRepository`, `JpaWalletRepositoryAdapter` | `core` | yes -- Spring Data JPA, H2 |
| [`web-adapter/`](web-adapter) | the inbound adapter: `WalletController`, `WalletExceptionHandler` | `core` | yes -- Spring MVC |
| [`app/`](app) | the composition root: `HexagonalApplication`, the only `@SpringBootApplication` in this project | `core`, `persistence-adapter`, `web-adapter` | yes -- it wires everything together |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
cd hexagonal
mvn clean install # builds all four modules in order, runs every test
```
To run the real application:
```bash
cd app
mvn spring-boot:run
curl -X POST "http://localhost:8080/wallets/w1/deposit?amountCents=500"
```
(there's no endpoint to create a wallet first -- the H2 schema starts empty; the application
test seeds one directly through `WalletRepository` before calling HTTP, which is itself worth
reading as an example of testing through the port rather than through a seed script.)
## Captured output
| File | What it shows |
|---|---|
| [`core/output/00-core-tests-no-spring.txt`](core/output/00-core-tests-no-spring.txt) | `WalletServiceTest`'s 4 tests, no Spring context anywhere, finishing in milliseconds |
| [`core/output/01-core-dependency-tree.txt`](core/output/01-core-dependency-tree.txt) | the full `mvn dependency:tree` for `hexagonal-core` -- JUnit and AssertJ, nothing else |
| [`persistence-adapter/output/00-jpa-adapter-tests.txt`](persistence-adapter/output/00-jpa-adapter-tests.txt) | `@DataJpaTest` against a real H2 database: a save-then-find round trip and an unknown-id lookup |
| [`web-adapter/output/00-webmvctest-mockmvc.txt`](web-adapter/output/00-webmvctest-mockmvc.txt) | `@WebMvcTest` with the ports mocked: the real JSON a deposit and a rejected withdrawal return |
| [`app/output/00-end-to-end-http.txt`](app/output/00-end-to-end-http.txt) | the full stack over real HTTP: deposit, withdraw, an overdraft rejected with 409, and the balance reloaded straight from the database to cross-check the HTTP response |
## A note for anyone copying this reactor shape
Two things cost real time while building this that are worth stating plainly rather than
leaving a reader to rediscover them:
- Spring Boot 4 split most `@...Test` annotations out of `spring-boot-test-autoconfigure` into
their own small artifacts, each under a *new* package too (`@DataJpaTest` is now
`org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest` in artifact
`spring-boot-data-jpa-test`; `@WebMvcTest` is `org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest`
in `spring-boot-webmvc-test`). `TestRestTemplate` survived as itself but moved to
`org.springframework.boot.resttestclient.TestRestTemplate` in artifact `spring-boot-resttestclient`,
and it is no longer auto-configured on `@SpringBootTest(webEnvironment = RANDOM_PORT)` --
`app`'s test needs an explicit `@AutoConfigureTestRestTemplate`.
- In a reactor where the `@SpringBootApplication` class's package is a *sibling* of the
persistence adapter's package rather than an ancestor, Spring Data JPA's repository scan and
JPA's entity scan both default to the main class's own package -- not `scanBasePackages`.
`HexagonalApplication` needs an explicit `@EnableJpaRepositories(basePackages = "...")` and
`@EntityScan(basePackages = "...")` pointing at `com.ankurm.hexagonal.persistence`, or the
adapter bean fails to wire with no hint that scanning is the cause.
## Licence
MIT -- see the root [LICENSE](../LICENSE).
@@ -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);
}
}
@@ -0,0 +1,43 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-core ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/core/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 7 source files with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ hexagonal-core ---
[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.WalletServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.164 s -- in com.ankurm.hexagonal.WalletServiceTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.794 s
[INFO] Finished at: 2026-10-04T01:53:57+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,26 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- dependency:3.7.0:tree (default-cli) @ hexagonal-core ---
[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
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.206 s
[INFO] Finished at: 2026-10-04T01:54:04+05:30
[INFO] ------------------------------------------------------------------------
+42
View File
@@ -0,0 +1,42 @@
<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-core</artifactId>
<packaging>jar</packaging>
<!-- No Spring dependency anywhere in this file, not even spring-boot-starter-test. That's
not a style choice being asked of contributors; it's what lets this one module's tests
run in milliseconds with no application context - see output/00-core-tests-no-spring.txt -->
<!-- Versions for both come from the parent's imported spring-boot-dependencies BOM (the
same BOM every other module in this repo uses), which is a version source, not a
runtime dependency - importing it in dependencyManagement pulls in nothing until a
module actually declares one of the artifacts it manages. -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,36 @@
package com.ankurm.hexagonal;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WalletRepository;
import com.ankurm.hexagonal.ports.WithdrawMoney;
/**
* The application layer: it implements both inbound ports and depends on nothing but the one
* outbound port. No {@code @Service}, no {@code @Autowired} -- this class has never heard of
* Spring. Wiring a real {@link WalletRepository} implementation into its constructor is the
* app module's job, not this class's.
*/
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);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.hexagonal.domain;
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String walletId, long balanceCents, long requestedCents) {
super("wallet " + walletId + " has " + balanceCents + " cents, cannot withdraw "
+ requestedCents + " cents");
}
}
@@ -0,0 +1,43 @@
package com.ankurm.hexagonal.domain;
/**
* The whole domain model for this demo. Nothing in this class, or anywhere else in this
* module, imports a single Spring class -- there is no Spring dependency in this module's
* pom.xml for it to import. A wallet knows how to deposit and withdraw money, and refuses an
* overdraft; it has no idea it will eventually be persisted or exposed over HTTP.
*/
public final class Wallet {
private final String id;
private long balanceCents;
public Wallet(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
public String id() {
return id;
}
public long balanceCents() {
return 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;
}
}
@@ -0,0 +1,8 @@
package com.ankurm.hexagonal.domain;
public class WalletNotFoundException extends RuntimeException {
public WalletNotFoundException(String walletId) {
super("no wallet with id " + walletId);
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.ports;
import com.ankurm.hexagonal.domain.Wallet;
/** An inbound port: the one way into the core for this use case. The web-adapter module
* depends on this interface, never on the concrete service behind it. */
public interface DepositMoney {
Wallet deposit(String walletId, long amountCents);
}
@@ -0,0 +1,18 @@
package com.ankurm.hexagonal.ports;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
/**
* An outbound port: the core declares the shape of persistence it needs, without knowing or
* caring whether the real answer is a JPA table, a key-value store, or (as in the core's own
* tests) a HashMap. The persistence-adapter module is the only place that implements this
* interface for real.
*/
public interface WalletRepository {
Optional<Wallet> findById(String walletId);
Wallet save(Wallet wallet);
}
@@ -0,0 +1,8 @@
package com.ankurm.hexagonal.ports;
import com.ankurm.hexagonal.domain.Wallet;
public interface WithdrawMoney {
Wallet withdraw(String walletId, long amountCents);
}
@@ -0,0 +1,31 @@
package com.ankurm.hexagonal;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
/** A hand-written test double, not a mocking-framework one -- there is no mocking library on
* this module's classpath either. This is the entire cost of testing the core in isolation:
* one small class implementing the one interface the application layer actually depends on. */
class InMemoryWalletRepository implements WalletRepository {
private final Map<String, Wallet> wallets = new HashMap<>();
void seed(Wallet wallet) {
wallets.put(wallet.id(), wallet);
}
@Override
public Optional<Wallet> findById(String walletId) {
return Optional.ofNullable(wallets.get(walletId));
}
@Override
public Wallet save(Wallet wallet) {
wallets.put(wallet.id(), wallet);
return wallet;
}
}
@@ -0,0 +1,59 @@
package com.ankurm.hexagonal;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* No {@code @SpringBootTest}, no {@code @ExtendWith(SpringExtension.class)} -- just JUnit and
* a hand-written fake. There is no application context to start, which is the whole point:
* run {@code mvn -pl hexagonal/core test} and compare the elapsed time with any of this
* repo's other modules that boot real Spring.
*/
class WalletServiceTest {
private final InMemoryWalletRepository wallets = new InMemoryWalletRepository();
private final WalletService service = new WalletService(wallets);
@Test
void depositIncreasesBalance() {
wallets.seed(new Wallet("w1", 1000));
Wallet result = service.deposit("w1", 500);
assertThat(result.balanceCents()).isEqualTo(1500);
}
@Test
void withdrawDecreasesBalance() {
wallets.seed(new Wallet("w1", 1000));
Wallet result = service.withdraw("w1", 400);
assertThat(result.balanceCents()).isEqualTo(600);
}
@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);
}
@Test
void depositToAnUnknownWalletFailsWithoutTouchingTheRepository() {
assertThatThrownBy(() -> service.deposit("ghost", 100))
.isInstanceOf(WalletNotFoundException.class)
.hasMessage("no wallet with id ghost");
}
}
@@ -0,0 +1,93 @@
[INFO] Scanning for projects...
[INFO]
[INFO] --------------< com.ankurm:hexagonal-persistence-adapter >--------------
[INFO] Building hexagonal-persistence-adapter 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-persistence-adapter ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/persistence-adapter/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-persistence-adapter ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/persistence-adapter/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-persistence-adapter ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-persistence-adapter ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/persistence-adapter/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-persistence-adapter ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ hexagonal-persistence-adapter ---
[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.persistence.JpaWalletRepositoryAdapterTest
01:54:15.288 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.persistence.JpaWalletRepositoryAdapterTest]: JpaWalletRepositoryAdapterTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:54:15.428 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.persistence.TestApplication for test class com.ankurm.hexagonal.persistence.JpaWalletRepositoryAdapterTest
01:54:15.430 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.persistence.JpaWalletRepositoryAdapterTest]: JpaWalletRepositoryAdapterTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:54:15.445 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.persistence.TestApplication for test class com.ankurm.hexagonal.persistence.JpaWalletRepositoryAdapterTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-04T01:54:15.739+05:30 INFO 13747 --- [ main] c.a.h.p.JpaWalletRepositoryAdapterTest : Starting JpaWalletRepositoryAdapterTest using Java 25.0.4.1 with PID 13747 (started by root in /home/claude/spring-boot-demo/hexagonal/persistence-adapter)
2026-10-04T01:54:15.740+05:30 INFO 13747 --- [ main] c.a.h.p.JpaWalletRepositoryAdapterTest : No active profile set, falling back to 1 default profile: "default"
2026-10-04T01:54:16.051+05:30 INFO 13747 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-04T01:54:16.089+05:30 INFO 13747 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 31 ms. Found 1 JPA repository interface.
2026-10-04T01:54:16.151+05:30 INFO 13747 --- [ main] beddedDataSourceBeanFactoryPostProcessor : Replacing 'dataSource' DataSource bean with embedded version
2026-10-04T01:54:16.314+05:30 INFO 13747 --- [ main] o.s.j.d.e.EmbeddedDatabaseFactory : Starting embedded database: url='jdbc:h2:mem:586d3997-ff00-4f61-9011-c28e820e265a;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
2026-10-04T01:54:16.618+05:30 INFO 13747 --- [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-04T01:54:16.662+05:30 INFO 13747 --- [ main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-10-04T01:54:16.967+05:30 INFO 13747 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-04T01:54:17.033+05:30 INFO 13747 --- [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:586d3997-ff00-4f61-9011-c28e820e265a]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 586D3997-FF00-4F61-9011-C28E820E265A/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:54:17.583+05:30 INFO 13747 --- [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
Hibernate: drop table if exists wallets cascade
Hibernate: create table wallets (balance_cents bigint not null, id varchar(255) not null, primary key (id))
2026-10-04T01:54:17.609+05:30 INFO 13747 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-04T01:54:17.712+05:30 INFO 13747 --- [ main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-10-04T01:54:17.819+05:30 INFO 13747 --- [ main] c.a.h.p.JpaWalletRepositoryAdapterTest : Started JpaWalletRepositoryAdapterTest in 2.324 seconds (process running for 3.492)
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
Hibernate: select we1_0.id,we1_0.balance_cents from wallets we1_0 where we1_0.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
Hibernate: select we1_0.id,we1_0.balance_cents from wallets we1_0 where we1_0.id=?
findById("ghost").isPresent(): false
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.284 s -- in com.ankurm.hexagonal.persistence.JpaWalletRepositoryAdapterTest
2026-10-04T01:54:18.443+05:30 INFO 13747 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
Hibernate: drop table if exists wallets cascade
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 6.595 s
[INFO] Finished at: 2026-10-04T01:54:18+05:30
[INFO] ------------------------------------------------------------------------
+47
View File
@@ -0,0 +1,47 @@
<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-persistence-adapter</artifactId>
<packaging>jar</packaging>
<!-- This is the only module allowed to know a database exists. It depends on hexagonal-core
to implement WalletRepository, and on Spring Data JPA to do so for real. -->
<dependencies>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-core</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot 4 split @DataJpaTest out of spring-boot-test-autoconfigure into its own
artifact, alongside the rest of the data/jpa/web slices. Confirmed by unzipping
spring-boot-test-autoconfigure-4.1.1.jar (no "jpa" class anywhere in it) and then
spring-boot-data-jpa-test-4.1.1.jar, which has
org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest - a new package too,
not the old org.springframework.boot.test.autoconfigure.orm.jpa. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,38 @@
package com.ankurm.hexagonal.persistence;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
import org.springframework.stereotype.Repository;
/**
* The real implementation of the core's outbound port -- the only class in this whole
* application that translates between the domain's {@link Wallet} and JPA's
* {@link WalletEntity}. If the persistence technology changed to MongoDB tomorrow, this is
* the only class that would need to change; {@code WalletService} would not even recompile
* differently, because it has never seen this class.
*/
@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;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.hexagonal.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
interface SpringDataWalletRepository extends JpaRepository<WalletEntity, String> {
}
@@ -0,0 +1,39 @@
package com.ankurm.hexagonal.persistence;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** The JPA shape of a wallet. Deliberately a separate class from the domain's
* {@code Wallet} -- this class exists to satisfy Hibernate (a no-arg constructor, mutable
* fields), and the domain class exists to satisfy the business rules. Mixing the two would
* mean every persistence concern leaks into the one class the core's tests are supposed to
* be free of. */
@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;
}
public String getId() {
return id;
}
public long getBalanceCents() {
return balanceCents;
}
public void setBalanceCents(long balanceCents) {
this.balanceCents = balanceCents;
}
}
@@ -0,0 +1,40 @@
package com.ankurm.hexagonal.persistence;
import com.ankurm.hexagonal.domain.Wallet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
/** A real H2 database, a real JPA EntityManager, and the adapter under test -- proving the
* port's contract against something that actually persists, not a guess at what Hibernate
* does. */
@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);
}
@Test
void findingAnUnknownIdReturnsEmpty() {
JpaWalletRepositoryAdapter adapter = new JpaWalletRepositoryAdapter(jpa);
boolean present = adapter.findById("ghost").isPresent();
System.out.println("findById(\"ghost\").isPresent(): " + present);
assertThat(present).isFalse();
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.persistence;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Test-only bootstrap class so @DataJpaTest has a @SpringBootConfiguration to find in this
* package. This module has no real @SpringBootApplication of its own -- that belongs to the
* app module, which is the only place all the adapters actually get wired together. */
@SpringBootApplication
class TestApplication {
}
+57
View File
@@ -0,0 +1,57 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- This article's one exception to the rest of this repo: a small reactor of its own, not
a single self-contained project. The module boundary below is what makes "the core has
no Spring dependency" a thing Maven enforces, not a naming convention a reviewer has to
remember to check. -->
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-parent</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
<modules>
<module>core</module>
<module>persistence-adapter</module>
<module>web-adapter</module>
<module>app</module>
</modules>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>4.1.1</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- spring-boot-starter-parent normally sets -parameters on the compiler for you; this
reactor doesn't inherit from it (see the note above), so it has to be set explicitly.
Without it, @RequestParam long amountCents with no explicit name fails at request time,
not at compile time: see web-adapter's test output for the exact exception this throws
when it's missing. -->
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
@@ -0,0 +1,67 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------< com.ankurm:hexagonal-web-adapter >------------------
[INFO] Building hexagonal-web-adapter 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-web-adapter ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/web-adapter/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-web-adapter ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/web-adapter/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-web-adapter ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-web-adapter ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/web-adapter/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-web-adapter ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ hexagonal-web-adapter ---
[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.web.WalletControllerTest
01:54:59.008 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.web.WalletControllerTest]: WalletControllerTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:54:59.199 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.web.TestApplication for test class com.ankurm.hexagonal.web.WalletControllerTest
01:54:59.205 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.web.WalletControllerTest]: WalletControllerTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:54:59.221 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.web.TestApplication for test class com.ankurm.hexagonal.web.WalletControllerTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-04T01:54:59.650+05:30 INFO 13988 --- [ main] c.a.hexagonal.web.WalletControllerTest : Starting WalletControllerTest using Java 25.0.4.1 with PID 13988 (started by root in /home/claude/spring-boot-demo/hexagonal/web-adapter)
2026-10-04T01:54:59.651+05:30 INFO 13988 --- [ main] c.a.hexagonal.web.WalletControllerTest : No active profile set, falling back to 1 default profile: "default"
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:01.256+05:30 INFO 13988 --- [ main] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2026-10-04T01:55:01.257+05:30 INFO 13988 --- [ main] o.s.t.web.servlet.TestDispatcherServlet : Initializing Servlet ''
2026-10-04T01:55:01.259+05:30 INFO 13988 --- [ main] o.s.t.web.servlet.TestDispatcherServlet : Completed initialization in 2 ms
2026-10-04T01:55:01.291+05:30 INFO 13988 --- [ main] c.a.hexagonal.web.WalletControllerTest : Started WalletControllerTest in 1.991 seconds (process running for 3.175)
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}
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.712 s -- in com.ankurm.hexagonal.web.WalletControllerTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 5.734 s
[INFO] Finished at: 2026-10-04T01:55:01+05:30
[INFO] ------------------------------------------------------------------------
+39
View File
@@ -0,0 +1,39 @@
<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-web-adapter</artifactId>
<packaging>jar</packaging>
<!-- This module knows about HTTP; it does not know about JPA, and its test below proves
that by never starting the persistence-adapter module at all. -->
<dependencies>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-core</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Same split as hexagonal-persistence-adapter's spring-boot-data-jpa-test: @WebMvcTest
now lives in its own artifact (org.springframework.boot.webmvc.test.autoconfigure),
not inside spring-boot-test-autoconfigure where Boot 3 had it. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,43 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WithdrawMoney;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The inbound adapter. It depends on the two inbound ports -- interfaces -- never on
* {@code WalletService} itself, and it has no idea whether the real implementation behind
* those interfaces talks to a database, a file, or (as in this class's own test) a
* hand-written fake.
*/
@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());
}
@PostMapping("/wallets/{id}/withdraw")
public BalanceResponse withdraw(@PathVariable("id") String id, @RequestParam long amountCents) {
Wallet wallet = withdrawMoney.withdraw(id, amountCents);
return new BalanceResponse(wallet.id(), wallet.balanceCents());
}
public record BalanceResponse(String walletId, long balanceCents) {
}
}
@@ -0,0 +1,25 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Translating a domain exception into an HTTP status is this adapter's job -- the domain
* exceptions themselves (see hexagonal-core) have never heard of HTTP status codes. */
@RestControllerAdvice
public class WalletExceptionHandler {
@ExceptionHandler(InsufficientFundsException.class)
public ProblemDetail onInsufficientFunds(InsufficientFundsException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(WalletNotFoundException.class)
public ProblemDetail onWalletNotFound(WalletNotFoundException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, exception.getMessage());
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.web;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Test-only bootstrap class so @WebMvcTest has a @SpringBootConfiguration to find in this
* package. Same reasoning as persistence-adapter's TestApplication: this module has no real
* @SpringBootApplication of its own -- that belongs to the app module. */
@SpringBootApplication
class TestApplication {
}
@@ -0,0 +1,67 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WithdrawMoney;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* {@code @WebMvcTest} loads only the web layer -- no persistence-adapter module on this
* test's classpath at all, and {@link DepositMoney} / {@link WithdrawMoney} are replaced with
* Mockito doubles rather than the real {@code WalletService}. This is what "testing the
* adapter against the port, not the implementation" looks like from the web side.
*/
@WebMvcTest(WalletController.class)
class WalletControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
DepositMoney depositMoney;
@MockitoBean
WithdrawMoney withdrawMoney;
@Test
void depositReturnsTheNewBalance() throws Exception {
when(depositMoney.deposit(anyString(), anyLong())).thenReturn(new Wallet("w1", 1500));
MvcResult result = mockMvc.perform(post("/wallets/w1/deposit").param("amountCents", "500"))
.andExpect(status().isOk())
.andExpect(content().json("{\"walletId\":\"w1\",\"balanceCents\":1500}"))
.andReturn();
System.out.println("POST /wallets/w1/deposit?amountCents=500 -> "
+ result.getResponse().getStatus() + " " + result.getResponse().getContentAsString());
}
@Test
void withdrawBeyondBalanceReturns409WithAProblemDetailBody() throws Exception {
when(withdrawMoney.withdraw(anyString(), anyLong()))
.thenThrow(new InsufficientFundsException("w1", 1000, 1001));
MvcResult result = 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\"}"))
.andReturn();
System.out.println("POST /wallets/w1/withdraw?amountCents=1001 -> "
+ result.getResponse().getStatus() + " " + result.getResponse().getContentAsString());
}
}