A function often starts with one job and gradually becomes a pipeline hidden inside a block of code. It reads raw input, interprets it, applies business rules, prepares parameters, performs an external action, and formats the result. Each step may be reasonable, but mixing them makes the whole function harder to understand and change.

A useful response is Split Phase: separate work that happens for different reasons into explicit stages, and pass a meaningful result from one stage to the next.

The goal is not to make functions shorter for its own sake. The goal is to expose a boundary that already exists conceptually. After reading this article, you should be able to recognize that boundary, choose what data should cross it, and decide when splitting a phase improves a design rather than merely moving code around.

See the hidden pipeline first

Consider a simplified order-dispatch operation:

dispatch_order(raw_request):
    order_id = trim(raw_request.order_id)
    priority = parse_priority(raw_request.priority)

    order = load_order(order_id)
    carrier = choose_carrier(order, priority)
    label = create_shipping_label(order, carrier)

    send_to_printer(label)
    mark_dispatched(order.id, carrier.id)

This code performs several kinds of work. The first lines interpret input. The middle lines gather information and decide how the order should be dispatched. The final lines cause observable effects.

Nothing about a seven-line function is automatically bad. The design pressure appears when these parts change independently. Perhaps request parsing changes because an API evolves, carrier selection changes because business rules change, and printing changes because hardware is replaced. A single function then has several reasons to change and several kinds of failure to reason about at once.

The useful question is not “How many helper functions can we extract?” It is:

Is there a point where one kind of work is complete and another kind can begin using a well-defined result?

That point is a candidate phase boundary.

Think in terms of input, transformation, and handoff

A phase takes an input, performs one coherent kind of work, and produces something the next phase can use.

For the dispatch example, one possible boundary is between planning and execution:

plan = plan_dispatch(raw_request)
execute_dispatch(plan)

The first phase decides what should happen. The second performs the effects required by that decision.

The handoff might be represented as:

DispatchPlan:
    order_id
    carrier_id
    shipping_label

Now the relationship between the phases is visible. execute_dispatch does not need the original request, the carrier-selection rules, or the full order object. It receives the information needed to carry out an already-made decision.

This is more than extracting two blocks into named functions. The DispatchPlan creates an explicit contract between them.

Choose the boundary by reason for change

A good split usually separates work that changes for different reasons.

Suppose carrier selection depends on order weight, destination, requested priority, and current business rules. Printing and persistence depend on infrastructure. Those concerns can evolve independently, so planning and execution form a useful boundary.

The planning phase could look like this:

plan_dispatch(raw_request):
    order_id = trim(raw_request.order_id)
    priority = parse_priority(raw_request.priority)
    order = load_order(order_id)
    carrier = choose_carrier(order, priority)
    label = create_shipping_label(order, carrier)

    return DispatchPlan(
        order_id = order.id,
        carrier_id = carrier.id,
        shipping_label = label
    )

Execution becomes narrower:

execute_dispatch(plan):
    send_to_printer(plan.shipping_label)
    mark_dispatched(plan.order_id, plan.carrier_id)

The exact split is a design choice. Loading the order is still an external read, so this is not a pure-function boundary. That may be acceptable if the practical goal is to isolate business preparation from write-side effects. If deterministic planning is important, the order lookup could happen earlier and a separate calculation phase could receive already-loaded data.

The principle is to choose the boundary that matches the problem you are trying to solve, not to chase a theoretically perfect separation.

Make the handoff smaller than the first phase

A common mistake is to split a function but pass almost everything into the second phase:

execute_dispatch(raw_request, order, priority, carrier, label, config)

This preserves most of the original coupling. The second phase can still depend on details from every earlier step, and future changes can easily leak across the boundary.

Prefer a handoff that represents the outcome of the first phase:

DispatchPlan(order_id, carrier_id, shipping_label)

The difference is important. A collection of intermediate variables describes how the first phase worked. A plan describes what the first phase decided.

A useful phase result should contain enough information for the next phase to do its job, but not every value that happened to be available during preparation.

This makes dependencies easier to see. If execution later needs warehouse_id, adding it to DispatchPlan is an explicit contract change rather than an unnoticed reach back into preparation state.

Keep ownership of validation clear

Splitting phases creates a new question: which phase is responsible for rejecting invalid data?

Validate a condition in the phase that has enough context to own that condition.

For example, parsing a priority string belongs near input interpretation:

priority = parse_priority(raw_request.priority)

Checking whether a carrier can serve the order’s destination belongs in planning, where the order and carrier rules are known.

Execution should normally be able to rely on the structural guarantees of a valid DispatchPlan. It may still need to handle operational failures such as a printer being unavailable or a persistence write failing. Those are not invalid-plan errors; they are failures that can occur while performing the plan.

This distinction helps error handling become more precise. Preparation errors say, in effect, “we cannot construct a valid action.” Execution errors say, “we had a valid action, but carrying it out failed.”

Splitting phases can improve testing

A mixed function often forces one test to arrange everything: request data, order storage, carrier rules, printer behavior, and dispatch persistence. That can make a small business-rule test depend on unrelated infrastructure.

