A function can report an error and still leave trouble behind. The first few steps may have changed state before a later step failed, so the caller receives a failure while the system now contains a mixture of old and new values.

This is partial state: an operation did not complete, but some of its intended changes became visible. Partial state makes retrying, debugging, and reasoning about invariants harder because “the operation failed” no longer tells you what state remains.

A useful design goal is to make important operations behave more like a single change: validate and prepare first, then make the smallest possible state change once success is known. This article explains that mental model, where it works, and what to do when true all-or-nothing behavior is not available.

See failure as a state transition problem

Consider a simplified account update:

change_contact(account, email, phone):
    account.email = normalize_email(email)
    account.phone = normalize_phone(phone)

Now suppose normalize_phone can reject malformed input. If email assignment happens first, a bad phone number produces this sequence:

old email, old phone
        |
        | update email
        v
new email, old phone
        |
        | phone validation fails
        v
new email, old phone   + error

The error is real, but so is the email change. A caller that interprets failure as “nothing changed” now has the wrong model.

The central question is therefore not only can this operation fail? It is also what state is observable after each possible failure?

That question exposes many bugs that ordinary happy-path reasoning misses.

Prepare before you publish

For in-memory state, a simple improvement is to calculate and validate the new values before changing the object:

change_contact(account, email, phone):
    new_email = normalize_email(email)
    new_phone = normalize_phone(phone)

    account.email = new_email
    account.phone = new_phone

If either normalization step fails, the account has not changed. Only after both values are ready does the function publish them to the object.

The mental model is:

  1. Check whether the requested change is allowed.
  2. Prepare everything that might fail without exposing the new state.
  3. Commit the prepared result with as little fallible work as practical.

This is not a special language feature. It is an ordering discipline. Move uncertain work earlier and visible mutation later.

For a richer object, preparing one replacement value can make the boundary even clearer:

change_contact(account, email, phone):
    new_contact = ContactDetails.create(email, phone)
    account.contact = new_contact

Here ContactDetails.create can validate and normalize both fields. If construction fails, the existing account.contact remains untouched. If it succeeds, one assignment publishes the complete replacement.

The example is deliberately simple. In production code, whether the final assignment itself can fail depends on the language, runtime, concurrency model, and surrounding infrastructure. The useful principle is to minimize the amount of failure-prone work after visible mutation begins.

Protect invariants, not individual assignments

The reason partial state matters is usually an invariant: a rule that should remain true whenever other code can observe the object.

Suppose a reservation keeps both a status and the time at which it was confirmed:

status = "pending"
confirmed_at = none

The intended rule is:

status == "confirmed"  <=>  confirmed_at is present

A fragile operation might update the fields separately around fallible work:

confirm(reservation):
    reservation.status = "confirmed"
    timestamp = clock.current_time()   // may fail in this example
    reservation.confirmed_at = timestamp

If obtaining the timestamp fails, the reservation says it is confirmed but has no confirmation time. The problem is not that one assignment failed. The problem is that the operation exposed a state the model says should not exist.

A better ordering prepares the timestamp first:

confirm(reservation):
    timestamp = clock.current_time()
    reservation.mark_confirmed_at(timestamp)

The object’s mutation method can then update the related state together, or replace one value that represents the confirmed state. The important design decision is to keep the invariant intact at observable boundaries.

This also suggests a review technique: when reading a multi-step operation, write down the invariants it is responsible for, then inspect every failure point after the first mutation. Ask whether a failure there can expose an invalid or misleading state.

Separate reversible preparation from irreversible effects

The pattern becomes more important when an operation mixes local changes with external effects.

Imagine fulfilling an order requires these actions:

reserve inventory
charge payment
mark order fulfilled
send confirmation

These steps do not share one automatic rollback mechanism. A payment provider cannot generally be rolled back by restoring an in-memory variable. An email that has already been sent cannot be unsent. Treating the whole sequence like a local assignment hides important failure semantics.

Classify steps by what failure means.

Preparation gathers information and constructs values without committing externally visible effects. Examples include parsing input, checking local rules, calculating totals, and building a request.

Commit-like changes make authoritative state visible. A database transaction commit is one example when the affected data is covered by that transaction.

Irreversible or independently committed effects cross boundaries where ordinary rollback is unavailable. Charging an external payment method or publishing a message may fall into this category, depending on the system’s guarantees.

Once an operation crosses such a boundary, “do all fallible work first” is no longer sufficient. Some later step can still fail after an earlier external effect succeeded.

The design must then state what partial completion means instead of pretending it cannot happen.

Choose an explicit failure strategy

There are several valid strategies for multi-step work. The right choice depends on the guarantees the underlying components can provide.

