Why long-lived branches fail for large monolith refactors

A large refactor in a monolith rarely touches one module. Renaming a core domain type, splitting a service class, or replacing a persistence layer ripples through call sites owned by many teams. Isolating that work on a long-lived branch looks safe, but it postpones every hard problem to the end.

Divergence grows with two variables: how long the branch lives and how many people commit to trunk meanwhile. Each trunk commit that touches code the refactor also touches is a future conflict. Rebasing regularly helps, but the refactor team ends up spending its time absorbing other people's changes. Conflicts concentrate at merge time, when the context behind each change has faded.

Deploys suffer too. Bug fixes land on trunk and must be ported to the branch, or they are lost at merge. Teams often freeze related areas so the merge can land, which blocks unrelated releases.

Most importantly, the refactored code never meets production traffic, data, or load until merge day. Then the whole change ships at once and is hard to roll back.

Concern Long-lived branch Trunk-based incremental refactoring
Merge conflicts Pile up until merge day Small and resolved daily
Exposure to real traffic Only after merge Early, behind toggles
Impact on other teams' deploys Freezes and backports Normal release cadence
Rollback Revert a large merge Flip a toggle

For backend and platform engineers working in large shared codebases with many contributors, trunk-based incremental refactoring should be the default.

Who should use this approach

Toggle-driven, trunk-based refactoring pays off when three conditions hold together:

  • Many people commit to the same code. If several teams merge to the modules you are changing every week, a branch will diverge quickly. A single developer working in a small repository has little divergence to avoid.
  • The refactor stays incomplete for longer than a few days. Changes that span weeks or months, such as replacing a persistence layer, splitting a large service class, or rewriting a pricing or billing component, are where merge debt builds up.
  • You deploy often and can observe production. The main benefits are early exposure to real traffic and rollback in seconds. Both assume you ship to production regularly and have error rates, logs and metrics that show whether the new path behaves.

In practice, the audience is backend and platform engineers who own core modules in a shared monolith, tech leads planning a multi-week migration, and teams preparing to extract a component from a monolith who need a clean seam first. Teams that release rarely, or that cannot see production behaviour, get much less from this approach.

How it works: branch by abstraction and feature toggles together

Branch by abstraction moves the "branch" out of version control and into the code. Each step is small enough to merge to trunk on its own:

  1. Introduce an abstraction over the code being replaced, such as an interface, a module boundary or a service class. At first it only wraps the existing implementation.
  2. Route every caller through it. This step is tedious but mechanical. Once callers no longer reach past the abstraction, you have a seam where the implementation can change without anyone upstream noticing.
  3. Build the new implementation behind the seam. It merges to trunk alongside the old one, compiled and tested but not yet serving traffic.
  4. Switch over by changing which implementation the abstraction delegates to.
  5. Delete the old implementation, and often the abstraction too, if it existed only to enable the swap.

The routing at runtime looks like this:

callers → abstraction → toggle check → old implementation (default) | new implementation

The abstraction and the toggle do different jobs. The abstraction gives you a single place where a decision can happen. The toggle makes that decision at runtime, so half-finished work can ship to production dark, exercised by tests but invisible to users.

The toggle check needs an explicit fallback. If the toggle service times out or its config fails to load, the seam should return the old implementation. The legacy path is the behaviour you already know. Defaulting to unproven code during an outage of the toggle system turns one incident into two.

import logging
from typing import Protocol

log = logging.getLogger(__name__)

class InvoiceRenderer(Protocol):
    # The seam: callers depend on this, never on a concrete class.
    def render(self, invoice_id: str) -> bytes: ...

class LegacyRenderer:
    def render(self, invoice_id: str) -> bytes:
        ...  # existing monolith code, moved here unchanged

class NewRenderer:
    def render(self, invoice_id: str) -> bytes:
        ...  # replacement, merged to trunk and tested, dark until toggled on

class ToggledRenderer:
    """The abstraction every caller receives. It picks an implementation per call."""

    def __init__(self, toggles, legacy: InvoiceRenderer, new: InvoiceRenderer):
        self._toggles = toggles  # any client exposing is_enabled(name) -> bool
        self._legacy = legacy
        self._new = new

    def render(self, invoice_id: str) -> bytes:
        return self._choose().render(invoice_id)

    def _choose(self) -> InvoiceRenderer:
        try:
            enabled = self._toggles.is_enabled("new-invoice-renderer")
        except Exception:
            # Toggle source timed out or config failed to load:
            # fall back to the behaviour we already trust.
            log.warning("toggle lookup failed; using legacy renderer", exc_info=True)
            enabled = False
        # Only an explicit True switches over; None, "" or junk mean legacy.
        return self._new if enabled is True else self._legacy

