Split Phase Refactoring to Separate Computation Stages

A function starts by interpreting input, then gradually accumulates validation, business rules, formatting, and output logic. None of those steps is necessarily complicated. The difficulty comes from having them interleaved: changing how input is interpreted can unexpectedly affect code that should only care about the interpreted result.

Split Phase is a refactoring that separates one computation into distinct stages. The first phase produces an explicit intermediate result. The next phase consumes that result without needing to know how it was produced.

This article shows how to recognize a useful phase boundary, introduce the smallest intermediate representation, and decide when the extra structure is worth keeping.

The mental model: separate transformation from use

Imagine a pricing function that receives text from an import file. It parses the quantity and unit price, decides whether a bulk discount applies, and returns the total.

A simplified version might look like this:

function calculateTotal(row):
    quantity = parseInteger(row["quantity"])
    unitPrice = parseMoney(row["unit_price"])

    if quantity >= 100:
        unitPrice = unitPrice * 0.90

    return quantity * unitPrice

This function contains two different kinds of work:

  1. Interpret an external representation: strings in an imported row become program values.
  2. Apply pricing rules to those values.

The distinction matters because the two parts change for different reasons. A new import format might rename fields or represent money differently. A pricing change might alter the discount threshold. When both concerns live in one function, each change requires understanding code from both stages.

Split Phase makes the handoff visible:

function parseOrderLine(row):
    return OrderLineInput(
        quantity = parseInteger(row["quantity"]),
        unitPrice = parseMoney(row["unit_price"])
    )

function calculateTotal(input):
    unitPrice = input.unitPrice

    if input.quantity >= 100:
        unitPrice = unitPrice * 0.90

    return input.quantity * unitPrice

The OrderLineInput value is the boundary between the phases. Parsing can now change without rewriting the pricing calculation, as long as it continues to produce the same meaningful values.

That is the core idea: one phase should finish enough work that the next phase can operate on its result rather than reaching back into the previous phase’s concerns.

Find a boundary where the meaning changes

Not every sequence of statements deserves separate phases. A useful boundary usually appears where data changes meaning or responsibility.

In the example, row["quantity"] is transport-shaped data. It is a string found under a particular field name. After parsing, quantity is a number the pricing rules can reason about. That change in meaning creates a natural boundary.

Other common boundaries include:

  • reading configuration, then applying it;
  • parsing a command, then executing the requested operation;
  • collecting raw measurements, then evaluating rules over them;
  • resolving names or identifiers, then performing domain calculations;
  • preparing a rendering model, then formatting output.

The strongest signal isn’t merely that a function is long. Look for groups of statements that use different vocabularies and have different reasons to change.

Suppose a report function contains variables such as csvColumns, delimiter, and rawDate near the top, then later uses invoiceTotal, overdueDays, and riskLevel. The vocabulary shift suggests that representation concerns and business interpretation may be mixed together.

Introduce the smallest useful intermediate result

The intermediate result is what keeps the phases independent. It should contain what the next phase needs, not a copy of everything the first phase happened to see.

Consider an import row with many fields:

{
    "sku": "A-104",
    "quantity": "120",
    "unit_price": "25.00",
    "warehouse": "west",
    "source_file": "orders-17.csv"
}

If pricing only needs quantity and unit price, passing the entire row into the pricing phase preserves unnecessary coupling. The pricing code can still start depending on CSV field names, source metadata, or other details.

A narrower handoff is more useful:

OrderLineInput(
    quantity = 120,
    unitPrice = 25.00
)

This doesn’t mean every intermediate result needs a dedicated class. Depending on the language and the size of the code, a record, struct, tuple, or small immutable value can be enough. The design requirement is simpler: the handoff should express the information the next phase is allowed to depend on.

A named representation becomes more valuable when several values travel together, when their meaning isn’t obvious from primitive types, or when the boundary is expected to remain useful as the code evolves.

Move behavior one phase at a time

Split Phase is easier to apply safely when the refactoring is mechanical before it becomes architectural.

Start with the existing behavior and identify the candidate boundary. Extract the statements for one stage into a separate function while preserving their order and behavior. Then make the data crossing the boundary explicit. Only after tests still pass should you simplify either side.

For the pricing example, a practical sequence is:

  1. Extract parsing into parseOrderLine.
  2. Return the parsed values together.
  3. Change calculateTotal to accept only those values.
  4. Run the relevant tests.
  5. Remove any remaining dependency from pricing back to the raw row.

This order matters. If you change parsing rules, pricing rules, and structure at the same time, a failing test no longer tells you whether the problem came from the refactoring or the behavior change.

The refactoring itself should preserve observable behavior. Once the boundary is stable, later changes can deliberately alter one phase.

A phase boundary can become a testing boundary

