Most Spring Boot tutorials put @Service, @Repository, and
@RestController on three classes that all talk to each other directly, and that
works right up until you need to test the business rule without a database, or swap H2 for
Postgres, or discover that half your “domain” code is actually quietly calling
EntityManager. Hexagonal architecture (also called
ports and adapters) is a way of writing the code so that can’t happen by
accident: the business rules live in a class that has never imported anything from the
org.springframework package, and this post proves that with a Maven command, not
just a promise.
The example is a wallet that can be deposited into and withdrawn from, and refuses an overdraft. That’s deliberately small — the point of this post isn’t the wallet, it’s the four module boundaries around it, and what Spring Boot 4 actually does (and occasionally refuses to do) when you draw them for real.
Versions used in this post. Spring Boot 4.1.1 (verified GA against Maven Central’smaven-metadata.xml—4.2.0-M2is the newest entry there but is a milestone, not a release) · Spring Framework 7.0.9 · Hibernate ORM 7.4.5.Final · H2 2.4.240, the version Boot 4.1.1’s BOM manages · JDK 25 LTS. Every piece of output quoted below came out of a realmvn testrun against this post’s companion repository, including the dependency tree that is this article’s main exhibit.
Keep the business rules in a class that has never heard of Spring
Start with the rule itself, in the plainest form it can take: a wallet holds a balance; you can add to it; you can take from it as long as there’s enough there. Nothing about “how do I save this” or “how does this arrive over HTTP” belongs in that sentence, so it doesn’t belong in the class either:
public final class Wallet {
private final String id;
private long balanceCents;
public Wallet(String id, long balanceCents) {
this.id = id;
this.balanceCents = 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;
}
}
core/src/main/java/com/ankurm/hexagonal/domain/Wallet.java
No import of jakarta.persistence, no import of
org.springframework.* — this class could run on a JVM from fifteen years ago. The
term for that is the domain, or the core, and the whole
discipline of hexagonal architecture is one rule applied consistently: everything the core
needs from the outside world is declared as an interface the core owns, and the outside world
implements it — never the other way around.
In this post’s companion repository that rule isn’t just followed, it’s enforced by
Maven: hexagonal is laid out as its own small 4-module reactor
(core, persistence-adapter, web-adapter, app)
rather than the single self-contained project every other article in this repo uses, specifically
so the claim “the core has no Spring dependency” is something mvn dependency:tree
can prove rather than something a reviewer has to remember to check by reading imports.
A port is just an interface the core owns
The core needs two things from the world outside it: a place to save and load a wallet, and a way for something to ask it to deposit or withdraw. Both become plain interfaces, declared inside the core, with no implementation anywhere near them:
public interface WalletRepository {
Optional<Wallet> findById(String walletId);
Wallet save(Wallet wallet);
}
core/src/main/java/com/ankurm/hexagonal/ports/WalletRepository.java — an outbound port: the core calling out
public interface DepositMoney {
Wallet deposit(String walletId, long amountCents);
}
core/src/main/java/com/ankurm/hexagonal/ports/DepositMoney.java — an inbound port: the world calling in
The names are the usual terms for this, and worth keeping straight: an inbound
port (sometimes “driving” port) is a use case the outside world triggers —
DepositMoney, WithdrawMoney. An outbound port
(sometimes “driven” port) is something the core needs and delegates to —
WalletRepository. The direction is always named from the core’s point of view.
The class that implements both inbound ports is the one piece of actual business logic in this whole project, and it carries no Spring annotation at all:
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);
}
}
core/src/main/java/com/ankurm/hexagonal/WalletService.java
No @Service. No @Autowired. WalletService is
constructed with a plain new, handed whatever implements WalletRepository.
Spring never has to be involved for this class to work correctly, which is exactly what the next
section tests.
- Going deeper on naming inbound vs. outbound ports: Alistair Cockburn’s original write-up, Hexagonal Architecture
Testing the business rule with no Spring context at all
Because WalletService only needs something that implements
WalletRepository, its test can hand it a hand-written fake — a HashMap
wearing the port’s interface — instead of a real database or a mock framework:
class InMemoryWalletRepository implements WalletRepository {
private final Map<String, Wallet> store = new HashMap<>();
void seed(Wallet wallet) { store.put(wallet.id(), wallet); }
@Override
public Optional<Wallet> findById(String walletId) {
return Optional.ofNullable(store.get(walletId));
}
@Override
public Wallet save(Wallet wallet) {
store.put(wallet.id(), wallet);
return wallet;
}
}
core/src/test/java/com/ankurm/hexagonal/InMemoryWalletRepository.java
class WalletServiceTest {
private final InMemoryWalletRepository wallets = new InMemoryWalletRepository();
private final WalletService service = new WalletService(wallets);
@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);
}
}
core/src/test/java/com/ankurm/hexagonal/WalletServiceTest.java (4 tests total)
Run just this module and watch what doesn’t happen — no banner, no “Starting WalletServiceTest using Java 25”, no embedded database spinning up:
$ mvn -pl core test
[INFO] --- surefire:3.5.4:test (default-test) @ hexagonal-core ---
[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] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] Total time: 2.794 s
Full transcript: core/output/00-core-tests-no-spring.txt
0.164 seconds for four tests, with no context to start, is the entire business case for keeping the core Spring-free: every other module in this project pays a multi-second Spring Boot startup cost per test class (you’ll see the exact numbers later in this post), and this one never does.
A dependency tree, not a promise
It’s one thing to say “the core has no framework dependency” in a README; it’s another to
make Maven itself incapable of proving otherwise. Here is the complete, unedited output of
asking Maven what hexagonal-core actually depends on:
$ mvn -pl core dependency:tree
[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
Full transcript: core/output/01-core-dependency-tree.txt
Eleven artifacts, every one of them JUnit, AssertJ, or a transitive of those two, every one
scoped test — nothing compiles against the core’s main source set except the JDK
itself. The reason this is even possible is in core/pom.xml: it imports the Boot
BOM for version management (so its test dependencies stay aligned with the rest of the repo) but
declares only junit-jupiter and assertj-core, both
test-scoped. Importing a BOM pulls in nothing by itself — it only supplies version numbers for
artifacts a module actually asks for, and this module never asks for a Spring one.
This is the test the companion repository is built to pass. If a future change to this module ever adds a Spring import by accident, this exact command, run against this exact module, will show it — that is a stronger guarantee than a code review catching an errant import, and it is the entire reason this one article uses a multi-module reactor instead of the single self-contained project every other post in this repo uses.
Plugging in a real database
The core declared WalletRepository; something has to actually implement it
against a database, and that something lives in its own module, persistence-adapter,
which is the only place in this project allowed to know JPA exists.
First, a JPA-shaped class that is deliberately not the domain’s Wallet:
@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;
}
// getters, setBalanceCents
}
persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/WalletEntity.java
Mixing Wallet and WalletEntity into one class would be the easiest
mistake to make here, and it would quietly drag Hibernate’s requirements (a no-arg constructor,
mutable setters, an identity column) back into the class the first section went out of its way
to keep clean. Keeping them separate is one extra class in exchange for the core staying honest.
Next, the real adapter — the only class in the whole application that translates between the two:
@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;
}
}
persistence-adapter/src/main/java/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.java
SpringDataWalletRepository itself is the plain one-liner you’ve written a dozen
times (interface SpringDataWalletRepository extends JpaRepository<WalletEntity,
String>), package-private on purpose — nothing outside this module has any business
calling it directly, since the only contract anything else should depend on is
WalletRepository.
Testing this adapter against a real, if temporary, database is @DataJpaTest‘s
whole job — it boots just enough Spring to wire an EntityManager and a repository,
against an embedded H2 database, and nothing else:
@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);
}
}
Hibernate: drop table if exists wallets cascade
Hibernate: create table wallets (balance_cents bigint not null, id varchar(255) not null, primary key (id))
Hibernate: select we1_0.id,we1_0.balance_cents from wallets we1_0 where we1_0.id=?
Reloaded wallet w1 balance from H2: 2500
findById("ghost").isPresent(): false
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.284 s
Full transcript: persistence-adapter/output/00-jpa-adapter-tests.txt
2500 cents, read back from an actual SQL select against an actual H2 table that
Hibernate actually created and dropped in the same run — not a Mockito stub standing in for the
database and quietly agreeing with whatever the test expects.
Going deeper: where @DataJpaTest actually lives in Spring Boot 4.1.1
This one cost a real compile cycle while writing this repository. In Spring Boot 3, @DataJpaTest lived in package org.springframework.boot.test.autoconfigure.orm.jpa, inside the spring-boot-test-autoconfigure artifact. In Boot 4.1.1 that package no longer exists — unzipping spring-boot-test-autoconfigure-4.1.1.jar and grepping its file listing for “jpa” (case-insensitive) returns nothing at all. Spring Boot 4 split most test-slice annotations out into their own small artifacts, each under a new package, not just a new jar carrying the old one. @DataJpaTest now lives in artifact spring-boot-data-jpa-test, package org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest — confirmed the same way, by downloading the jar from Maven Central and unzipping its file listing before writing a single line of pom.xml. @WebMvcTest went through the identical move, to artifact spring-boot-webmvc-test, package org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest — see the web-adapter section below for that one in context. If you are migrating an existing Boot 3 test suite to Boot 4, this is very likely the first compile error you hit, and the fix is a new dependency coordinate plus a new import, not a code change.
Plugging in HTTP
The web-adapter module is the mirror image of the persistence one: it depends on the core’s
inbound ports, DepositMoney and WithdrawMoney, and translates
HTTP requests into calls against them:
@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());
}
public record BalanceResponse(String walletId, long balanceCents) { }
}
web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletController.java
Notice the constructor parameters are the interfaces, not WalletService.
WalletController has never heard of WalletService, which is the point
of an inbound port: swap the implementation behind DepositMoney for anything else
that satisfies the contract, and this class does not change.
Turning a domain exception into an HTTP status is also this adapter’s job, never the core’s:
@RestControllerAdvice
public class WalletExceptionHandler {
@ExceptionHandler(InsufficientFundsException.class)
public ProblemDetail onInsufficientFunds(InsufficientFundsException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, exception.getMessage());
}
}
web-adapter/src/main/java/com/ankurm/hexagonal/web/WalletExceptionHandler.java
@WebMvcTest loads only this layer — no persistence-adapter module
on the classpath at all — and replaces the two ports with Mockito doubles:
@WebMvcTest(WalletController.class)
class WalletControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean DepositMoney depositMoney;
@MockitoBean WithdrawMoney withdrawMoney;
@Test
void withdrawBeyondBalanceReturns409WithAProblemDetailBody() throws Exception {
when(withdrawMoney.withdraw(anyString(), anyLong()))
.thenThrow(new InsufficientFundsException("w1", 1000, 1001));
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\"}"));
}
}
web-adapter/src/test/java/com/ankurm/hexagonal/web/WalletControllerTest.java
POST /wallets/w1/withdraw?amountCents=1001 -> 409 {"detail":"wallet w1 has 1000 cents, cannot withdraw 1001 cents","instance":"/wallets/w1/withdraw","status":409,"title":"Conflict"}
POST /wallets/w1/deposit?amountCents=500 -> 200 {"walletId":"w1","balanceCents":1500}
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.712 s
Full transcript: web-adapter/output/00-webmvctest-mockmvc.txt
That’s the real ProblemDetail JSON RFC 9457 produces, built entirely from a
domain exception the web layer never constructed — it only decided what status code it means.
Two more Boot 4 moves, both easy to trip over.@MockitoBeanis not where@MockBeanused to be: the oldorg.springframework.boot.test.mock.mockito.MockBeanis gone, replaced byorg.springframework.test.context.bean.override.mockito.MockitoBean— note it has moved out of a Spring-Boot-specific package intospring-testitself, as part of Spring Framework 6.2’s general bean-override mechanism. And@RequestParam long amountCentswith no explicit name only resolves if the class files carry parameter names, which needs-parameterspassed tojavac—spring-boot-starter-parentsets that for you silently, but this module’s parent POM is this article’s own small reactor, notspring-boot-starter-parent, so it has to be added by hand (as<parameters>true</parameters>onmaven-compiler-plugininhexagonal/pom.xml). Without it the failure doesn’t show up at compile time at all — it shows up as a 500 at request time, with the message “Name for argument of type [long] not specified…”
- The full migration guide this corner belongs to: RestTemplate to RestClient migration guide
The one class that is allowed to know everything
Three modules so far have never been allowed to depend on each other. Something still has to
construct a real WalletService with a real JpaWalletRepositoryAdapter
inside it, and hand that to a real WalletController — that something is the
composition root, and in a Spring Boot app it’s the app module,
specifically the one class carrying @SpringBootApplication:
@SpringBootApplication(scanBasePackages = "com.ankurm.hexagonal")
@EnableJpaRepositories(basePackages = "com.ankurm.hexagonal.persistence")
@EntityScan(basePackages = "com.ankurm.hexagonal.persistence")
public class HexagonalApplication {
public static void main(String[] args) {
SpringApplication.run(HexagonalApplication.class, args);
}
@Bean
public WalletService walletService(WalletRepository walletRepository) {
return new WalletService(walletRepository);
}
}
app/src/main/java/com/ankurm/hexagonal/app/HexagonalApplication.java
The @Bean method is the whole point: WalletService carries no
@Service, so without that method Spring would never construct it at all. This is
what “the wiring is explicit, not discovered” means in practice — one method, in one class, is
the entire answer to “which implementation gets used.”
The two annotations above it exist because of a failure that cost real time to track down, and it’s worth walking through because it will bite anyone who copies this module layout without reading this far.
Leave those two annotations off and the application fails to start with:
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.ankurm.hexagonal.persistence.SpringDataWalletRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2304)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1732)
Full transcript (captured by actually removing the annotations and running the test, not reconstructed from memory): app/output/01-missing-scan-base-packages-failure.txt
which reads exactly like a missing @Repository or a missing component scan —
except scanBasePackages = "com.ankurm.hexagonal" already covers that package.
@EnableJpaRepositories and @EntityScan each have their own default base
package, and that default is the package the main class lives in —
com.ankurm.hexagonal.app — not whatever scanBasePackages says.
com.ankurm.hexagonal.app and com.ankurm.hexagonal.persistence are
siblings, not ancestor and descendant, so the default scan for repositories and entities simply
never reaches the adapter module at all. The fix is the two explicit
basePackages arguments above, pointed at the adapter’s actual package.
This only bites multi-module layouts. In a single-module Spring Boot project, the main class almost always sits at the root of the package tree everything else nests under, so the default happens to be correct by accident. The moment the @SpringBootApplication class and your @Repository interfaces live in genuinely separate, non-nested packages — which a hexagonal layout all but guarantees — the accident stops covering for you.
With both annotations in place, the full stack starts and the bean graph completes. Here’s the whole thing, over a real embedded Tomcat, a real H2 database, and real HTTP calls:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class HexagonalApplicationTest {
@Autowired TestRestTemplate rest;
@Autowired WalletRepository walletRepository;
@Test
void depositThenWithdrawOverHttpEndsAtTheRightBalanceThroughTheRealDatabase() {
walletRepository.save(new Wallet("w1", 1000));
ResponseEntity<BalanceResponse> afterDeposit = rest.postForEntity(
"/wallets/w1/deposit?amountCents=500", null, BalanceResponse.class);
ResponseEntity<BalanceResponse> afterWithdraw = rest.postForEntity(
"/wallets/w1/withdraw?amountCents=200", null, BalanceResponse.class);
assertThat(afterWithdraw.getBody().balanceCents()).isEqualTo(1300);
// Reload straight from the real repository bean -- not from the HTTP response -- to
// prove the number actually landed in H2, not just in the controller's return value.
long persisted = walletRepository.findById("w1").orElseThrow().balanceCents();
assertThat(persisted).isEqualTo(1300);
}
}
app/src/test/java/com/ankurm/hexagonal/app/HexagonalApplicationTest.java
Overdraft attempt: 409 CONFLICT
After deposit: 200 OK BalanceResponse[walletId=w1, balanceCents=1500]
After withdraw: 200 OK BalanceResponse[walletId=w1, balanceCents=1300]
Balance reloaded directly from WalletRepository: 1300
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.208 s
Full transcript: app/output/00-end-to-end-http.txt
1300 cents, confirmed two independent ways — once from the HTTP response body, once by reloading straight through the port from the real database — is the strongest statement this post can make that every boundary drawn above actually holds together end to end.
Going deeper: TestRestTemplate survived Boot 4, but it moved, and it isn’t automatic anymore
@AutoConfigureTestRestTemplate on the test class above is not decoration. Spring Boot deprecated TestRestTemplate in favor of the newer RestTestClient in Boot 4, but, unlike @DataJpaTest and @WebMvcTest, it did not remove the class — it moved it to its own artifact, spring-boot-resttestclient, package org.springframework.boot.resttestclient.TestRestTemplate (the old package, org.springframework.boot.test.web.client.TestRestTemplate, is gone). The surprising part: a @SpringBootTest(webEnvironment = RANDOM_PORT) no longer auto-configures a TestRestTemplate bean on its own the way it did in Boot 3 — omit @AutoConfigureTestRestTemplate and @Autowired TestRestTemplate rest fails with a plain NoSuchBeanDefinitionException, giving no hint that an annotation, rather than a dependency, is missing. And because TestRestTemplateTestAutoConfiguration’s @ConditionalOnMissingBean check reflectively inspects a RestTemplateBuilder-returning @Bean method to decide whether to back off, the production, non-test artifact spring-boot-restclient (which is where RestTemplateBuilder itself now lives) has to be on the test classpath too, even though no test code in this module calls it directly. Leave it off and the context fails to start with a NoClassDefFoundError: org/springframework/boot/restclient/RestTemplateBuilder buried inside autoconfiguration condition evaluation — a genuinely confusing error for a missing test-scope dependency. All three artifact coordinates above were confirmed by downloading the jar from Maven Central and unzipping its contents before writing the dependency, not by guessing from a blog post.
Should every Spring Boot app be structured this way? No. The ceremony in this post — four Maven modules, two interfaces per use case, a translation layer between every pair of adjacent concerns — buys you a core you can test without Spring and swap infrastructure underneath without touching, and that trade is worth making when the business rules are genuinely worth protecting (they outlive the current database, or a second delivery mechanism is coming, or the team is large enough that “what does this class actually depend on” needs to be enforceable rather than remembered). For a small CRUD app with one database and one UI, for its whole lifetime,@Servicecalling@Repositorydirectly is not a mistake — it’s the appropriately-sized answer, and reaching for four modules and nine interfaces to add one field would be the actual over-engineering.
Further reading
- hexagonal companion repository — versions, module table, and the output/ index
- Transactional Outbox with the Spring Modulith Event Publication Registry — another post in this architecture series, on keeping a database write and an outgoing event consistent
- Saga Pattern in Spring Boot: Orchestration vs Choreography with Kafka — what happens once the business operation spans more than one service’s own data
- Alistair Cockburn — Hexagonal Architecture (the original source of the term)
- Spring Boot reference — Testing Spring Boot Applications
No Comments yet!