Functional Core, Imperative Shell for Managing Side Effects
Business logic is often easy to describe and hard to test because it sits between database reads, network calls, clocks, queues, and file writes. A pricing rule that should be a few comparisons can become tangled with fetching a customer, saving an order, and sending a notification.
Functional core, imperative shell is a design approach for separating those concerns. The functional core makes decisions from explicit input values and returns values describing the result. The imperative shell obtains those inputs and performs the required side effects.
The goal isn’t to eliminate side effects. Real software must interact with the outside world. The goal is to keep those interactions from obscuring the rules you most need to understand and test.
The mental model: decide first, perform effects second
Imagine a service that decides whether to approve a refund. The rule is simple: a completed order can be refunded within 30 days, unless it has already been refunded.
A tightly coupled version might look like this:
refund(order_id):
order = database.load_order(order_id)
now = system_clock.now()
if order.status != COMPLETED:
return NOT_ALLOWED
if order.refunded:
return ALREADY_REFUNDED
if now > order.completed_at + 30 days:
return TOO_LATE
payment_gateway.refund(order.payment_id, order.total)
database.mark_refunded(order_id)
email.send_refund_receipt(order.customer_email)
return REFUNDEDThe method mixes two kinds of work. It decides what the refund policy allows, and it coordinates external systems. Testing the policy now requires controlling or replacing dependencies that have nothing to do with the policy itself.
The functional-core approach separates the decision:
decide_refund(order, now):
if order.status != COMPLETED:
return NOT_ALLOWED
if order.refunded:
return ALREADY_REFUNDED
if now > order.completed_at + 30 days:
return TOO_LATE
return APPROVEDThe shell handles the environment:
refund(order_id):
order = database.load_order(order_id)
decision = decide_refund(order, system_clock.now())
if decision != APPROVED:
return decision
payment_gateway.refund(order.payment_id, order.total)
database.mark_refunded(order_id)
email.send_refund_receipt(order.customer_email)
return REFUNDEDThis is simplified pseudocode, not a production refund workflow. In a real payment system, retries, idempotency, partial failure, and reconciliation would need deliberate treatment. The example isolates one idea: the policy can be evaluated without performing an external action.
What makes the core functional
“Functional” here doesn’t require a functional programming language. It describes code whose result is determined by its explicit inputs and that doesn’t change externally visible state while making the decision.
If decide_refund(order, now) receives the same order and now, it should return the same decision. It doesn’t read the clock itself, query a database, mutate the order in storage, or send a message.
That property has practical consequences. A test can describe a boundary case directly:
order.completed_at = 2026-08-11 12:00
now = 2026-09-10 12:00
assert decide_refund(order, now) == APPROVEDThen it can test the next instant according to the policy’s exact boundary semantics. The test doesn’t need to wait for time to pass or configure a fake payment gateway merely to ask whether the order is eligible.
The core can still contain substantial logic. It may calculate prices, select transitions, validate state changes, or produce a plan containing several actions. What matters is that environmental effects happen outside that decision-making boundary.
Return decisions that the shell can act on
A boolean is sometimes enough, but richer decisions often make the boundary clearer.
Suppose an order cancellation rule can produce several outcomes. Instead of returning true or false, the core can return a value that explains the decision:
decide_cancellation(order, now):
if order.shipped_at != NONE:
return { kind: REJECTED, reason: ALREADY_SHIPPED }
if now > order.cancel_before:
return { kind: REJECTED, reason: WINDOW_CLOSED }
return {
kind: APPROVED,
refund_amount: order.amount_paid
}The shell interprets that result and performs effects only when required.
This has two advantages. First, policy outcomes become explicit rather than hidden in control flow. Second, the core can calculate information the shell needs without knowing which database, queue, or payment provider will receive it.
Avoid taking this idea too far. Returning a large list of low-level commands such as UPDATE_TABLE_ROW or HTTP_POST merely moves infrastructure vocabulary into the core. Prefer results that describe domain decisions: CancellationApproved, PaymentRequired, or OrderRejected.
Keep orchestration in the imperative shell
The shell is where effects belong: loading state, reading time, calling external services, persisting changes, and publishing messages. It also decides the order in which those effects occur.
That ordering matters because side effects can fail independently. In the refund example, the payment provider might accept the refund and the following database update might fail. Separating the core doesn’t solve that consistency problem.
This distinction is useful because it prevents an exaggerated claim about the pattern. A functional core improves reasoning about decisions. It does not make distributed operations atomic, guarantee exactly-once processing, or remove the need for retry and recovery strategies.
The shell therefore deserves tests of its own. Those tests answer different questions from core tests:
- Does it load the correct state before making the decision?
- Does a rejected decision avoid the payment call?
- When approval is returned, are the required effects invoked with the right values?
- What happens when an effect fails partway through the workflow?
Core tests can be numerous and fine-grained because they are cheap to set up. Shell tests are usually fewer and focus on integration and orchestration boundaries.
Grow the boundary from a real source of friction
A common mistake is to redesign an application into “core” and “shell” layers before identifying what problem the split should solve.
A safer approach is to start with a decision that is currently difficult to test or understand. Look at what the decision reads from its environment. Time, configuration, database records, and responses from other services are all candidates to turn into explicit input values.
Then look at what the decision changes. Instead of performing the change immediately, consider whether the core can return a meaningful result that the surrounding code can execute.
For example, code that automatically suspends an account might begin like this:
account = repository.load(account_id)
usage = metering.current_usage(account_id)
if usage > account.limit:
repository.suspend(account_id)
notifications.send_limit_notice(account.owner)The smallest useful extraction may be only the rule:
decide_account_status(account, usage):
if usage > account.limit:
return SUSPEND
return KEEP_ACTIVEThat is enough if the engineering problem is uncertainty around the usage threshold. There is no need to introduce a broad architecture framework just to gain this boundary.
Don’t hide input reads inside the core
Moving code into a function named calculate or decide doesn’t make it a functional core if that function still reaches into global state.
Consider this version:
decide_refund(order):
policy = global_config.refund_policy
now = system_clock.now()
...The function’s apparent input is order, but its real inputs include configuration and time. A reader can’t understand its result from the signature, and a test still needs environmental control.
Pass the values the decision actually needs:
decide_refund(order, now, refund_window):
...This doesn’t mean every configuration object should be exploded into dozens of parameters. If several values form a coherent policy, passing a RefundPolicy value can make the boundary clearer. The point is to make dependencies explicit at a useful level.
Mutation needs a deliberate boundary too
A function can avoid external I/O and still be difficult to reason about if it mutates objects shared with its caller.
For a strong functional-core boundary, prefer returning a new decision or state value rather than changing shared state invisibly:
next_order = apply_discount(order, promotion)rather than:
apply_discount(order, promotion) // silently mutates orderSome languages and codebases use mutable domain objects extensively. Replacing all mutation may be impractical and unnecessary. In that case, focus first on externally visible effects and mutation that crosses the boundary in surprising ways. The pattern is a tool for clearer reasoning, not a purity contest.
Know where the approach becomes awkward
Not every operation has a meaningful decision to extract. A thin adapter that reads a file and uploads its bytes may contain almost no domain logic. Splitting it into a functional core and imperative shell could add ceremony without making anything easier to understand.
The approach can also become awkward when a decision genuinely depends on a sequence of interactions. An algorithm that asks an external system for another page until a condition is met cannot always be reduced to one pure function without introducing a more elaborate representation of the interaction. Sometimes straightforward orchestration with well-defined interfaces is easier to maintain.
Large inputs are another practical consideration. Pulling an entire dataset into memory just so a core function can remain pure may be worse than processing a stream incrementally. Architectural boundaries should respect resource constraints.
Use the pattern where the decision has value independent of the mechanism that supplies data or performs effects. If separating those concerns makes the code harder to follow, the simpler design may be the better one.
Avoid turning the shell into an untested dumping ground
Once teams become comfortable moving logic into a core, the shell can accumulate complicated branching of its own. At that point the design has only relocated the problem.
Keep the shell focused on coordination. If it starts deciding business outcomes based on returned data, that decision may belong in the core. If it contains complicated recovery behavior, treat that behavior as real logic and test it at the appropriate level rather than dismissing it as infrastructure.
A useful review question is: Could this branch be decided from values we already have, without performing an effect? If yes, it may belong in the core. If the branch exists because an effect succeeded, timed out, or returned a particular external result, the shell may be the natural place for it, possibly followed by another core decision using that result as input.
That last case is common. Real workflows can alternate between the two:
shell: fetch inputs
core: decide next action
shell: perform action and receive result
core: decide what the result means
shell: persist or publish the outcomeFunctional core, imperative shell is therefore a boundary principle, not a requirement that every request pass through exactly one pure function.
Make one decision easier to see
When a method is difficult to test because business rules are surrounded by I/O, don’t begin by adding mocks for every dependency. First ask whether the rule can accept plain input values and return a meaningful decision.
Move that decision into a small functional core. Leave database access, clocks, network calls, and other effects in the imperative shell. Then test each side for what it actually owns: policy in the core, orchestration and failure handling in the shell.
The useful outcome isn’t functional purity. It’s code where a developer can see what the system decides separately from the machinery required to carry that decision out.