A seam (InvoiceRenderer) with a toggle-driven delegator that serves the new implementation only when the toggle is explicitly on and falls back to the legacy path whenever the toggle source is unavailable.

Choosing the right kind of toggle for a refactor

Toggles serve different purposes, and the purpose determines how long a toggle should live and who is allowed to flip it.

Toggle type Purpose Expected lifespan Who changes it
Release Hide incomplete or unproven code paths Days to weeks The team doing the work
Ops / kill switch Disable or degrade a feature under load or failure Can be long-lived On-call engineers, operators
Experiment Compare variants against a measured outcome Length of the experiment Product and engineering together
Permission Gate features by customer, plan or role Often permanent Product, via entitlement data

A refactoring toggle is a release toggle. Its job ends when the new implementation is the only implementation. Every week it survives, you maintain two code paths, test both combinations, and risk it drifting into an informal kill switch that nobody dares delete. Give each one a named owner (a person or team, not a channel) and a removal date recorded next to the flag definition. Open the cleanup ticket on the same day you create the toggle. If you need a kill switch for the new path, create a separate one with its own lifecycle. Do not repurpose the refactoring toggle.

Runtime evaluation adds a dependency. A flag read at request time means your code path now depends on SDK initialisation, network calls to the flag service, and cache freshness. Each toggle should declare three things in code:

  • A default value.
  • What happens when evaluation times out.
  • Whether a stale cached value is acceptable.

Write tests for the "configuration unavailable" case, not only for on and off.

Validating the new path: dark launches, parallel runs and gradual rollout

With safe defaults in place, the toggle can do more than switch paths. It can show you whether the new path is correct before any user depends on it.

Start with a dark launch. The old path still serves the response. The new path runs on the same input, either asynchronously or in a shadow call, and its result is discarded. A parallel run goes further: it compares both outputs and logs any mismatch with enough context to reproduce it. Two rules matter here. The comparison must never block or alter the real response. The new path must not trigger side effects such as writes, emails or payment calls.

Once mismatches are rare and explained, move to a gradual rollout. Route traffic by percentage, or by tenant if your customers' data shapes differ a lot. Hash on a stable key so each user sees consistent behaviour. If error rates rise, flip the toggle back. Rollback takes seconds and needs no redeploy, which is the main reason to choose this approach over a branch.

Testing needs the same discipline:

  • Test both toggle states for any code under change, in CI.
  • Limit concurrent toggles in one module. Three interacting flags produce eight combinations, and most of them will go untested.
  • Align test defaults with production, so a green build reflects what actually runs.

While both paths are live, every bug fix and behaviour change has to land twice. That duplicated effort is real, so keep the window short.

import hashlib
import logging
from concurrent.futures import ThreadPoolExecutor

import pytest

log = logging.getLogger("pricing.migration")
_shadow_pool = ThreadPoolExecutor(max_workers=4)

def in_rollout(key: str, percent: int) -> bool:
    # Stable hash: the same tenant always lands in the same bucket across requests and hosts.
    bucket = int(hashlib.sha256(key.encode()).hexdigest(), 16) % 100
    return bucket < percent

def _shadow_compare(order, expected):
    try:
        actual = new_quote(order, dry_run=True)  # dry run: no writes, emails or payment calls
        if actual != expected:
            log.warning("quote mismatch order=%s old=%r new=%r", order.id, expected, actual)
    except Exception:
        log.exception("new path failed in shadow, order=%s", order.id)

def quote(order, flags):
    # Defaults (0, False) mean old path only. Rollback is setting the percent back to 0.
    if in_rollout(order.tenant_id, flags.get("pricing_v2_percent", 0)):
        return new_quote(order, dry_run=False)
    result = legacy_quote(order)
    if flags.get("pricing_v2_shadow", False):
        # Fire and forget: the comparison never blocks or alters the real response.
        _shadow_pool.submit(_shadow_compare, order, result)
    return result

@pytest.mark.parametrize("percent", [0, 100])
def test_quote_in_both_toggle_states(percent, sample_order, expected_total):
    assert quote(sample_order, {"pricing_v2_percent": percent}) == expected_total

Each request goes to a tenant-hashed percentage rollout. Requests that stay on the old path can run the new path as a non-blocking shadow comparison, and a parametrized test covers both toggle states.

Where toggles break down: schemas, data migrations and shared state

A toggle can only choose between code paths that already exist. It cannot choose between two database schemas, because there is only one database and both paths read and write it at the same time. Shared state works the same way: caches, queues and files written by the new path will be read by the old one the moment the flag flips back. So the schema has to support both paths for the whole transition. The usual way to do that is expand and contract.

