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 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-core ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/core/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 7 source files with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ hexagonal-core ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.hexagonal.WalletServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.164 s -- in com.ankurm.hexagonal.WalletServiceTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.794 s
[INFO] Finished at: 2026-10-04T01:53:57+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,26 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- dependency:3.7.0:tree (default-cli) @ hexagonal-core ---
[INFO] com.ankurm:hexagonal-core:jar:1.0
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.0.3:test
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:6.0.3:test
[INFO] | | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | | +- org.junit.platform:junit-platform-commons:jar:6.0.3:test
[INFO] | | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] | | \- org.jspecify:jspecify:jar:1.0.1:test
[INFO] | +- org.junit.jupiter:junit-jupiter-params:jar:6.0.3:test
[INFO] | \- org.junit.jupiter:junit-jupiter-engine:jar:6.0.3:test
[INFO] | \- org.junit.platform:junit-platform-engine:jar:6.0.3:test
[INFO] \- org.assertj:assertj-core:jar:3.27.7:test
[INFO] \- net.bytebuddy:byte-buddy:jar:1.18.11:test
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.206 s
[INFO] Finished at: 2026-10-04T01:54:04+05:30
[INFO] ------------------------------------------------------------------------
+42
View File
@@ -0,0 +1,42 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>hexagonal-core</artifactId>
<packaging>jar</packaging>
<!-- No Spring dependency anywhere in this file, not even spring-boot-starter-test. That's
not a style choice being asked of contributors; it's what lets this one module's tests
run in milliseconds with no application context - see output/00-core-tests-no-spring.txt -->
<!-- Versions for both come from the parent's imported spring-boot-dependencies BOM (the
same BOM every other module in this repo uses), which is a version source, not a
runtime dependency - importing it in dependencyManagement pulls in nothing until a
module actually declares one of the artifacts it manages. -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,36 @@
package com.ankurm.hexagonal;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import com.ankurm.hexagonal.ports.DepositMoney;
import com.ankurm.hexagonal.ports.WalletRepository;
import com.ankurm.hexagonal.ports.WithdrawMoney;
/**
* The application layer: it implements both inbound ports and depends on nothing but the one
* outbound port. No {@code @Service}, no {@code @Autowired} -- this class has never heard of
* Spring. Wiring a real {@link WalletRepository} implementation into its constructor is the
* app module's job, not this class's.
*/
public class WalletService implements DepositMoney, WithdrawMoney {
private final WalletRepository wallets;
public WalletService(WalletRepository wallets) {
this.wallets = wallets;
}
@Override
public Wallet deposit(String walletId, long amountCents) {
Wallet wallet = wallets.findById(walletId).orElseThrow(() -> new WalletNotFoundException(walletId));
wallet.deposit(amountCents);
return wallets.save(wallet);
}
@Override
public Wallet withdraw(String walletId, long amountCents) {
Wallet wallet = wallets.findById(walletId).orElseThrow(() -> new WalletNotFoundException(walletId));
wallet.withdraw(amountCents);
return wallets.save(wallet);
}
}
@@ -0,0 +1,9 @@
package com.ankurm.hexagonal.domain;
public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String walletId, long balanceCents, long requestedCents) {
super("wallet " + walletId + " has " + balanceCents + " cents, cannot withdraw "
+ requestedCents + " cents");
}
}
@@ -0,0 +1,43 @@
package com.ankurm.hexagonal.domain;
/**
* The whole domain model for this demo. Nothing in this class, or anywhere else in this
* module, imports a single Spring class -- there is no Spring dependency in this module's
* pom.xml for it to import. A wallet knows how to deposit and withdraw money, and refuses an
* overdraft; it has no idea it will eventually be persisted or exposed over HTTP.
*/
public final class Wallet {
private final String id;
private long balanceCents;
public Wallet(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
public String id() {
return id;
}
public long balanceCents() {
return balanceCents;
}
public void deposit(long amountCents) {
if (amountCents <= 0) {
throw new IllegalArgumentException("deposit amount must be positive");
}
this.balanceCents += amountCents;
}
public void withdraw(long amountCents) {
if (amountCents <= 0) {
throw new IllegalArgumentException("withdraw amount must be positive");
}
if (amountCents > balanceCents) {
throw new InsufficientFundsException(id, balanceCents, amountCents);
}
this.balanceCents -= amountCents;
}
}
@@ -0,0 +1,8 @@
package com.ankurm.hexagonal.domain;
public class WalletNotFoundException extends RuntimeException {
public WalletNotFoundException(String walletId) {
super("no wallet with id " + walletId);
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.ports;
import com.ankurm.hexagonal.domain.Wallet;
/** An inbound port: the one way into the core for this use case. The web-adapter module
* depends on this interface, never on the concrete service behind it. */
public interface DepositMoney {
Wallet deposit(String walletId, long amountCents);
}
@@ -0,0 +1,18 @@
package com.ankurm.hexagonal.ports;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
/**
* An outbound port: the core declares the shape of persistence it needs, without knowing or
* caring whether the real answer is a JPA table, a key-value store, or (as in the core's own
* tests) a HashMap. The persistence-adapter module is the only place that implements this
* interface for real.
*/
public interface WalletRepository {
Optional<Wallet> findById(String walletId);
Wallet save(Wallet wallet);
}
@@ -0,0 +1,8 @@
package com.ankurm.hexagonal.ports;
import com.ankurm.hexagonal.domain.Wallet;
public interface WithdrawMoney {
Wallet withdraw(String walletId, long amountCents);
}
@@ -0,0 +1,31 @@
package com.ankurm.hexagonal;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
/** A hand-written test double, not a mocking-framework one -- there is no mocking library on
* this module's classpath either. This is the entire cost of testing the core in isolation:
* one small class implementing the one interface the application layer actually depends on. */
class InMemoryWalletRepository implements WalletRepository {
private final Map<String, Wallet> wallets = new HashMap<>();
void seed(Wallet wallet) {
wallets.put(wallet.id(), wallet);
}
@Override
public Optional<Wallet> findById(String walletId) {
return Optional.ofNullable(wallets.get(walletId));
}
@Override
public Wallet save(Wallet wallet) {
wallets.put(wallet.id(), wallet);
return wallet;
}
}
@@ -0,0 +1,59 @@
package com.ankurm.hexagonal;
import com.ankurm.hexagonal.domain.InsufficientFundsException;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.domain.WalletNotFoundException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* No {@code @SpringBootTest}, no {@code @ExtendWith(SpringExtension.class)} -- just JUnit and
* a hand-written fake. There is no application context to start, which is the whole point:
* run {@code mvn -pl hexagonal/core test} and compare the elapsed time with any of this
* repo's other modules that boot real Spring.
*/
class WalletServiceTest {
private final InMemoryWalletRepository wallets = new InMemoryWalletRepository();
private final WalletService service = new WalletService(wallets);
@Test
void depositIncreasesBalance() {
wallets.seed(new Wallet("w1", 1000));
Wallet result = service.deposit("w1", 500);
assertThat(result.balanceCents()).isEqualTo(1500);
}
@Test
void withdrawDecreasesBalance() {
wallets.seed(new Wallet("w1", 1000));
Wallet result = service.withdraw("w1", 400);
assertThat(result.balanceCents()).isEqualTo(600);
}
@Test
void withdrawBeyondBalanceIsRefused() {
wallets.seed(new Wallet("w1", 1000));
assertThatThrownBy(() -> service.withdraw("w1", 1001))
.isInstanceOf(InsufficientFundsException.class)
.hasMessage("wallet w1 has 1000 cents, cannot withdraw 1001 cents");
// The refusal happened before any write -- the balance is exactly where it started.
assertThat(wallets.findById("w1").orElseThrow().balanceCents()).isEqualTo(1000);
}
@Test
void depositToAnUnknownWalletFailsWithoutTouchingTheRepository() {
assertThatThrownBy(() -> service.deposit("ghost", 100))
.isInstanceOf(WalletNotFoundException.class)
.hasMessage("no wallet with id ghost");
}
}