Functional Core, Imperative Shell: Refactoring Checkout Code for Testability and Reuse
The article demonstrates refactoring a tangled checkout method by separating pure calculation logic into a functional core using Java 17 records and sealed interfaces, leaving IO operations in an imperative shell, resulting in easily testable, reusable code without mocks or Spring containers.
Writing unit tests for a checkout (settlement) interface is painful because the core accounting logic — often just a few dozen lines — is entangled with numerous external dependencies: cart, SKU, member info, coupons, and multiple service/mapper calls. The root cause is that pure in-memory computation and database/IO operations are mixed together. To test a few subtraction steps, you must mock the entire database state and all external dependencies.
Before Refactoring: Spaghetti Code
A typical settlement method looks like this:
BigDecimal total = sumItems(userId);
Coupon coupon = couponMapper.selectById(couponId);
if (!coupon.reachable(total)) return total;
total = total.subtract(coupon.getValue());
couponMapper.markUsed(couponId); sumItemsinternally queries the cart and SKU tables and decides whether to use member pricing. reachable checks if the amount meets the coupon threshold. The last line writes the coupon usage back to the database.
These five lines actually perform two fundamentally different kinds of work:
Pure calculation : depends only on input parameters and produces a result (total accumulation, threshold check, amount deduction).
Side effects (IO reads/writes) : database queries, external calls, and data writes (querying the coupon, marking it used).
Mixing them creates three problems:
Hard to test : covering the member-discount branch requires fabricating SKU data; covering the coupon-threshold branch requires creating coupons with different thresholds. A single unit test needs extensive test data in the dev environment.
Hard to reuse : a price-preview endpoint needs identical logic but must not mark the coupon as used. Developers copy the method and remove the last line, leading to duplicate code that diverges over time.
Hard to change : every new promotion rule (e.g., site-wide 10% off) bloats the method, expanding regression-test scope exponentially.
Core Idea: Functional Core, Imperative Shell
Gary Bernhardt proposed this architectural pattern in 2012, later adopted in Google internal training materials: extract business calculations into completely independent pure functions, leaving all database/IO operations in the outermost layer .
Designing the Functional Core
First, define the data contracts using Java 17 features:
Input : a PriceItem record (immutable) with three fields — original price, member price, quantity — and an amountOf(boolean isMember) method to compute the line amount.
Rules : a CouponRule sealed interface that exhaustively enumerates coupon types (threshold coupon, discount coupon, etc.).
Output : a PriceResult record containing original total, discount amount, and final payable amount.
The accounting logic is concentrated in a single static method with zero IO dependencies:
public static PriceResult calculate(List<PriceItem> items, CouponRule coupon, boolean member) {
// Compute original total
BigDecimal original = items.stream()
.map(i -> i.amountOf(member))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// Compute discount
BigDecimal discount = coupon == null ? BigDecimal.ZERO : coupon.discountOf(original, member);
// Compute payable
BigDecimal payable = original.subtract(discount).setScale(2, RoundingMode.HALF_UP);
return new PriceResult(original, discount, payable);
}Thanks to Java 17 records, instances are inherently immutable, so calculate has no mutable intermediate state.
The Imperative Shell
After extracting the calculation, the outer control flow (the shell) becomes just four lines:
// 1. Pure IO: prepare data
List<PriceItem> items = loadItems(userId);
// 2. Pure calculation: in-memory accounting
PriceResult result = PriceCalculator.calculate(items, loadCoupon(couponId), memberService.isMember(userId));
// 3. Pure IO: write back state
if (result.discount().signum() > 0) couponMapper.markUsed(couponId);
return result.payable();Member status is an objective fact fetched by the shell; how to deduct money is a business rule delegated to the core. The two remain completely separate.
Benefits After Refactoring
Unit tests become trivial because testing a pure function requires no mock framework and no Spring container:
var items = List.of(new PriceItem(new BigDecimal("10"), null, 2));
var result = PriceCalculator.calculate(items,
new ThresholdCoupon(new BigDecimal("15"), new BigDecimal("5")),
false);
assertEquals(new BigDecimal("15.00"), result.payable());Simply new the inputs, invoke, assert the result — done.
The price-preview endpoint can now directly reuse PriceCalculator.calculate without executing the shell's write-back logic. Both endpoints share the exact same core calculation rules, eliminating inconsistency.
Decision Criteria: Core vs. Shell
When writing code, decide whether logic belongs in the core or the shell by checking three conditions:
Does it query the database or call external services? No → core; Yes → shell.
Does it modify database tables or global variables? No → core; Yes → shell.
Given the same inputs, is the result always unique? Yes → core; No (depends on external environment) → shell.
How to test? Core: instantiate objects and assert directly. Shell: requires container-based integration tests or mocks.
This refactoring does not eliminate database reads/writes (side effects); it moves side effects from a tangled mess to the very edges . Now, reading the code once reveals exactly which tables are read and which are written.
Trade-offs and Pragmatism
Over-engineering is discouraged. For a one-off, dozens-of-lines admin script that runs once and is discarded, a straight if-else is better. The cost of splitting is writing a few extra records and adding an indirection layer. Only when the core logic must be repeatedly tested and reused in multiple places does the refactoring cost pay off.
Many codebases become unmaintainable because no one initially distinguished pure calculation from query/write logic. Over years they intertwine, making later separation extremely difficult.
During code reviews, the author habitually first scans where side effects are scattered (which queries, which messages sent), then examines the specific calculation logic. Logic errors are caught by tests, but scattered side-effect problems can mostly only be prevented by human vigilance.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
samdeepthink
Knowledge Planet: Old Dock's Tech Chronicles Zhihu: SamDeepThinking A technical manager who still codes heavily on the front line. From junior developer to tech lead, then tech manager, now leading the whole front‑ and back‑end development team—leveling up along the way. I have some insights on programming, career development, and tech management.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
