Business logic often becomes difficult to test for a reason that has little to do with the rule itself. A function that decides whether to approve a refund may also read a database, check the clock, call another service, write an audit record, and send a message.
The decision is now mixed with the machinery required to obtain inputs and apply outputs. Tests must control all of that machinery just to ask, “What should happen for this refund?”
Functional core, imperative shell is a design approach that separates those concerns. The functional core contains deterministic calculations: given the same explicit inputs, it produces the same result without performing external side effects. The imperative shell obtains real-world inputs, calls the core, and performs the resulting effects.
This article shows how to make that separation, where the boundary should sit, and when the pattern is more complexity than you need.
Start with the decision hidden inside the workflow
Consider a refund operation with this policy:
- refunds within 30 days are allowed;
- premium customers may receive refunds within 60 days;
- refunds above $500 require manual review.
A direct implementation might look like this:
processRefund(orderId):
order = database.loadOrder(orderId)
customer = customerService.load(order.customerId)
age = clock.today() - order.purchaseDate
if age > 30 days and not customer.premium:
database.saveRefundStatus(orderId, "denied")
email.sendRefundDenied(customer.email)
return
if order.amount > 500:
database.saveRefundStatus(orderId, "manual-review")
return
paymentGateway.refund(order.paymentId, order.amount)
database.saveRefundStatus(orderId, "approved")The policy is visible, but it is entangled with five external concerns: database access, customer lookup, time, email, and payment processing.
A unit test for the 60-day premium rule now needs substitutes for several collaborators. A test failure may come from setup around those collaborators rather than from the policy being tested.
The first design move is not to mock more dependencies. It is to extract the decision.
Make the core accept facts and return a decision
The policy can be expressed as a calculation over explicit inputs:
decideRefund(daysSincePurchase, isPremium, amount):
if daysSincePurchase > 30 and not isPremium:
return Denied
if daysSincePurchase > 60:
return Denied
if amount > 500:
return ManualReview
return ApprovedThis function is the functional core. It does not read the clock, fetch a customer, update storage, or issue a refund. It receives the facts required for the decision and returns a value describing the decision.
The order of the first two conditions matters. A non-premium customer is denied after 30 days, while a premium customer reaches the 60-day check. At exactly 30 days or exactly 60 days, the > comparisons still allow the refund, subject to the amount rule. Those boundary choices are now visible and easy to test.
For example:
decideRefund(20, false, 100) -> Approved
decideRefund(40, false, 100) -> Denied
decideRefund(40, true, 100) -> Approved
decideRefund(61, true, 100) -> Denied
decideRefund(10, false, 700) -> ManualReviewThe example is intentionally small. In production code, money and dates should use appropriate domain types rather than ambiguous primitive values. The design point is that the decision depends only on explicit data.
Let the shell translate the real world into facts
The imperative shell still has important work to do. It coordinates systems that cannot be made pure simply by moving code around.
processRefund(orderId):
order = database.loadOrder(orderId)
customer = customerService.load(order.customerId)
days = daysBetween(order.purchaseDate, clock.today())
decision = decideRefund(days, customer.premium, order.amount)
if decision == Denied:
database.saveRefundStatus(orderId, "denied")
email.sendRefundDenied(customer.email)
if decision == ManualReview:
database.saveRefundStatus(orderId, "manual-review")
if decision == Approved:
paymentGateway.refund(order.paymentId, order.amount)
database.saveRefundStatus(orderId, "approved")The shell is imperative because it tells external systems to do things. Its responsibilities are to gather inputs, translate them into values the core understands, invoke the core, and apply the decision.
This separation does not make I/O reliable by itself. The payment call can still time out, the database write can still fail, and retry behavior still needs deliberate design. The benefit is narrower: those operational concerns no longer obscure the refund policy.
Return decisions, not hidden side effects
A useful core usually returns more than a Boolean when the caller must take different actions.
Imagine this interface:
isRefundAllowed(...) -> booleanIt cannot distinguish a policy denial from a case that requires manual review. The shell may then start reconstructing policy to decide what false means.
A result type keeps the decision explicit:
RefundDecision =
Approved
| Denied(reason)
| ManualReview(reason)Now the core can explain what it decided without performing the effect itself:
Denied("outside refund window")
ManualReview("amount exceeds automatic refund limit")The shell decides how those outcomes map to storage, messages, metrics, or external calls.
This is an important boundary. The core owns business meaning. The shell owns interaction with the outside world.
Pass time and randomness in as data when they affect rules
Time is a common source of accidental impurity.
Suppose the core calls a global clock:
eligible(order):
return today() <= order.purchaseDate + 30 daysThe result now depends on when the test runs. A case that passes today can produce a different result later without any input changing in the test.
Instead, the shell can obtain the current date and pass it to the core:
eligible(order, currentDate):
return currentDate <= order.purchaseDate + 30 daysThe same idea applies to random choices, generated identifiers, exchange rates, feature configuration, and other environmental values. If a value affects a business decision, making it an explicit input usually makes the dependency easier to reason about.
This does not require pushing every low-level detail into every function parameter. Related facts can be grouped into meaningful input types. The goal is explicit dependency, not long parameter lists.
Test the core and shell for different reasons
The separation changes what each test needs to prove.
Core tests can cover business rules directly. They are good places for boundary cases, combinations of policy conditions, and examples that document expected decisions. Because the core has no external effects, these tests can usually run without network, storage, or clock substitutes.
Shell tests answer different questions:
- Are external values translated into the right core inputs?
- Is each decision mapped to the correct external action?
- What happens when an external operation fails or times out?
- Are retries safe for effects that may already have happened?
A small number of integration tests can then verify that real adapters work with actual infrastructure where that confidence is needed.
The pattern does not eliminate integration testing. It prevents integration concerns from becoming the only practical way to test business decisions.
Keep effects out of the core only when the distinction is real
A function is not part of the functional core merely because it has a short body or lives in a directory named domain.
If it reads mutable global state, writes a log that is required business behavior, calls a service, or depends on the current time without receiving it as input, it has an observable dependency or effect.
Conversely, not every internal mutation makes a function unsuitable for the core. An implementation may build a local list or update a local data structure while calculating a result. What matters at the architectural boundary is whether the calculation’s observable result is determined by its explicit inputs and whether it performs externally visible effects.
That distinction keeps the pattern practical instead of turning it into a debate about syntax.
Avoid turning the shell into a second business layer
A common failure mode is to extract a pure function but leave important rules in orchestration code:
if customer.country == "X":
decision = decideRefund(...)
else:
decision = ApprovedNow the business decision is split between the shell and core. Tests of the core cannot describe the complete policy, and future changes require developers to know which layer owns which rule.
When a condition changes the business outcome, prefer passing the relevant fact into the core and deciding there. Keep the shell focused on obtaining facts and applying outcomes.
Some branching genuinely belongs in the shell. Choosing which adapter to call after an Approved decision is orchestration. Deciding whether the refund should be approved is policy. The distinction is about responsibility, not about removing every if statement from the shell.
Know the trade-offs
Functional core, imperative shell introduces a boundary and often additional data types. That cost is worthwhile when business decisions are important, have many cases, or are currently difficult to test because of I/O.
The pattern is especially useful when:
- rules depend on several facts gathered from different systems;
- policy changes frequently while infrastructure changes independently;
- deterministic tests would make boundary cases easier to cover;
- the same decision may later be used by another entry point or workflow.
A simpler direct implementation may be better for a thin adapter with almost no business logic. A function that receives an HTTP request, forwards it to one service, and returns the response may gain little from manufacturing a “functional core” with no meaningful decision inside it.
There is also a risk of moving too much orchestration into the core. A pure function that returns a huge script of low-level effects can couple business logic to infrastructure concepts just as strongly as direct calls did. Prefer results expressed in domain terms, then let the shell translate them into concrete operations.
Use the boundary as a design diagnostic
When a workflow is hard to test, identify the sentence that describes its actual decision.
For the refund example, that sentence is not “load an order, call a customer service, and update a database.” It is “decide whether this refund is approved, denied, or requires review from these facts.”
Try to express that sentence as a function from explicit inputs to an explicit result. Everything needed to obtain the inputs or apply the result belongs around that calculation unless it is itself part of the business rule.
If extracting the core is difficult, the difficulty is useful information. It may reveal hidden dependencies, mixed responsibilities, or business rules that are spread across several effects.
Conclusion
Functional core, imperative shell separates deciding from doing. The core receives explicit facts and returns business decisions. The shell reads clocks and databases, calls services, and turns those decisions into external effects.
Use the pattern when side effects make important rules hard to understand or test. Keep observable business policy in the core, keep infrastructure coordination in the shell, and test each layer for the risks it actually owns. When a workflow contains little real decision-making, keep it simple rather than adding a boundary that has nothing useful to protect.