Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4
This commit is contained in:
@@ -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