Suppose the refactor moves orders.status from free text to a status_id foreign key:

  1. Expand. Add the new structure without touching the old one: ALTER TABLE orders ADD COLUMN status_id INT NULL. Old code ignores the column, so this is safe in either flag state.
  2. Dual-write and backfill. Make both code paths write status and status_id. Then backfill existing rows in batches with UPDATE orders SET status_id = ... WHERE status_id IS NULL, so the batches do not hold long locks.
  3. Switch reads. Move reads to status_id behind the toggle. Rollback is still safe because status is kept current.
  4. Contract. Once the old path is deleted, stop writing status and run ALTER TABLE orders DROP COLUMN status.

The contract step is where the safety net ends. Flipping the flag back will not restore a dropped column or recover data that a lossy transformation discarded. That is why destructive migrations run last, as a separate deploy, after the toggle has been removed and you no longer need to roll back.

-- PostgreSQL syntax. Each phase ships as its own deploy.

-- 1. EXPAND: additive only. Old code ignores the new column.
CREATE TABLE order_statuses (
  id   INT PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);
INSERT INTO order_statuses (id, name)
VALUES (1, 'pending'), (2, 'paid'), (3, 'shipped'), (4, 'cancelled');

ALTER TABLE orders ADD COLUMN status_id INT NULL
  REFERENCES order_statuses (id);

-- 2. DUAL-WRITE + BACKFILL: app code now writes status AND status_id.
-- Backfill old rows in small batches; repeat until 0 rows updated.
UPDATE orders o
SET status_id = s.id
FROM order_statuses s
WHERE o.id IN (
        SELECT id FROM orders
        WHERE status_id IS NULL
        LIMIT 1000)
  AND s.name = o.status;

-- Check before switching reads: rows the backfill could not map.
SELECT count(*) FROM orders WHERE status_id IS NULL;

-- 3. SWITCH READS: no SQL. The toggle moves reads to status_id;
-- status is still written, so flipping the flag back is safe.

-- 4. CONTRACT: only after the toggle and old code path are deleted.
-- Irreversible: no flag flip brings this column or its data back.
ALTER TABLE orders ALTER COLUMN status_id SET NOT NULL;
ALTER TABLE orders DROP COLUMN status;

An expand-and-contract sequence in PostgreSQL that moves orders.status to a status_id foreign key, with the destructive DROP COLUMN placed last, after the toggle is gone.

When to use a branch, a toggle, or a different strategy

Toggles are not the default answer for every refactor. The right tool depends on how long the change stays incomplete and how cleanly the old and new code can coexist.

Situation Best fit Why
Change fits in a day or two, touches few files Short-lived branch Merge conflicts stay small; a toggle would be overhead
Behavioural rewrite of one component over weeks Branch by abstraction plus a toggle Both paths ship together, and you can switch back without a redeploy
Sweeping mechanical rename across the codebase Automated codemod, merged quickly A toggle cannot select between two names for the same symbol
Framework or runtime upgrade that touches everything Short coordinated branch with a merge freeze There is no single seam to put a toggle behind
Seam would cut through tangled shared state Extract a module first The abstraction would be riskier than the merge it replaces

The last row deserves attention. If introducing the interface requires threading new parameters through dozens of call sites, or splitting a transaction boundary, the seam itself becomes a risky change. In that case it is cheaper to isolate the code behind a clean module boundary first. Once the boundary exists, a toggle becomes straightforward.

Trunk-based refactoring also has prerequisites. CI must run on every merge and block a broken build. Test feedback needs to arrive in minutes, so developers integrate often instead of batching changes. The team must agree that incomplete work stays dark behind its toggle and never changes default behaviour. If any of these is missing, merging partial refactors destabilizes trunk for everyone, and a long-lived branch starts to look safer, even though it isn't.

When to avoid toggle-based refactoring

Avoid this approach, or postpone it until the gaps are fixed, in these cases:

  • The change is small. If it fits in a day or two and touches few files, a toggle adds a seam, a conditional and a cleanup task for no benefit. Use a short-lived branch.
  • Old and new code cannot run side by side. Mechanical renames, language or framework upgrades, and build-system changes affect every file at once. There is no single decision point for a toggle to control.
  • The data change is not reversible. If the new path needs a lossy transformation, or the schema cannot support both paths at the same time, the toggle gives a false sense of safety. Flipping it back will not undo the data change. Redesign the migration as expand and contract first, or treat the cutover as a one-way, planned event.
  • The delivery basics are missing. Without CI that blocks broken builds, fast tests and frequent deploys, partial work on trunk will hurt everyone, and the rollback advantage is lost.
  • The team will not delete toggles. If past toggles are still in the code with no owner, adding more increases the number of untested combinations. Fix the cleanup process before starting another toggle-driven refactor.
  • Your release process forbids dormant code in production builds. Some change-control processes require every shipped code path to be reviewed and approved as released behaviour. Clarify whether dark code behind a toggle is allowed before you depend on it.
  • The seam is riskier than the refactor. If building the abstraction means cutting through transactions or shared mutable state, extract a clean module boundary first.

