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.

samdeepthink
samdeepthink
samdeepthink
Functional Core, Imperative Shell: Refactoring Checkout Code for Testability and Reuse

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);
sumItems

internally 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Software Architectureunit testingRefactoringcheckoutJava 17recordFunctional Core Imperative Shellsealed interface
samdeepthink
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.