Use an atomic mechanism when the boundary supports it

If related changes belong to one transactional resource, use that resource’s transaction semantics rather than recreating them manually. For example, multiple changes covered by one database transaction can normally be committed or rolled back as a unit according to that database’s transaction guarantees.

Keep the scope precise. A database transaction does not automatically include an HTTP request to another service, a file write, or a message sent through an unrelated system.

Compensate when an earlier effect cannot simply be rolled back

Sometimes an operation must perform effect A before effect B, and B can fail. A compensating action attempts to counteract A.

For example:

charge payment
try:
    reserve shipment
catch:
    request payment refund
    report failure

A refund is not the same thing as erasing the original charge. It is a new operation with its own possible delay or failure. Compensation therefore gives different semantics from atomic rollback. The system may need an explicit state such as refund_pending so operators and callers can see that recovery is incomplete.

Record progress when partial completion is a real business state

For long-running workflows, hiding intermediate states may be impossible or undesirable. Model them explicitly:

pending
  -> payment_captured
  -> fulfillment_requested
  -> fulfilled

If fulfillment fails after payment succeeds, payment_captured is not corruption if the workflow deliberately defines what happens next. A retry worker or operator can continue from that state.

This is a key distinction: partial state is dangerous when it is accidental or ambiguous. An explicit intermediate workflow state can be correct when its meaning and recovery path are part of the design.

Design retries around the remaining state

Retries expose weak failure semantics quickly.

Suppose this operation fails after appending an item but before recalculating a total:

add_item(cart, item):
    cart.items.append(item)
    cart.total = price(cart.items)

If price fails and the caller retries the whole operation, the same item may be appended twice. The retry is not safe because the failed attempt left a visible change.

Preparing first avoids that particular problem:

add_item(cart, item):
    new_items = cart.items + [item]
    new_total = price(new_items)
    cart.replace_contents(new_items, new_total)

If pricing fails, the cart stays unchanged. If it succeeds, the object receives a complete prepared state.

For external systems, safe retry may require additional mechanisms such as idempotency keys, operation identifiers, deduplication, or explicit workflow state. Those mechanisms solve a broader distributed-systems problem; the local lesson remains the same: define what a failed attempt may have changed before deciding that retrying is safe.

Common mistakes

One mistake is to catch an exception without repairing the state:

try:
    update_a()
    update_b()
catch error:
    log(error)
    return failure

The catch block changes the control flow, not the effects that already happened. If update_a succeeded, logging does not undo it.

Another mistake is manual rollback that can itself fail:

old_value = object.value
object.value = new_value
try:
    do_more_work()
catch:
    object.value = old_value

This can be reasonable for simple, reliable local mutation, but it becomes fragile when restoration has side effects, concurrent observers can see the temporary value, or several resources must be restored in the correct order. Prefer preparing before mutation when that is practical.

A third mistake is making the commit phase too large. If the supposedly final phase still performs parsing, network calls, callbacks, or complex calculations, many failure points remain after state starts changing. Move those operations into preparation where possible.

Finally, do not force atomic-looking behavior onto a workflow whose intermediate states matter. A long-running process that spans independent services often needs explicit progress and recovery rather than an illusion of one indivisible operation.

When the simpler approach is enough

Not every function needs a formal prepare-and-commit structure. Direct mutation is often clear enough when each assignment is independent, later failures cannot invalidate earlier changes, and callers do not rely on all-or-nothing semantics.

For example, updating an optional display preference and recording a separate diagnostic counter may not need to behave as one unit. Combining them into a transaction-like abstraction could add complexity without protecting a meaningful invariant.

Use stronger structure when several changes form one logical decision, failure can occur between them, and partial completion would confuse callers, violate an invariant, make retries unsafe, or require difficult recovery.

A practical review method

When designing or reviewing a fallible operation, trace it in execution order and ask four questions:

  1. What state is visible before the operation starts?
  2. Which steps can fail?
  3. What has already changed at each failure point?
  4. Is that remaining state valid, understandable, and recoverable?

If the answer to the fourth question is no, first try reordering the work so validation and preparation happen before mutation. Then reduce the visible commit to the smallest coherent change. If external effects prevent that, choose explicit transaction, compensation, retry, or workflow semantics instead.

Conclusion

Good error handling is not only about returning the right error. It is also about leaving the system in a state whose meaning is clear after failure.

For local operations, a strong default is to check, prepare, then commit: perform fallible work before visible mutation and publish a complete state only when the required pieces are ready. For operations spanning independent resources, make partial completion explicit and design recovery around the guarantees those resources actually provide.

The practical test is simple: at every point where the operation can fail, you should be able to explain what state remains and what the caller or system should do next.