Business logic often becomes difficult to test for a simple reason: the code that decides what should happen is mixed with the code that makes it happen.

A function reads the clock, queries storage, applies pricing rules, sends an email, and writes an audit record. To test one discount rule, the test must now arrange several unrelated dependencies. When a failure occurs, it is harder to tell whether the decision was wrong or an external operation failed.

A useful design technique is to separate decisions from side effects. Decision code works from explicit inputs and produces a result describing what should happen. Effectful code gathers those inputs and performs the required I/O.

This article develops that model, shows the smallest useful refactoring, and explains where the boundary helps and where it does not.

Start with one question: what is the code deciding?

Consider a subscription renewal job. A simplified implementation might look like this:

function renew(subscription_id):
    subscription = database.load(subscription_id)
    now = clock.now()

    if subscription.expires_at <= now:
        database.mark_expired(subscription_id)
        email.send_expired_notice(subscription.customer_email)
        return "expired"

    return "active"

The function performs three different kinds of work:

observe: read subscription and current time
decide: determine whether the subscription is expired
act:    update storage and send a notification

The middle step contains the rule we care about. Yet the rule cannot be exercised without code that also knows about a database and a clock.

The first improvement is not a framework or a new architectural layer. It is simply to make the decision visible.

Extract the smallest useful decision

Move the rule into a function whose relevant inputs are explicit:

function renewal_status(subscription, now):
    if subscription.expires_at <= now:
        return "expired"

    return "active"

The effectful code becomes:

function renew(subscription_id):
    subscription = database.load(subscription_id)
    now = clock.now()

    status = renewal_status(subscription, now)

    if status == "expired":
        database.mark_expired(subscription_id)
        email.send_expired_notice(subscription.customer_email)

    return status

This small change creates a useful boundary.

renewal_status does not need to know where the subscription came from or how the current time was obtained. Given the same subscription and time, it makes the same decision. Tests for the expiry rule can therefore supply ordinary values rather than coordinating infrastructure.

The outer renew function still performs I/O. That is not a defect. Software must eventually read and write the outside world. The goal is to stop external mechanics from obscuring the rule.

Think of the design as observe, decide, act

A practical mental model is:

outside world
    |
    v
 observe
    |
    v
 explicit data
    |
    v
 decide
    |
    v
 result / requested effects
    |
    v
  act
    |
    v
outside world

Observe obtains facts the decision needs: records, configuration, current time, user input, or responses from another service.

Decide applies rules to those facts without performing the external actions itself.

Act interprets the decision and performs writes, messages, network calls, or other effects.

This separation is sometimes described with terms such as a functional core and imperative shell. The terminology is less important than the dependency direction: decision logic should receive the information it needs instead of reaching outward to discover it while making the decision.

Return meaning, not just a boolean

A boolean can be enough for a tiny rule:

is_expired(subscription, now) -> true | false

As decisions become richer, a boolean often loses useful information.

Suppose renewal can produce three outcomes:

continue normally
send a warning because expiry is near
expire the subscription

Returning a meaningful result makes the policy explicit:

function renewal_decision(subscription, now):
    if subscription.expires_at <= now:
        return Expire

    if subscription.expires_at <= now + 7 days:
        return Warn(days_remaining(subscription, now))

    return Continue

The caller can then translate the decision into effects:

decision = renewal_decision(subscription, now)

match decision:
    Expire:
        database.mark_expired(subscription.id)
        email.send_expired_notice(subscription.customer_email)

    Warn(days):
        email.send_expiry_warning(subscription.customer_email, days)

    Continue:
        do_nothing()

The example is pseudocode. A real implementation might use an enum, tagged union, class hierarchy, or another representation appropriate to the language.

The important property is that the result describes the rule’s conclusion without performing the external work.

Make hidden inputs explicit

I/O is not limited to databases and HTTP calls. Decision code can depend on hidden environmental inputs too.

Time is a common example:

function can_cancel(order):
    return clock.now() < order.shipping_deadline

The rule looks small, but its answer can change between calls because it reads the clock internally.

Passing the time makes the dependency visible:

function can_cancel(order, now):
    return now < order.shipping_deadline

The same principle applies to values such as:

  • configuration that affects the decision;
  • feature state;
  • exchange rates already retrieved from an external source;
  • permissions or identity information;
  • random choices when reproducibility matters.

Do not pass every global detail through every function merely to achieve theoretical purity. Make an input explicit when it is part of the rule being evaluated and hiding it makes behavior harder to understand, reproduce, or test.

Keep effect descriptions at the right level

Decision code sometimes needs to request work without knowing how that work is performed.

For example:

function evaluate_order(order):
    if order.total_cents >= 10000:
        return ApproveAndNotifyCustomer

    return Approve

This can be useful when the distinction is genuinely part of policy. But there is a trap: returning low-level commands can move infrastructure details into the decision layer.

Avoid results like:

ExecuteSql("UPDATE orders ...")
SendHttpPost("https://...")

Those results couple the policy to implementation mechanics even though the calls happen elsewhere.

Prefer domain-level intent:

ApproveOrder
NotifyCustomer
ReserveInventory

The effectful boundary decides whether NotifyCustomer means email, a queued message, an internal API call, or something else.

The separation works when the inner result says what the application intends, while the outer code knows how this environment carries out that intent.

