Business logic often becomes difficult to test for a reason that has little to do with the rules themselves. A function decides what should happen while also reading the clock, querying storage, calling a service, sending a message, and writing logs. To test one decision, you must arrange all of those surroundings.
A functional core, imperative shell design separates those concerns. The functional core receives ordinary data and computes decisions without performing external side effects. The imperative shell gathers inputs, calls the core, and carries out the resulting actions.
The goal is not to make an entire application purely functional. It is to put deterministic policy where it can be understood and tested directly, while keeping unavoidable effects explicit at the boundary.
Start with a mixed decision
Imagine a service that reminds a customer when an invoice is overdue:
process_invoice(invoice_id):
invoice = repository.load(invoice_id)
now = clock.now()
if invoice.unpaid and now > invoice.due_at:
mailer.send_reminder(invoice.customer_email)
repository.mark_reminded(invoice_id, now)The rule is small: an unpaid invoice past its due time needs a reminder. Yet the function also performs four effects: it reads storage, reads time, sends email, and writes storage.
A test for the rule therefore needs substitutes for the repository, clock, and mailer. It may also need to verify which calls happened and in what order. The test starts describing orchestration instead of the decision.
The useful design question is: what would this function decide if all required facts were already available?
Make the core compute a decision
Move the rule into a function that receives the facts it needs:
decide_reminder(invoice, now):
if invoice.paid:
return NoReminder
if now <= invoice.due_at:
return NoReminder
return SendReminder(
invoice_id = invoice.id,
email = invoice.customer_email,
reminded_at = now
)This function does not ask what time it is. The caller tells it. It does not send an email or update storage. It returns a value describing the action that should occur.
For the same invoice and now, it produces the same result. That deterministic property makes the rule straightforward to test:
invoice = unpaid_invoice(due_at = 2026-09-01)
result = decide_reminder(invoice, 2026-09-05)
expect result == SendReminder(...)The example is deliberately language-neutral. A production implementation might represent decisions with records, enums, tagged unions, classes, or another structure appropriate to the language.
Let the shell perform the effects
The outer layer still has real work to do:
process_invoice(invoice_id):
invoice = repository.load(invoice_id)
now = clock.now()
decision = decide_reminder(invoice, now)
if decision is SendReminder:
mailer.send_reminder(decision.email)
repository.mark_reminded(
decision.invoice_id,
decision.reminded_at
)Nothing has eliminated I/O. The design has changed where the uncertainty lives.
The core answers, “Given these facts, what should happen?” The shell answers, “How do I obtain those facts, and how do I make the chosen action happen in this environment?”
That boundary is the central mental model.
Return intent, not performed work
A functional core becomes more useful when its output captures intent clearly enough for the shell to execute it.
Suppose reminder policy becomes richer. An invoice may require a reminder, an escalation, or no action:
decide_invoice_action(invoice, now):
if invoice.paid:
return NoAction
days_overdue = days_between(invoice.due_at, now)
if days_overdue >= 30:
return Escalate(invoice.id, invoice.account_owner)
if days_overdue >= 1:
return SendReminder(invoice.id, invoice.customer_email)
return NoActionThe core owns the policy thresholds and precedence. The shell owns the mechanisms for sending or escalating.
This separation matters because policy and mechanism often change for different reasons. A business rule may change from 30 days to 21 days without changing the email provider. An email provider may change without changing what counts as overdue.
Pass facts instead of hiding reads
A common half-step is to move logic into a new function but let that function keep reading global or injected services:
decide_reminder(invoice):
if clock.now() > invoice.due_at:
...The function now has a decision-oriented name, but its result still depends on an external clock. Tests must still control that dependency.
When a value is part of the decision, prefer passing the value itself when practical:
decide_reminder(invoice, now)The same principle applies to exchange rates, feature settings, permissions, or previously loaded account data. The shell can retrieve those facts; the core can reason over them.
Do not take this to the extreme by passing dozens of unrelated primitives. If several values form one meaningful input to a decision, represent that concept explicitly.
Keep effect results at the boundary too
Real workflows often depend on the outcome of an effect. For example, a payment attempt may succeed or fail, and the next decision depends on that result.
Do not pretend the whole workflow can be one pure call. Alternate between effects and decisions:
request = decide_payment_request(order)
result = payment_gateway.charge(request)
next_action = decide_after_payment(order, result)
execute(next_action)The gateway call belongs in the shell because its result depends on an external system. The interpretation of that result can return to the core.
This pattern creates explicit checkpoints: compute an intention, perform an effect, convert the observed result into data, then make the next decision.
Be precise about what purity guarantees
A pure decision function is easier to exercise in isolation because it does not require real external resources. It also avoids failures caused directly by network, filesystem, clock, or process state inside that function.
Purity does not prove that the business rule is correct. A deterministic function can consistently compute the wrong answer. It also does not prove that the shell executes actions correctly, that serialization preserves data, or that external services behave as expected.
Use focused tests for the core’s rules and separate tests for effectful boundaries and important integrations. The split changes what each test needs to establish; it does not remove the need to test the complete system at appropriate levels.
Avoid turning decisions into a command language
Returning actions can go too far. If every tiny operation becomes a custom command object, the application may acquire an elaborate interpreter that is harder to follow than direct code.
Prefer decision values when they create a useful boundary: the result represents meaningful policy, needs independent testing, may have several execution mechanisms, or separates a stable rule from volatile infrastructure.
For a trivial operation with no meaningful decision, direct imperative code is often clearer:
repository.save(profile)There is little value in wrapping that statement in SaveProfileCommand merely to satisfy a pattern.
Decide where the boundary should sit
The core does not have to be one function or one module. It can contain domain objects and several cooperating functions as long as their behavior is driven by explicit inputs rather than hidden effects.
Likewise, the shell does not have to be thin in line count. Coordinating retries, transactions, resource lifetimes, and external protocols can require substantial code. What matters is that infrastructure coordination does not quietly become the place where business policy accumulates.
A practical boundary often appears around a use case:
shell: load current facts
core: decide what should happen
shell: perform required effects
core: interpret effect results when another decision is neededIf moving a rule into the core requires reproducing a large framework abstraction as data, the boundary may be in the wrong place. Move only the information the decision genuinely needs.
Use the pattern where decisions and effects are tangled
Functional core, imperative shell is especially useful when important rules are buried among database calls, clocks, randomness, queues, filesystem operations, or remote requests. It can also help when tests spend more effort configuring mocks than stating business examples.
It is less valuable for simple adapters whose purpose is almost entirely side effects, such as a small component that maps an internal request to a vendor API call. Forcing such code into a pure core may add indirection without isolating meaningful policy.
Start by finding one decision that can be expressed as “given these facts, choose this result.” Pass those facts in, return the decision as data, and leave execution outside. If that makes the rule easier to explain and test, the boundary is earning its place.
The lasting idea is simple: separate deciding from doing when the separation clarifies the software. Keep policy deterministic where practical, keep effects explicit, and let each side have tests that match the responsibility it actually owns.