Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4

This commit is contained in:
Claude
2026-10-03 20:28:16 +00:00
parent ae681f85c4
commit fd6d0fa1e7
33 changed files with 1297 additions and 2 deletions
@@ -0,0 +1,43 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WithdrawMoney;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The inbound adapter. It depends on the two inbound ports -- interfaces -- never on
* {@code WalletService} itself, and it has no idea whether the real implementation behind
* those interfaces talks to a database, a file, or (as in this class's own test) a
* hand-written fake.
*/
@RestController
public class WalletController {
private final DepositMoney depositMoney;
private final WithdrawMoney withdrawMoney;
public WalletController(DepositMoney depositMoney, WithdrawMoney withdrawMoney) {
this.depositMoney = depositMoney;
this.withdrawMoney = withdrawMoney;
}
@PostMapping("/wallets/{id}/deposit")
public BalanceResponse deposit(@PathVariable("id") String id, @RequestParam long amountCents) {
Wallet wallet = depositMoney.deposit(id, amountCents);
return new BalanceResponse(wallet.id(), wallet.balanceCents());
}
@PostMapping("/wallets/{id}/withdraw")
public BalanceResponse withdraw(@PathVariable("id") String id, @RequestParam long amountCents) {
Wallet wallet = withdrawMoney.withdraw(id, amountCents);
return new BalanceResponse(wallet.id(), wallet.balanceCents());
}
public record BalanceResponse(String walletId, long balanceCents) {
}
}
@@ -0,0 +1,25 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Translating a domain exception into an HTTP status is this adapter's job -- the domain
* exceptions themselves (see hexagonal-core) have never heard of HTTP status codes. */
@RestControllerAdvice
public class WalletExceptionHandler {
@ExceptionHandler(InsufficientFundsException.class)
public ProblemDetail onInsufficientFunds(InsufficientFundsException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(WalletNotFoundException.class)
public ProblemDetail onWalletNotFound(WalletNotFoundException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, exception.getMessage());
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.web;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Test-only bootstrap class so @WebMvcTest has a @SpringBootConfiguration to find in this
* package. Same reasoning as persistence-adapter's TestApplication: this module has no real
* @SpringBootApplication of its own -- that belongs to the app module. */
@SpringBootApplication
class TestApplication {
}
@@ -0,0 +1,67 @@
package com.ankurm.hexagonal.web;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WithdrawMoney;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* {@code @WebMvcTest} loads only the web layer -- no persistence-adapter module on this
* test's classpath at all, and {@link DepositMoney} / {@link WithdrawMoney} are replaced with
* Mockito doubles rather than the real {@code WalletService}. This is what "testing the
* adapter against the port, not the implementation" looks like from the web side.
*/
@WebMvcTest(WalletController.class)
class WalletControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
DepositMoney depositMoney;
@MockitoBean
WithdrawMoney withdrawMoney;
@Test
void depositReturnsTheNewBalance() throws Exception {
when(depositMoney.deposit(anyString(), anyLong())).thenReturn(new Wallet("w1", 1500));
MvcResult result = mockMvc.perform(post("/wallets/w1/deposit").param("amountCents", "500"))
.andExpect(status().isOk())
.andExpect(content().json("{\"walletId\":\"w1\",\"balanceCents\":1500}"))
.andReturn();
System.out.println("POST /wallets/w1/deposit?amountCents=500 -> "
+ result.getResponse().getStatus() + " " + result.getResponse().getContentAsString());
}
@Test
void withdrawBeyondBalanceReturns409WithAProblemDetailBody() throws Exception {
when(withdrawMoney.withdraw(anyString(), anyLong()))
.thenThrow(new InsufficientFundsException("w1", 1000, 1001));
MvcResult result = mockMvc.perform(post("/wallets/w1/withdraw").param("amountCents", "1001"))
.andExpect(status().isConflict())
.andExpect(content().json(
"{\"status\":409,\"detail\":\"wallet w1 has 1000 cents, cannot withdraw 1001 cents\"}"))
.andReturn();
System.out.println("POST /wallets/w1/withdraw?amountCents=1001 -> "
+ result.getResponse().getStatus() + " " + result.getResponse().getContentAsString());
}
}