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