Separating phases often makes tests more focused because each stage has a smaller contract.

The parsing phase can be tested with representation-specific cases:

input:  { "quantity": "120", "unit_price": "25.00" }
output: OrderLineInput(quantity = 120, unitPrice = 25.00)

The pricing phase can be tested without constructing import rows:

input:  OrderLineInput(quantity = 120, unitPrice = 25.00)
output: 2700.00

Here the calculation assumes a 10% bulk discount: 120 × 25.00 × 0.90 = 2700.00.

This separation improves failure diagnosis. If a field is malformed, parsing tests should expose it. If the discount threshold is wrong, pricing tests can exercise that rule directly. An end-to-end test can still verify that the phases work together; Split Phase doesn’t remove the need to test important integrations.

Avoid turning every internal phase into a permanently frozen public contract. If the intermediate representation is private to a module, tests can focus on useful behavior without making incidental implementation details expensive to change.

Keep failures in the phase that can explain them

A clean phase boundary also clarifies error handling.

Suppose quantity contains "many". The pricing phase cannot make a meaningful decision about that text. Parsing is the stage that understands the external representation, so it should reject or report the invalid value before handing data onward.

After a successful handoff, the next phase should be able to rely on the guarantees the first phase established. For example, OrderLineInput.quantity might be guaranteed to be an integer. If the domain also requires quantity to be positive, decide deliberately which phase owns that rule.

A useful distinction is:

  • representation validity belongs near parsing: “Is this text a valid integer?”
  • domain validity belongs near the domain rule: “May an order line have zero quantity?”

The exact placement depends on the system, but mixing both questions everywhere weakens the boundary. A phase should either establish a guarantee or consume it; callers shouldn’t repeatedly guess whether the guarantee holds.

Don’t split phases that still need each other’s internals

A weak Split Phase refactoring creates two functions but leaves them tightly entangled.

For example:

parsed = parseOrderLine(row)
calculateTotal(parsed, row)

If calculateTotal still receives the raw row, the supposed second phase can bypass the intermediate result. The structural split exists, but the dependency hasn’t changed.

Another warning sign is shared mutable state. If the first phase writes several temporary fields into an object and the second phase reads whichever fields happen to have been populated, understanding the handoff still requires tracing hidden state. Passing an explicit result usually makes the dependency easier to see.

The goal isn’t two functions. The goal is a one-way relationship: phase A produces a result, and phase B works from that result.

Know when a simple function is better

Split Phase introduces names, a handoff, and often another data structure. Those additions have a maintenance cost.

If a function performs a short, obvious sequence and both parts always change together, splitting it may make the code harder to follow. A three-line conversion immediately followed by one calculation doesn’t automatically need an intermediate type.

The refactoring earns its keep when separation removes a real source of coupling. Good reasons include independent change pressure, repeated use of one phase, complicated setup for testing, or representation details leaking into logic that shouldn’t depend on them.

Be cautious about speculative phase boundaries. If you don’t yet understand where responsibilities differ, extracting arbitrary stages can produce abstractions that later have to be undone. First identify what changes independently; then shape the boundary around that evidence.

Watch for phase boundaries that leak too much

Even a legitimate split can become awkward if the intermediate result exposes details from the wrong side.

Suppose the parser returns this:

ParsedLine(
    quantityText = "120",
    quantityColumnIndex = 4,
    unitPriceText = "25.00"
)

The next phase still has to understand textual representation and column layout. The parser hasn’t completed its job; it has mostly renamed its temporary variables.

Compare that with:

OrderLineInput(
    quantity = 120,
    unitPrice = 25.00
)

Now the second phase receives values in its own vocabulary. That doesn’t guarantee a perfect design, but it gives the boundary a useful property: changes to column positions or numeric syntax can remain inside parsing.

The same test applies in other contexts. Ask, “If the implementation of phase A changes while preserving the meaning of its output, does phase B need to change?” If the answer is routinely yes, the handoff may still expose too much.

Use Split Phase to make the next change local

The practical value of Split Phase appears when the system changes.

Suppose the import source moves from CSV rows to messages whose fields are already typed. With the phases separated, you can replace or add the first phase so that it produces OrderLineInput. The pricing phase doesn’t need to learn about the new transport format.

Or suppose the bulk discount becomes tiered. That change belongs in the pricing phase. Tests for parsing don’t need to change merely because the business rule changed.

This is the result to look for when deciding whether the refactoring worked: a change that belongs to one stage can usually be made by understanding and editing that stage, while the other stage keeps its existing contract.

The next time a function feels difficult because several kinds of work are interleaved, don’t start by splitting it at an arbitrary line count. Find where the data changes meaning, make that handoff explicit, and see whether the code on each side can then change for its own reasons.