← Academy index
Chapter 7 · 10 min read

Trunk-Based Development with Flags

Why long-lived branches are the wrong abstraction, how feature flags replace them, and the branch-by-abstraction pattern that lets you refactor anything live.

deliverytrunk-basedbranching

The dominant branching model of the 2010s was GitFlow: develop branches, release branches, hotfix branches, a six-step incantation to ship a fix. It was an answer to a problem, “how do we hold work back from production until it’s ready”, that feature flags solve more directly.

This chapter is about how that substitution works.

What trunk-based actually means

Trunk-based development is exactly what it sounds like: every engineer commits to one shared branch (call it main). Branches exist for hours or days, never weeks. Merges to main happen multiple times a day. main is always shippable. CI gates the branch; release is a separate, cheaper decision made later, often via a feature flag.

That last clause is the whole point. Trunk-based development without feature flags is a wish. You either need flags or you need the discipline to never merge anything incomplete, and the latter doesn’t scale beyond a handful of engineers.

Here’s the trade trunk-based makes:

Long-lived branchesTrunk-based with flags
Merge conflicts get worse over timeMerge conflicts stay tiny
Integration is a discrete, painful eventIntegration is continuous
Release happens when the branch is “done”Release happens when the flag is on
Bug fixes ship behind weeks of unrelated workBug fixes ship in minutes
Code review happens after the work is “done”Code review happens incrementally

A team that’s truly trunk-based ships an order of magnitude more often than a team that isn’t. The math is in why feature flags ship faster.

The mechanics of “merge unfinished code”

The thing engineers find hardest about trunk-based development is the idea of merging code that isn’t done. Done how? Done with the feature.

The pattern looks like this:

Day 1. Merge an empty feature flag, off in all environments, with a single placeholder call site:

if (await flags.get("new-search", user)) {
  return newSearch(user);
}
return oldSearch(user);

The compiler is happy because newSearch exists, as a function returning the same shape as oldSearch but doing nothing yet. The flag is off everywhere. Production is unchanged. PR is 30 lines.

Day 2 through N. Land work in newSearch in small, reviewable PRs. Each lands behind the flag. Production is still unchanged. The flag is still off.

Day N+1. Turn the flag on for internal users, then beta, then 1%, 5%, 50%, 100%. See Chapter 3 for the ramp.

Day N+30. Delete the flag. Delete oldSearch. See Chapter 6 for why.

What changed is the unit of integration. Instead of integrating one big lump of work at the end, you integrated continuously, behind a flag, and decoupled the release from any of those integration events.

Branch by abstraction: how to refactor anything live

The hardest case for trunk-based is not “add a feature”. It’s “rewrite a foundational thing”. A two-month rewrite of the auth system can’t sit in a branch, but it also can’t be turned on/off with a single boolean.

The pattern is branch by abstraction:

  1. Identify the seam. What’s the interface between “the thing being rewritten” and “the rest of the codebase”? If there isn’t one, your first job is to extract one.
  2. Introduce the abstraction in code. A small interface. Two implementations behind it: the existing one (v1) and a stub for the new one (v2).
  3. Route through the abstraction with a flag.
    const impl = (await flags.get("auth-impl", user)) === "v2" ? v2 : v1;
  4. Build out v2 incrementally. Every PR moves another method from “throw not-implemented” to “implemented identically to v1”, ideally with a shadow-mode flag that calls both and logs differences.
  5. Roll the flag through the standard ramp. Internal → beta → percentages → 100%.
  6. Delete v1. Delete the abstraction if it has no other readers. Delete the flag.

This is how giants like Stripe and Shopify rewrite their cores without ever having a “rewrite branch”. The system is always live. The risk is always isolated to the percentage of traffic on the new path.

It’s also how you avoid the classic two-year rewrite-that-shipped-once-and-broke-everything failure mode. The integration risk is paid down continuously.

What CI looks like under trunk-based

A trunk-based codebase imposes specific requirements on CI:

  • main is always green. Broken commits to main block everyone, so main is sacred. Branch protection enforces it.
  • CI is fast. If CI takes 40 minutes, nobody waits, they merge and pray. CI under 10 minutes is the realistic target for a team that’s ship-multiple-times-a-day.
  • Tests cover both branches of every flag. If flag.get('x') returns either true or false, both paths need test coverage. A long-lived flag with an untested off branch is a kill switch you can’t trust (Chapter 4).
  • The “release” job is small. Often: tag a version, push artifacts, done. The “is this safe to release?” decision moved out of CI and into the flag.

If your CI isn’t there yet, fix that first. Trunk-based on top of slow, flaky CI is worse than long branches.

What this looks like for the team

Concrete shifts:

  • PRs get smaller. A 1000-line PR feels normal in a long-lived-branch world. In trunk-based, it’s a smell. Most PRs land under 200 lines.
  • Code review happens daily. Big PRs once a week become small PRs constantly. Reviews stay manageable.
  • The “feature done” event disappears. What used to be a single event (“we merged the branch”) becomes a sequence of small events (“first call site lives behind the flag”, “flag on internally”, “flag at 5%”).
  • The phrase “I’ll merge that in once it’s done” stops being said. It’s done in pieces. Each piece merges immediately.

Some teams find this disorienting at first. They want a moment of “completion”. The reframe: completion isn’t merging anymore, it’s deleting the flag. That moment still exists, it’s just two weeks after the code shipped, not before.

Where it breaks

Trunk-based doesn’t fit every shape of work. Three honest exceptions:

  1. Database migrations. A column rename is not a flag. The standard pattern is expand–migrate–contract (add new column, dual-write, backfill, switch reads, drop old column). Each phase is its own deploy. Flags help control the application behavior during the transition, but the schema work is its own dance.

  2. Public API changes. You can’t flag away “we changed our REST contract”. Versioning is the right tool.

  3. Marketing-coordinated launches. When the launch is the event (“Tuesday at 9am Eastern, the new feature goes live for everyone”), the flag exists, but the rollout collapses to a single flip. That’s fine.

For everything else: trunk-based with flags is the model. The longer essay lives in Trunk-based development and feature flags.


Previous: Chapter 6, Managing flag debt  ·  Next: Chapter 8, Feature flags at the edge