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.
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 branches | Trunk-based with flags |
|---|---|
| Merge conflicts get worse over time | Merge conflicts stay tiny |
| Integration is a discrete, painful event | Integration is continuous |
| Release happens when the branch is “done” | Release happens when the flag is on |
| Bug fixes ship behind weeks of unrelated work | Bug 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:
- 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.
- Introduce the abstraction in code. A small interface. Two implementations behind it: the existing one (
v1) and a stub for the new one (v2). - Route through the abstraction with a flag.
const impl = (await flags.get("auth-impl", user)) === "v2" ? v2 : v1; - Build out
v2incrementally. 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. - Roll the flag through the standard ramp. Internal → beta → percentages → 100%.
- 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:
mainis always green. Broken commits tomainblock everyone, somainis 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 eithertrueorfalse, 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:
-
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.
-
Public API changes. You can’t flag away “we changed our REST contract”. Versioning is the right tool.
-
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