After a useful split, tests can target different questions.

Planning tests can verify decisions:

given a heavy order and express priority
when plan_dispatch is called
then the plan selects the freight carrier

Execution tests can verify effects:

given a valid DispatchPlan
when execute_dispatch is called
then its label is printed
and its order is marked dispatched with the planned carrier

The benefit comes from the design boundary, not from testing private implementation steps. If tests simply assert every intermediate value produced by the old function, the refactoring may make tests more brittle rather than more useful.

Test the contract and behavior of each meaningful phase.

Be precise about failure between phases

A phase boundary can also become an operational boundary. If planning and execution happen in the same call and memory, a DispatchPlan may exist only for a few microseconds. If the phases are separated by a queue, database, file, or process boundary, the plan becomes durable data with a longer lifetime.

That changes the engineering problem.

A durable handoff may need to answer questions such as:

  • Can the same plan be executed twice?
  • Can the data referenced by the plan change before execution?
  • How is an old plan recognized after its schema changes?
  • What happens if one execution effect succeeds and another fails?

Split Phase does not solve those problems automatically. It only makes the boundary explicit. Once the boundary crosses time or process space, reliability concerns such as retries, idempotency, version compatibility, and partial failure need deliberate designs of their own.

Do not introduce a queue merely because the code now has two phases. A function boundary and a distributed-systems boundary have very different costs.

Avoid splitting at arbitrary line counts

Shorter functions can be easier to scan, but line count is a weak guide for phase design.

Consider this extraction:

step_one(request)
step_two(request)
step_three(request)

If every step receives the same large mutable object and each one reads and changes fields that the others depend on, the pipeline is still implicit. The order of calls remains part of the hidden contract, and no phase has a clear result.

A stronger split gives each phase a meaningful responsibility and makes its output explicit:

validated = validate_request(request)
plan = build_dispatch_plan(validated)
execute_dispatch(plan)

Even this should not be applied mechanically. If validation and planning are inseparable because the rules require the same context and always change together, combining them may be clearer.

The purpose of the split is to reveal meaningful stages, not to maximize their number.

Watch for mutable handoff objects

An explicit phase object loses much of its value if every later stage can freely rewrite it.

Suppose DispatchPlan is created with one carrier_id, but execution silently replaces that carrier after a timeout. The object no longer represents a stable planning decision. Readers cannot tell whether the carrier field is an input, a suggestion, or temporary scratch state.

When practical, treat the handoff as a value describing a completed decision. If execution needs to produce new information, return or record a separate result rather than mutating the plan into a different meaning.

For example:

plan = plan_dispatch(request)
result = execute_dispatch(plan)
record_dispatch_result(result)

This is not a rule that every phase object must be immutable in every language. The broader point is semantic: data crossing a phase boundary should have a clear meaning that does not quietly change underneath later code.

Know when a simpler function is better

Split Phase adds names, functions, and often a new data structure. Those additions are useful only when they clarify a real boundary.

Keep a straightforward function when the work is small, the steps change together, the intermediate data has no meaningful identity, and separating the stages would force readers to jump through several abstractions to understand one simple operation.

Consider splitting when you see one or more stronger signals:

  • one section changes for different reasons from another;
  • later code depends on only a small result from a large preparation step;
  • business decisions and side effects are difficult to test independently;
  • intermediate variables collectively represent a concept that deserves a name;
  • one stage may eventually need a different execution strategy;
  • error handling becomes clearer when preparation failures and execution failures are distinguished.

These signals point to conceptual separation. They are more useful than rules about function length or nesting depth.

Refactor in small steps

You do not need to discover the final design before changing the code.

Start by identifying the likely boundary. Extract the later work into a function without changing behavior. Observe which values that function actually needs. Group those values into a phase result only if they form a coherent concept. Then narrow the new function so it depends on that result instead of reaching back into earlier state.

For the example, a safe sequence is:

1. Extract the printing and persistence lines into execute_dispatch.
2. Pass the existing variables required by that function.
3. Introduce DispatchPlan to represent those values together.
4. Make the planning code return DispatchPlan.
5. Remove parameters and temporary state that no longer cross the boundary.

Run the relevant tests after each behavior-preserving step. This keeps a design refactoring separate from intentional behavior changes and makes mistakes easier to locate.

Use phase boundaries to make reasoning local

The strongest benefit of Split Phase is local reasoning.

When reading plan_dispatch, a developer can focus on the question “What should we do?” without also tracing printer behavior and persistence updates. When reading execute_dispatch, the developer can focus on “How do we carry out this plan?” without reconstructing carrier-selection rules.

That separation also gives future changes a natural home. A new carrier rule belongs in planning. A new printer adapter belongs in execution. If a change repeatedly touches both phases, that is useful feedback: either the concern genuinely spans the boundary or the boundary may be in the wrong place.

Split Phase is therefore not primarily a technique for producing smaller methods. It is a way to turn an implicit sequence of responsibilities into explicit contracts. Use it when a function contains stages with different reasons to change, give each stage a meaningful input and output, and keep the handoff focused on what one phase has decided rather than how it reached that decision.