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,38 @@
package com.ankurm.hexagonal.persistence;
import java.util.Optional;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
import org.springframework.stereotype.Repository;
/**
* The real implementation of the core's outbound port -- the only class in this whole
* application that translates between the domain's {@link Wallet} and JPA's
* {@link WalletEntity}. If the persistence technology changed to MongoDB tomorrow, this is
* the only class that would need to change; {@code WalletService} would not even recompile
* differently, because it has never seen this class.
*/
@Repository
public class JpaWalletRepositoryAdapter implements WalletRepository {
private final SpringDataWalletRepository jpa;
public JpaWalletRepositoryAdapter(SpringDataWalletRepository jpa) {
this.jpa = jpa;
}
@Override
public Optional<Wallet> findById(String walletId) {
return jpa.findById(walletId).map(entity -> new Wallet(entity.getId(), entity.getBalanceCents()));
}
@Override
public Wallet save(Wallet wallet) {
WalletEntity entity = jpa.findById(wallet.id()).orElseGet(() -> new WalletEntity(wallet.id(), 0));
entity.setBalanceCents(wallet.balanceCents());
jpa.save(entity);
return wallet;
}
}
@@ -0,0 +1,6 @@
package com.ankurm.hexagonal.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
interface SpringDataWalletRepository extends JpaRepository<WalletEntity, String> {
}
@@ -0,0 +1,39 @@
package com.ankurm.hexagonal.persistence;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** The JPA shape of a wallet. Deliberately a separate class from the domain's
* {@code Wallet} -- this class exists to satisfy Hibernate (a no-arg constructor, mutable
* fields), and the domain class exists to satisfy the business rules. Mixing the two would
* mean every persistence concern leaks into the one class the core's tests are supposed to
* be free of. */
@Entity
@Table(name = "wallets")
public class WalletEntity {
@Id
private String id;
private long balanceCents;
protected WalletEntity() {
}
public WalletEntity(String id, long balanceCents) {
this.id = id;
this.balanceCents = balanceCents;
}
public String getId() {
return id;
}
public long getBalanceCents() {
return balanceCents;
}
public void setBalanceCents(long balanceCents) {
this.balanceCents = balanceCents;
}
}
@@ -0,0 +1,40 @@
package com.ankurm.hexagonal.persistence;
import com.ankurm.hexagonal.domain.Wallet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
/** A real H2 database, a real JPA EntityManager, and the adapter under test -- proving the
* port's contract against something that actually persists, not a guess at what Hibernate
* does. */
@DataJpaTest
class JpaWalletRepositoryAdapterTest {
@Autowired
SpringDataWalletRepository jpa;
@Test
void savingThenFindingRoundTripsThroughTheRealDatabase() {
JpaWalletRepositoryAdapter adapter = new JpaWalletRepositoryAdapter(jpa);
adapter.save(new Wallet("w1", 2500));
Wallet reloaded = adapter.findById("w1").orElseThrow();
System.out.println("Reloaded wallet w1 balance from H2: " + reloaded.balanceCents());
assertThat(reloaded.balanceCents()).isEqualTo(2500);
}
@Test
void findingAnUnknownIdReturnsEmpty() {
JpaWalletRepositoryAdapter adapter = new JpaWalletRepositoryAdapter(jpa);
boolean present = adapter.findById("ghost").isPresent();
System.out.println("findById(\"ghost\").isPresent(): " + present);
assertThat(present).isFalse();
}
}
@@ -0,0 +1,10 @@
package com.ankurm.hexagonal.persistence;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Test-only bootstrap class so @DataJpaTest has a @SpringBootConfiguration to find in this
* package. This module has no real @SpringBootApplication of its own -- that belongs to the
* app module, which is the only place all the adapters actually get wired together. */
@SpringBootApplication
class TestApplication {
}