Test policy without rebuilding the environment

Once the decision receives ordinary values, tests can focus directly on boundary cases.

For the expiry rule:

expires_at = 2026-09-10 12:00

now = 2026-09-10 11:59 -> active
now = 2026-09-10 12:00 -> expired
now = 2026-09-10 12:01 -> expired

These tests explain the rule more clearly than a test that boots a database, inserts a subscription, configures a fake mail server, manipulates a clock dependency, runs a job, and then inspects several outputs.

That does not make integration tests unnecessary.

The outer layer still needs tests that answer different questions:

Does the repository load the correct data?
Does Expire cause the expected persistent update?
Is the notification handed to the correct delivery mechanism?
What happens when a write or message fails?

Separating decisions from effects therefore changes the testing mix. It lets many policy cases use small deterministic tests while reserving integration tests for the boundaries and coordination they are actually meant to verify.

Do not mistake separation for atomicity

A clean decision does not solve failures between effects.

Suppose the decision says:

Expire subscription
Notify customer

The outer code might perform:

1. update database
2. send email

If step 1 succeeds and step 2 fails, extracting pure decision logic has not made those operations atomic.

This is an operational problem that needs an operational design. Depending on the system, that might involve retryable work, an outbox, idempotent consumers, a transaction where all relevant changes share one transactional resource, or another coordination strategy.

Keep the guarantees separate:

decision separation -> makes policy easier to reason about
transaction/reliability mechanism -> controls partial effect failures

Conflating them leads to false confidence. A well-tested decision can still be surrounded by unreliable effect handling.

Decide where failures belong

Observation itself can fail. Storage may be unavailable. A remote service may time out. Configuration may be malformed.

Those failures normally occur before the decision has enough valid input to run:

subscription = repository.load(id)  // may fail
rate = pricing_service.current_rate() // may fail

decision = choose_price(subscription, rate)

The decision should not invent an answer for missing information unless fallback behavior is itself part of the policy.

For example, “use yesterday’s cached rate when the live rate is unavailable” is a real decision rule if the product requires it. In that case, pass enough information to express the distinction explicitly. Otherwise, infrastructure failure should remain an infrastructure failure rather than being disguised as a normal policy outcome.

This boundary makes error handling clearer: failures to obtain trustworthy inputs are different from valid decisions such as RejectOrder or ExpireSubscription.

Avoid turning the outer layer into a giant script

Separating policy from effects can be overdone.

A common failure mode is one enormous coordinator that loads twenty values, invokes dozens of tiny pure functions, and manually wires every result to every side effect. The individual functions look simple while the real application behavior becomes concentrated in an unreadable orchestration procedure.

The answer is not to mix everything together again. Group behavior around meaningful operations.

A checkout use case, for example, can have a coordinator that owns one coherent workflow:

load checkout inputs
calculate checkout decision
persist accepted result
request required follow-up work

Inside the decision, related rules can be composed into appropriately sized functions or objects. Outside it, infrastructure adapters can encapsulate storage or messaging mechanics.

The useful unit is not “the smallest possible pure function.” It is a boundary that makes an important decision understandable without forcing the reader to simulate unrelated I/O.

Know when direct code is simpler

Not every effectful function needs this pattern.

Consider:

function rename_file(old_path, new_path):
    filesystem.rename(old_path, new_path)

If the operation contains no meaningful policy beyond invoking the filesystem, extracting a pure rename_decision adds ceremony without isolating useful logic.

The technique becomes valuable when effectful code contains decisions with several cases, boundary conditions, or rules that deserve independent reasoning.

A practical signal is test friction. If testing a small rule requires extensive mocks or setup for collaborators that do not affect the rule’s answer, the decision boundary may be hidden inside the effectful code.

Another signal is change friction. If changing a policy repeatedly requires editing code that also handles transport, persistence, or framework details, separating the two concerns can reduce the number of concepts involved in each change.

Watch for common mistakes

One mistake is mocking every dependency instead of changing the boundary. Mocks can be appropriate for interaction tests, but a large mock graph around a deterministic rule often indicates that the rule has not been given explicit inputs.

Another is moving side effects without removing hidden dependencies. A function that no longer writes to a database but still reads global time, mutable configuration, and process state may remain difficult to reproduce.

A third is returning infrastructure instructions from policy code. That preserves physical separation while keeping conceptual coupling.

Finally, avoid pursuing purity as a goal by itself. The design should improve comprehension, testing, or changeability. If separating a small operation creates more indirection than understanding, keep the direct implementation.

Use the boundary to clarify responsibility

Separating decisions from side effects is useful because it gives two kinds of code different jobs.

Decision code answers questions such as:

Is this subscription expired?
Which discount applies?
Should this request be accepted?
What domain actions are required?

Effectful code answers different questions:

Where do the inputs come from?
How is the result persisted?
How is a message delivered?
What happens when an external operation fails?

Keeping those questions apart does not remove complexity. It places each kind of complexity where it can be reasoned about with the right tools.

Start with one rule buried inside I/O-heavy code. Pass its meaningful inputs explicitly and return a result that expresses domain intent. Let the surrounding code observe the world and carry out the effects. If that boundary makes the rule easier to explain, test, and change, keep it. If it merely adds forwarding functions around trivial I/O, prefer the simpler design.