Alternatives to feature toggles and long-lived branches

If toggles are a poor fit, these options remain:

  • Short-lived branches. Split the refactor into changes that each merge within a day or two. Each step must leave trunk working without a toggle, for example by adding new code that nothing calls yet, then switching callers in a later small change.
  • Automated codemods. For mechanical, codebase-wide edits such as renames or API signature changes, generate the change with a script or refactoring tool, review it, and merge it quickly, ideally at a time of low commit activity to reduce conflicts.
  • Parallel change (expand and contract) at the API level. Add the new method or signature next to the old one, migrate callers gradually, then remove the old one. This is the code-level version of the schema pattern above and often needs no runtime toggle, because callers choose explicitly.
  • Strangler fig. Instead of swapping implementations inside the monolith, put a routing layer in front of it, such as a proxy, API gateway or dispatch module, and move endpoints or capabilities one at a time to new code. It suits extracting a service from the monolith more than refactoring internals.
  • Modularize first. When the code is too tangled for a clean seam, spend the first phase drawing module boundaries and enforcing them, for example with dependency rules checked in CI. Then use one of the strategies above.
  • Coordinated branch with a merge freeze. For changes that truly touch everything at once, such as a major framework upgrade, keep the branch as short as possible and schedule a brief freeze on the affected areas so the merge lands cleanly.

These options can be combined. A typical large migration might modularize a component, put a toggle behind its new boundary, use codemods to update callers, and finish with an expand-and-contract schema change.

Impact on cost and engineering time, and toggle hygiene

The two approaches cost roughly the same total effort. They differ in when you pay it. A long-lived branch feels cheap while it is open, because nobody else is affected. The cost arrives at merge time as merge debt: conflicts against weeks of trunk changes, re-reviewing code that has drifted, and integration bugs that appear only when the branch meets production. Toggles and branch by abstraction move the cost to the start. You build the seam, carry conditional paths in the code, and maintain two implementations for a while.

Illustrative estimate (assumed numbers, not a benchmark)

  • Branch merge cost ≈ weeks open × conflicting trunk changes per week × hours per conflict
  • Toggle overhead ≈ seam hours + (dual-maintenance weeks × hours per week) + removal hours

Branch: assume 12 weeks open, 8 conflicting changes per week and 1.5 hours per conflict. That gives 12 × 8 × 1.5 = 144 hours, before any integration bugs.

Toggle: assume 40 hours to build the seam, 12 × 4 = 48 hours of dual maintenance and 8 hours for removal. That gives 96 hours.

Your inputs may reverse the result. The structural point holds either way: branch cost grows with every week the branch stays open, while toggle cost is mostly fixed. The formula also leaves out two savings from toggles. Problems surface early on real traffic, and rollback is a configuration change rather than a redeploy.

Those savings disappear if toggles never get removed. Make toggle hygiene part of the definition of done:

  • Register every toggle in code with an owner and an expiry date.
  • Alert or fail the build when a toggle passes its expiry.
  • When the refactor starts, file the deletion of the old path and the toggle as an explicit task.
import sys
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class Toggle:
    name: str
    owner: str            # team accountable for removing it
    expires: date         # date by which old path + toggle must be gone
    cleanup_ticket: str   # deletion task filed when the refactor started


TOGGLES = {t.name: t for t in [
    Toggle("billing.use_new_invoice_service", "team-billing", date(2026, 12, 15), "BILL-1423"),
    Toggle("orders.read_from_order_module", "team-orders", date(2026, 11, 30), "ORD-877"),
]}


def is_enabled(name: str, config: dict) -> bool:
    # Refuse unregistered toggles so none can exist without an owner and expiry.
    if name not in TOGGLES:
        raise KeyError(f"Unregistered toggle: {name}")
    return bool(config.get(name, False))


def stale(today: date) -> list:
    return [t for t in TOGGLES.values() if t.expires < today]


if __name__ == "__main__":
    expired = stale(date.today())
    for t in expired:
        print(f"STALE TOGGLE: {t.name} owner={t.owner} "
              f"expired={t.expires} cleanup={t.cleanup_ticket}", file=sys.stderr)
    # Non-zero exit fails the CI job until the toggle is deleted or re-dated.
    sys.exit(1 if expired else 0)

A minimal in-code toggle registry where each toggle has an owner, an expiry date and a cleanup ticket, plus a CI check that fails the build once any toggle passes its expiry.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to refactor monolith feature toggles, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.