diff --git a/README.md b/README.md index 1909ac6..9cdd835 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ files. | Directory | Article | What it demonstrates | |---|---|---| +| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way | | [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang | | [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache | | [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation | @@ -39,9 +40,11 @@ files. | [`i18n/`](i18n) | [Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/) | English, Marathi and Hindi message bundles, `Accept-Language` vs a cookie resolver, apostrophes and Devanagari digits in `MessageFormat`, the JVM default locale as a hidden fallback, localized validation messages and `ProblemDetail` titles | | [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic | -`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` are the exception: they have **no `docs/` folder**. Their deeper material lives in +`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache` and `hexagonal` are the exception: they have **no `docs/` folder**. Their deeper material lives in collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` -directory instead of `docs/output/`. +directory instead of `docs/output/` (`hexagonal` is also the one multi-module reactor in this +repo, so its output sits one level deeper still, under each module: `core/output/`, +`persistence-adapter/output/`, and so on). Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/hexagonal/README.md b/hexagonal/README.md new file mode 100644 index 0000000..3253eb2 --- /dev/null +++ b/hexagonal/README.md @@ -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). diff --git a/hexagonal/app/output/00-end-to-end-http.txt b/hexagonal/app/output/00-end-to-end-http.txt new file mode 100644 index 0000000..3b821ab --- /dev/null +++ b/hexagonal/app/output/00-end-to-end-http.txt @@ -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] ------------------------------------------------------------------------ diff --git a/hexagonal/app/pom.xml b/hexagonal/app/pom.xml new file mode 100644 index 0000000..52ab1b1 --- /dev/null +++ b/hexagonal/app/pom.xml @@ -0,0 +1,73 @@ + + 4.0.0 + + + com.ankurm + hexagonal-parent + 1.0 + + + hexagonal-app + jar + + + + + com.ankurm + hexagonal-core + 1.0 + + + com.ankurm + hexagonal-persistence-adapter + 1.0 + + + com.ankurm + hexagonal-web-adapter + 1.0 + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-resttestclient + test + + + + org.springframework.boot + spring-boot-restclient + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + diff --git a/hexagonal/app/src/main/java/com/ankurm/hexagonal/app/HexagonalApplication.java b/hexagonal/app/src/main/java/com/ankurm/hexagonal/app/HexagonalApplication.java new file mode 100644 index 0000000..ec13a71 --- /dev/null +++ b/hexagonal/app/src/main/java/com/ankurm/hexagonal/app/HexagonalApplication.java @@ -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. + * + *

{@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); + } +} diff --git a/hexagonal/app/src/main/resources/application.properties b/hexagonal/app/src/main/resources/application.properties new file mode 100644 index 0000000..38d508b --- /dev/null +++ b/hexagonal/app/src/main/resources/application.properties @@ -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 diff --git a/hexagonal/app/src/test/java/com/ankurm/hexagonal/app/HexagonalApplicationTest.java b/hexagonal/app/src/test/java/com/ankurm/hexagonal/app/HexagonalApplicationTest.java new file mode 100644 index 0000000..4ebcb8b --- /dev/null +++ b/hexagonal/app/src/test/java/com/ankurm/hexagonal/app/HexagonalApplicationTest.java @@ -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. + * + *

{@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 afterDeposit = rest.postForEntity( + "/wallets/w1/deposit?amountCents=500", null, BalanceResponse.class); + System.out.println("After deposit: " + afterDeposit.getStatusCode() + " " + afterDeposit.getBody()); + + ResponseEntity 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 response = rest.postForEntity( + "/wallets/w2/withdraw?amountCents=101", null, String.class); + System.out.println("Overdraft attempt: " + response.getStatusCode()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } +} diff --git a/hexagonal/core/output/00-core-tests-no-spring.txt b/hexagonal/core/output/00-core-tests-no-spring.txt new file mode 100644 index 0000000..5c95e79 --- /dev/null +++ b/hexagonal/core/output/00-core-tests-no-spring.txt @@ -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] ------------------------------------------------------------------------ diff --git a/hexagonal/core/output/01-core-dependency-tree.txt b/hexagonal/core/output/01-core-dependency-tree.txt new file mode 100644 index 0000000..9b01c07 --- /dev/null +++ b/hexagonal/core/output/01-core-dependency-tree.txt @@ -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] ------------------------------------------------------------------------ diff --git a/hexagonal/core/pom.xml b/hexagonal/core/pom.xml new file mode 100644 index 0000000..3a94871 --- /dev/null +++ b/hexagonal/core/pom.xml @@ -0,0 +1,42 @@ + + 4.0.0 + + + com.ankurm + hexagonal-parent + 1.0 + + + hexagonal-core + jar + + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/WalletService.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/WalletService.java new file mode 100644 index 0000000..23b738f --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/WalletService.java @@ -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); + } +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/InsufficientFundsException.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/InsufficientFundsException.java new file mode 100644 index 0000000..134a1eb --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/InsufficientFundsException.java @@ -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"); + } +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/Wallet.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/Wallet.java new file mode 100644 index 0000000..c3be45e --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/Wallet.java @@ -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; + } +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/WalletNotFoundException.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/WalletNotFoundException.java new file mode 100644 index 0000000..dab3d35 --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/domain/WalletNotFoundException.java @@ -0,0 +1,8 @@ +package com.ankurm.hexagonal.domain; + +public class WalletNotFoundException extends RuntimeException { + + public WalletNotFoundException(String walletId) { + super("no wallet with id " + walletId); + } +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/DepositMoney.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/DepositMoney.java new file mode 100644 index 0000000..5a5b706 --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/DepositMoney.java @@ -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); +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WalletRepository.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WalletRepository.java new file mode 100644 index 0000000..bf0f1d2 --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WalletRepository.java @@ -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 findById(String walletId); + + Wallet save(Wallet wallet); +} diff --git a/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WithdrawMoney.java b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WithdrawMoney.java new file mode 100644 index 0000000..669c26f --- /dev/null +++ b/hexagonal/core/src/main/java/com/ankurm/hexagonal/ports/WithdrawMoney.java @@ -0,0 +1,8 @@ +package com.ankurm.hexagonal.ports; + +import com.ankurm.hexagonal.domain.Wallet; + +public interface WithdrawMoney { + + Wallet withdraw(String walletId, long amountCents); +} diff --git a/hexagonal/core/src/test/java/com/ankurm/hexagonal/InMemoryWalletRepository.java b/hexagonal/core/src/test/java/com/ankurm/hexagonal/InMemoryWalletRepository.java new file mode 100644 index 0000000..52f5664 --- /dev/null +++ b/hexagonal/core/src/test/java/com/ankurm/hexagonal/InMemoryWalletRepository.java @@ -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 wallets = new HashMap<>(); + + void seed(Wallet wallet) { + wallets.put(wallet.id(), wallet); + } + + @Override + public Optional findById(String walletId) { + return Optional.ofNullable(wallets.get(walletId)); + } + + @Override + public Wallet save(Wallet wallet) { + wallets.put(wallet.id(), wallet); + return wallet; + } +} diff --git a/hexagonal/core/src/test/java/com/ankurm/hexagonal/WalletServiceTest.java b/hexagonal/core/src/test/java/com/ankurm/hexagonal/WalletServiceTest.java new file mode 100644 index 0000000..9d6429d --- /dev/null +++ b/hexagonal/core/src/test/java/com/ankurm/hexagonal/WalletServiceTest.java @@ -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"); + } +} diff --git a/hexagonal/persistence-adapter/output/00-jpa-adapter-tests.txt b/hexagonal/persistence-adapter/output/00-jpa-adapter-tests.txt new file mode 100644 index 0000000..932a265 --- /dev/null +++ b/hexagonal/persistence-adapter/output/00-jpa-adapter-tests.txt @@ -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] ------------------------------------------------------------------------ diff --git a/hexagonal/persistence-adapter/pom.xml b/hexagonal/persistence-adapter/pom.xml new file mode 100644 index 0000000..3b7ca37 --- /dev/null +++ b/hexagonal/persistence-adapter/pom.xml @@ -0,0 +1,47 @@ + + 4.0.0 + + + com.ankurm + hexagonal-parent + 1.0 + + + hexagonal-persistence-adapter + jar + + + + + com.ankurm + hexagonal-core + 1.0 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-data-jpa-test + test + + + diff --git a/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.java b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.java new file mode 100644 index 0000000..30aac44 --- /dev/null +++ b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.java @@ -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 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; + } +} diff --git a/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/SpringDataWalletRepository.java b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/SpringDataWalletRepository.java new file mode 100644 index 0000000..ab05180 --- /dev/null +++ b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/SpringDataWalletRepository.java @@ -0,0 +1,6 @@ +package com.ankurm.hexagonal.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface SpringDataWalletRepository extends JpaRepository { +} diff --git a/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/WalletEntity.java b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/WalletEntity.java new file mode 100644 index 0000000..887d4b6 --- /dev/null +++ b/hexagonal/persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/WalletEntity.java @@ -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; + } +} diff --git a/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapterTest.java b/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapterTest.java new file mode 100644 index 0000000..1607422 --- /dev/null +++ b/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapterTest.java @@ -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(); + } +} diff --git a/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/TestApplication.java b/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/TestApplication.java new file mode 100644 index 0000000..0027b53 --- /dev/null +++ b/hexagonal/persistence-adapter/src/test/java/com/ankurm/hexagonal/persistence/TestApplication.java @@ -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 { +} diff --git a/hexagonal/pom.xml b/hexagonal/pom.xml new file mode 100644 index 0000000..a6f15df --- /dev/null +++ b/hexagonal/pom.xml @@ -0,0 +1,57 @@ + + 4.0.0 + + + com.ankurm + hexagonal-parent + 1.0 + pom + + + core + persistence-adapter + web-adapter + app + + + + 25 + 25 + UTF-8 + 4.1.1 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + + + diff --git a/hexagonal/web-adapter/output/00-webmvctest-mockmvc.txt b/hexagonal/web-adapter/output/00-webmvctest-mockmvc.txt new file mode 100644 index 0000000..a95297d --- /dev/null +++ b/hexagonal/web-adapter/output/00-webmvctest-mockmvc.txt @@ -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] ------------------------------------------------------------------------ diff --git a/hexagonal/web-adapter/pom.xml b/hexagonal/web-adapter/pom.xml new file mode 100644 index 0000000..215792a --- /dev/null +++ b/hexagonal/web-adapter/pom.xml @@ -0,0 +1,39 @@ + + 4.0.0 + + + com.ankurm + hexagonal-parent + 1.0 + + + hexagonal-web-adapter + jar + + + + + com.ankurm + hexagonal-core + 1.0 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-webmvc-test + test + + + diff --git a/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletController.java b/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletController.java new file mode 100644 index 0000000..172f145 --- /dev/null +++ b/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletController.java @@ -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) { + } +} diff --git a/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletExceptionHandler.java b/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletExceptionHandler.java new file mode 100644 index 0000000..314e5db --- /dev/null +++ b/hexagonal/web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletExceptionHandler.java @@ -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()); + } +} diff --git a/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/TestApplication.java b/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/TestApplication.java new file mode 100644 index 0000000..5f3e13b --- /dev/null +++ b/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/TestApplication.java @@ -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 { +} diff --git a/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/WalletControllerTest.java b/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/WalletControllerTest.java new file mode 100644 index 0000000..0dc8c96 --- /dev/null +++ b/hexagonal/web-adapter/src/test/java/com/ankurm/hexagonal/web/WalletControllerTest.java @@ -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()); + } +}