A function reads an order, checks inventory, chooses a shipping method, updates a record, sends an email, and writes a log entry. A small rule change arrives: express shipping is now allowed only when every item is in stock.

The rule itself is simple. Testing it is not. To exercise the decision, a test may need a database, an inventory service, an email substitute, and careful setup for several unrelated operations.

This is often a design problem rather than a testing-tool problem. Decision logic and side effects have been mixed into the same unit of code.

A useful alternative is to separate them. Let one part of the program gather facts and perform effects, while another part decides what should happen from the facts it receives. This article explains that mental model, how to apply it without overengineering, and where the boundary stops being useful.

Think in three stages

Many application operations can be understood as three stages:

observe -> decide -> act

Observe means obtaining facts from outside the decision: reading a record, checking the current time, receiving an API request, or asking another component for information.

Decide means applying rules to those facts. Given the same relevant facts, the decision should produce the same result.

Act means changing or interacting with the outside world: writing a record, sending a message, calling another service, or emitting an event.

The useful distinction is not between “important code” and “unimportant code.” All three stages can matter. The distinction is between code whose result can be computed from explicit inputs and code whose behavior depends on external state or changes it.

For example:

inventory status ----\
order total ---------- > choose shipping method -> shipping decision
customer preference --/

Choosing the shipping method can be ordinary deterministic logic. Fetching inventory and storing the chosen method are side effects around that logic.

When those responsibilities are separated, the decision can be understood without reproducing the environment in which it runs.

Start with the smallest useful decision

Consider a simplified shipping rule:

  • use express shipping when the customer requested it and every item is in stock;
  • otherwise use standard shipping.

A language-neutral version of the decision can be tiny:

chooseShipping(requestedExpress, allItemsInStock):
    if requestedExpress and allItemsInStock:
        return EXPRESS

    return STANDARD

This example is intentionally simple. Its purpose is to show the boundary, not to recommend a production shipping model.

The function does not fetch inventory. It does not update an order. It does not send a confirmation. Those operations are outside the decision.

That gives the function a useful property: its behavior is determined by its inputs. The important cases are easy to enumerate:

requested express | all in stock | result
------------------|--------------|---------
false             | false        | STANDARD
false             | true         | STANDARD
true              | false        | STANDARD
true              | true         | EXPRESS

A test for this rule needs values, not infrastructure. If the rule changes, the test failures point directly at the decision being changed.

Put orchestration around the decision

Real work still needs side effects. Separating them does not remove I/O; it makes its location and purpose clearer.

An application operation might look like this:

shipOrder(orderId):
    order = orders.load(orderId)
    stock = inventory.check(order.items)

    method = chooseShipping(
        order.requestedExpress,
        stock.allItemsInStock
    )

    orders.setShippingMethod(orderId, method)
    notifications.sendShippingConfirmation(orderId, method)

Here, shipOrder coordinates the operation. It reads facts, passes them into the decision, and performs the resulting effects.

The structure is now visible:

orders.load ---------\
inventory.check ------ > chooseShipping -> orders.setShippingMethod
                                      \--> notifications.sendShippingConfirmation

The outer operation is still effectful. That is appropriate: shipping an order is real work, not a mathematical exercise. The improvement is that the shipping rule no longer has to know how inventory is fetched or how notifications are delivered.

This separation gives different kinds of code different jobs:

  • orchestration obtains facts and coordinates effects;
  • decision logic interprets facts and produces a result.

That distinction often reduces the number of reasons one function must change.

Return decisions instead of performing them immediately

A single return value works when a decision produces one simple result. More complicated rules may need to request several actions.

Suppose cancelling an order should refund a payment only when money has already been captured, release inventory only when stock was reserved, and send a cancellation notice in either case.

The decision can return a description of the work:

planCancellation(paymentCaptured, inventoryReserved):
    actions = [SEND_CANCELLATION_NOTICE]

    if paymentCaptured:
        actions.add(REFUND_PAYMENT)

    if inventoryReserved:
        actions.add(RELEASE_INVENTORY)

    return actions

The caller interprets that plan:

actions = planCancellation(paymentCaptured, inventoryReserved)

for action in actions:
    execute(action)

This makes an important question explicit: what did the rules decide, separately from whether executing that decision succeeds?

For example, the rule may correctly request a refund while the payment provider is temporarily unavailable. Those are two different failures:

wrong decision:     refund was not requested when it should have been
execution failure:  refund was requested, but the provider rejected or timed out

Keeping them distinct improves diagnosis. A business-rule defect and an integration failure usually require different fixes.

In production code, an action plan should carry the data needed to perform each action, and its representation should be chosen carefully. The simplified list above only demonstrates the idea.

Make all decision inputs explicit

Separating effects works poorly if supposedly deterministic code secretly reaches back into the environment.

Consider this function:

calculateLateFee(invoice):
    if currentTime() > invoice.dueAt:
        return invoice.balance * 0.02

    return 0

It looks like calculation code, but the result also depends on the clock. Two calls with the same invoice can produce different answers at different times.

Make the time an explicit input instead:

calculateLateFee(invoice, asOf):
    if asOf > invoice.dueAt:
        return invoice.balance * 0.02

    return 0

The outer code can read the clock once and pass the value in:

asOf = clock.now()
fee = calculateLateFee(invoice, asOf)

