Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4
This commit is contained in:
@@ -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] ------------------------------------------------------------------------
|
||||
@@ -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] ------------------------------------------------------------------------
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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] ------------------------------------------------------------------------
|
||||
@@ -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>
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.hexagonal.persistence;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
interface SpringDataWalletRepository extends JpaRepository<WalletEntity, String> {
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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 {
|
||||
}
|
||||
@@ -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] ------------------------------------------------------------------------
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
+25
@@ -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 {
|
||||
}
|
||||
+67
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user