Hexagonal Architecture for Testability: Imperative Shell, Functional Core
I have lost count of how many codebases I have seen where a change to the caching layer breaks a test for the discount policy. The test was never supposed to care about caching. But the discount logic calls a repository, the repository has a cache annotation, and now the test has to spin up a cache mock just so the discount test can run.
This is the symptom of a deeper design problem: business logic mixed with stateful infrastructure code. It is the single biggest obstacle to writing fast, reliable, meaningful tests.
There is a well-known fix, but it demands more up-front thinking about your domain model and your interface boundaries than most teams are willing to invest. If you do that work up front, the payoff is enormous: your core business logic becomes testable with nothing more than plain function calls.
This note walks through the pattern: Hexagonal Architecture as the structural approach, and Imperative Shell, Functional Core as the concrete boundary rule. It includes a concrete before-and-after example to show the difference in testing effort.
The Testing Pain in Layered Architecture
The typical layered architecture puts controllers, services, and repositories in distinct layers. In theory, each layer only depends on the one below it. In practice, those layers create a testing tax on every change.
Here is a simplified service class written in the layered style:
public class OrderService {
private final OrderRepository repository;
private final PaymentGateway gateway;
private final EmailService email;
public void placeOrder(OrderRequest request) {
Order order = new Order(request.items());
BigDecimal total = order.calculateTotal();
// business logic mixed with stateful calls
if (total.compareTo(new BigDecimal("100")) > 0) {
order.applyDiscount(new BigDecimal("0.1"));
}
PaymentResult result = gateway.charge(order.getCustomerId(), total);
if (!result.isSuccess()) {
throw new PaymentException("Payment failed");
}
repository.save(order);
email.sendConfirmation(order.getCustomerId(), order.getId());
}
}
The business logic here is: apply a 10% discount on orders over $100. Everything else is infrastructure: charging a card, saving to a database, sending an email. But the discount logic sits inside a method that also calls gateway.charge, repository.save, and email.sendConfirmation.
To test the discount behavior you need to mock PaymentGateway, OrderRepository, and EmailService. That is three mocks before you have written a single assertion. The test setup alone takes ten lines of mocking boilerplate. And the test breaks if someone adds a new dependency to the constructor, adds a cache annotation on the repository, or changes the payment flow.
The real problem is not that services depend on repositories. It is that the decision logic (when to apply a discount) and the side-effect orchestration (charge, save, send) live in the same function. They cannot be tested independently.
Hexagonal Architecture: Ports and Adapters as a Testing Strategy
Hexagonal Architecture, also called Ports and Adapters, inverts this dependency structure. The core domain defines interfaces (ports) that infrastructure code implements (adapters). The business logic never references an external library, a database driver, or an HTTP client. It only references its own interfaces.
From a testing perspective, the critical property is: the core domain has zero dependencies on infrastructure. You can instantiate any domain service or entity without a database, without a web server, and without a mock. You just pass in a test double that satisfies the port interface, or nothing at all if the operation is pure.
This architectural property does not come for free. You must design the ports, the domain models, and the interaction boundaries before you wire up the infrastructure. That is the part that takes thought.
Imperative Shell, Functional Core
Hexagonal Architecture says where the boundary goes between domain and infrastructure. Imperative Shell, Functional Core (ISFC) says how to enforce that boundary within a single module or service.
The idea is simple: push all stateful, impure operations to the outer shell of the application. The inner core contains only pure functions that take data in and return data out, with no side effects.
The imperative shell assembles the inputs (fetch from a database, parse an HTTP request), calls the functional core to compute a result, then executes the side effects (save to database, send a response, emit an event).
For the order service example above, the ISFC separation looks like this:
Functional Core (pure, no infrastructure dependencies):
public class OrderCalculator {
public CalculatedOrder applyPolicies(Order order, Campaign campaign) {
BigDecimal total = order.calculateTotal();
if (total.compareTo(new BigDecimal("100")) > 0) {
total = total.multiply(new BigDecimal("0.9")); // 10% discount
}
if (campaign.isActive() && campaign.appliesTo(order)) {
total = total.subtract(campaign.discount());
}
return new CalculatedOrder(order, total);
}
}
Imperative Shell (orchestration with side effects):
public class PlaceOrderHandler {
private final OrderRepository repository;
private final PaymentGateway gateway;
private final EmailService email;
private final OrderCalculator calculator;
private final CampaignService campaigns;
public void handle(PlaceOrder command) {
Order order = repository.findById(command.orderId());
Campaign campaign = campaigns.getCurrent();
CalculatedOrder result = calculator.applyPolicies(order, campaign);
PaymentResult payment = gateway.charge(
command.customerId(), result.total()
);
if (!payment.isSuccess()) {
throw new PaymentException("Payment failed");
}
repository.save(result.toPersistentOrder());
email.sendConfirmation(
command.customerId(), result.orderId()
);
}
}
Now focus on what it takes to test each piece.
Testing the Core vs Testing the Shell
Testing the functional core requires zero mocking:
@Test
void appliesTenPercentDiscountOnOrdersOverOneHundred() {
OrderCalculator calculator = new OrderCalculator();
Order order = new Order(List.of(new LineItem("Laptop", new BigDecimal("120"))));
Campaign inactive = new Campaign(false, BigDecimal.ZERO);
CalculatedOrder result = calculator.applyPolicies(order, inactive);
assertEquals(new BigDecimal("108"), result.total());
// 120 * 0.9 = 108
}
That is a constructor call, a data setup, and an assertion. No mocks. No @BeforeEach boilerplate. No test framework extensions. The test can run in milliseconds and is oblivious to whether the real app uses PostgreSQL, Redis, or flat files.
The imperative shell still needs integration tests with real infrastructure, but those tests are fewer and cover orchestration, not logic. You do not test your discount policy through an integration test that hits a real database. You test the discount policy in the core unit test above, and you test the shell in one or two smoke tests that verify the handler reads from a repository and calls the calculator with the correct arguments.
The practical result: about 80% of your tests live in the functional core, run in milliseconds, and break only when the business rules change. The remaining 20% live in the shell, run slower, use real infrastructure or focused mocks, and break only when the orchestration or wiring changes.
What This Means for Your Test Suite
I have worked on projects that made this transition. The difference in developer experience is dramatic. Before the transition, every test was a slow, flaky integration test. After the transition, the majority of tests are pure unit tests that never touch a database. Build times drop from minutes to seconds. Test failures become meaningful: a failing test tells you exactly which business rule is violated, not which mock configuration is wrong.
But there is a tradeoff. The functional core cannot call a database. It cannot send an email. It cannot check the current time. All of those operations must be passed in as data or as dependencies. This constraint forces you to define clean domain models and explicit interfaces before you start wiring up infrastructure. That takes discipline and up-front design.
If your team is not willing to spend that design time, the pattern will degrade. The core will acquire implicit dependencies on global state, static methods, or time. The shell will leak business logic. You will end up back in the same place, but with more interfaces.
The Hard Part: Designing Boundaries
The hardest part of Hexagonal Architecture is not the architecture itself. It is deciding what goes in the core and what goes in the shell. This decision requires a clear understanding of your domain.
A good heuristic: if you cannot describe the operation without referencing a specific technology, it belongs in the shell. “Calculate the discount for this order” is a core operation. “Save the order to PostgreSQL” is a shell operation. “Send a confirmation email” is a shell operation. “Determine whether a campaign applies to this purchase” is core.
Another heuristic: the core should be expressible in pure data transformations. Given inputs A and B, what is output C? If the answer involves “and then update the database” or “and then send a notification”, you have crossed the boundary into the shell.
Neither heuristic is perfect, but both point in the same direction. Push side effects out. Pull decision logic in.
Getting Started Without a Big Rewrite
You do not need to rewrite your entire application to benefit from this pattern. Pick one service that is particularly painful to test. Extract its decision logic into a pure class that takes data in and returns data out. Write a unit test for that class. Then refactor the original service to delegate to the extracted class. Run your existing tests to confirm nothing broke.
Repeat this for the next painful service. Over time, the functional core grows, the imperative shell shrinks, and the code becomes progressively easier to test.
This incremental approach works because the pattern is compatible with most codebases. It does not require a new framework, a new database, or a new programming language. It only requires a willingness to move a method from one class to another and to define an interface where there was none before.