The same idea applies to randomness, configuration, feature state, locale, exchange rates, and other values that influence a decision. If a fact changes the answer, consider making that fact visible at the decision boundary rather than fetching it invisibly inside the calculation.

This does not mean every low-level function needs dozens of parameters. Group facts into meaningful values when they belong together. The goal is explicit dependency on relevant information, not parameter-count maximization.

The boundary improves reasoning as well as testing

Fast unit tests are a useful consequence, but the design benefit is broader.

Suppose a production incident reports that some eligible orders received standard shipping. With mixed code, debugging may require following database reads, service calls, branching logic, writes, and notifications at the same time.

With a decision boundary, the investigation can split into two questions:

  1. What facts did the operation observe?
  2. Given those facts, did the decision produce the correct result?

If the facts were wrong, investigate observation: perhaps inventory data was stale or a request field was mapped incorrectly. If the facts were right but the result was wrong, investigate the rule. If both were right but the stored outcome was wrong, investigate execution.

The architecture has created useful fault categories:

observe incorrectly -> wrong inputs

decide incorrectly  -> wrong intended action

act incorrectly     -> intended action not carried out correctly

That separation can make logs and tests more informative because they can describe which stage failed rather than treating the whole operation as one opaque behavior.

Do not confuse deterministic logic with harmless logic

A deterministic function can still be wrong, expensive, or unsafe.

For example, a pricing calculation can consistently return the wrong price. A parser can deterministically consume excessive CPU for a pathological input. A pure transformation can allocate too much memory.

Separating side effects provides a reasoning boundary; it does not prove correctness or performance.

Similarly, effectful code is not inherently poor design. Reading data, committing transactions, sending messages, and interacting with users are essential parts of useful software. The goal is not to push side effects away because they are undesirable. It is to prevent them from obscuring decisions that do not need to depend on them.

Keep consistency requirements in the outer design

Some actions must succeed or fail together. Moving decision logic into a deterministic function does not change those consistency requirements.

Suppose an operation decides to debit one balance and credit another. Returning a transfer plan may make the rules easier to test, but executing the two writes independently could still leave inconsistent state if one succeeds and the other fails.

The execution layer must still provide whatever atomicity, transaction, retry, idempotency, or compensation behavior the system requires.

The separation is therefore:

decision layer: what should happen?
execution layer: how do we make it happen with the required guarantees?

Those guarantees are not automatically supplied by the decision layer.

This distinction is especially important when a decision requests multiple external effects. A list of intended actions is not a transaction. It is only a description of intent until the execution mechanism provides the necessary guarantees.

Avoid turning every expression into a new abstraction

Once developers see the pattern, it is easy to overapply it.

This is usually unnecessary:

isPositive(x)
addTax(amount)
formatBoolean(flag)

Creating tiny wrappers around trivial operations can increase navigation and naming overhead without creating a useful boundary.

A separate decision function earns its place when at least one of these is true:

  • the rule has meaningful branches or boundary cases;
  • the rule changes independently of the surrounding I/O;
  • the same decision is needed from more than one workflow;
  • the rule is important enough to test directly;
  • mixing the rule with infrastructure makes failures hard to diagnose.

For a straightforward CRUD operation with almost no decision logic, direct orchestration may be clearer. A handler that validates a simple request and writes one record does not need an elaborate functional core merely to follow a pattern.

Watch for side effects hidden behind innocent names

A boundary is only useful when its contracts are truthful.

A function named calculatePrice is difficult to reason about if it silently writes an audit record. A method named isEligible is surprising if it refreshes remote data. A property getter that performs network I/O makes ordinary-looking expressions depend on latency and failure.

Names alone cannot guarantee purity, but APIs should make important effects visible enough that callers can reason about them.

When reviewing code, ask:

Can this call fail because of the network, disk, clock, or shared mutable state?
Can it change something another part of the program can observe?

If the answer is yes, treat the call as effectful even if its syntax looks like an ordinary calculation.

Apply the pattern incrementally

You do not need to redesign an application into two architectural layers before gaining value.

Start with one operation that is hard to test or reason about because rules and I/O are tangled together.

A practical refactoring sequence is:

  1. Identify the decision you want to understand independently.
  2. List the facts that actually influence that decision.
  3. Read those facts before calling the decision logic.
  4. Make the decision return a value or a description of intended actions.
  5. Perform the effects after the decision.
  6. Test the decision with representative inputs and boundary cases.
  7. Keep integration tests for the effectful path and its required guarantees.

This sequence preserves an important balance. Deterministic tests check the rule efficiently, while integration tests still verify that real dependencies are wired and used correctly.

Do not delete integration coverage merely because the inner decision is well tested. The outer code can still map inputs incorrectly, call the wrong dependency, mishandle failures, or execute the result incorrectly.

Use the boundary where it clarifies a real decision

Separating decisions from side effects is most useful when software contains meaningful rules surrounded by infrastructure: pricing, eligibility, scheduling, validation outcomes, workflow choices, routing, prioritization, or policy evaluation.

The mental model is simple:

observe facts
make the decision
perform effects with the required guarantees

The payoff is not “pure code everywhere.” It is a clearer answer to three practical questions: what facts did we observe, what did our rules decide, and did we carry that decision out correctly?

When those questions can be answered independently, important behavior is often easier to test, debug, and change. When the operation contains no meaningful independent decision, keep the simpler design.