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