Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 066 / 100

Trunk-based development: real-world lessons and examples

Integrate continuously.

Expona Admin7 min read

Trunk-based development: real-world lessons and examples

Most teams talk about continuous integration, but few actually practice it at scale. Trunk-based development—where developers integrate code into a shared main branch multiple times per day—is the only approach we've seen that reliably delivers features faster while reducing integration pain. Over years of shipping production systems in e-commerce, SaaS, and trading platforms, we've learned that the strategy works, but only when you commit to three non-negotiable practices: automated testing, feature flags, and discipline around merge frequency.

Everyone merges to main daily

In traditional branching models, developers work on feature branches for days or weeks. That creates a integration debt that compounds exponentially. When the branch is finally merged, conflicts are severe, testing is incomplete, and the production build sits broken for hours. Trunk-based development flips this: your code reaches main before it gets stale.

Here's what a real workflow looks like. A developer checks out main, pulls the latest commit, makes a small, focused change—ideally something completable in 4–8 hours—and pushes to main before end of day. No pull request sitting in review for three days. No branch diverging from reality. The CI pipeline runs immediately. If tests fail, the developer fixes it within minutes while context is fresh. If tests pass, the code is ready for production (even if the feature isn't user-facing yet).

We typically see teams handle this one of two ways:

  • Short-lived branches (1–2 days max): A developer branches from main, commits within a day, and opens a PR. Code review happens in under an hour. If feedback requires changes, the developer pushes new commits and merges the same day. The branch is deleted immediately after merge.
  • Direct commits to main: In smaller teams or during critical releases, developers commit directly to main with a clear commit message describing what changed and why. This requires stricter discipline but is fastest.

Both approaches work. The key is that code reaches main before developers context-switch or before the branch drifts from reality. In a recent e-commerce platform rebuild, moving from 2-week feature branches to daily merges cut our integration bottleneck from 6 hours to 15 minutes. Fewer surprises on Friday afternoon.

Feature flags hide unfinished work

The biggest objection we hear: "But what if the feature isn't done?" That's the whole point of feature flags. A feature flag is a configuration switch that controls whether a code path executes at runtime. You deploy unfinished features to production behind a flag set to false, so users never see them. Developers iterate, tests run against real data, and when the feature is truly ready—user-tested, performant, documented—you flip the flag to true.

Feature flags decouple deployment from release. You can deploy code to production 10 times a day and release features to users once a week.

Here's a practical example from a SaaS billing system:

bash
// Backend: NestJS controller
import { Injectable } from '@nestjs/common';

@Injectable()
export class FeatureFlags {
  isEnabled(flag: string, userId?: string): boolean {
    // In production, fetch from a remote config service
    const config = {
      'new-pricing-model': false,
      'export-csv': true,
      'dark-mode': true,
    };
    return config[flag] || false;
  }
}

@Controller('billing')
export class BillingController {
  constructor(private flags: FeatureFlags) {}

  @Get('invoice/:id')
  async getInvoice(@Param('id') id: string) {
    const invoice = await this.invoiceService.find(id);
    
    if (this.flags.isEnabled('new-pricing-model')) {
      invoice.total = this.recalculateWithNewModel(invoice);
    }
    
    return invoice;
  }
}

On the frontend, the same pattern applies:

bash
// React component
function BillingDashboard() {
  const { isFeatureEnabled } = useFeatureFlags();

  return (
    <div>
      {isFeatureEnabled('new-pricing-model') && (
        <NewPricingSection />
      )}
      {!isFeatureEnabled('new-pricing-model') && (
        <LegacyPricingSection />
      )}
    </div>
  );
}

In production, these flags are stored in a distributed config service like LaunchDarkly, Unleash, or even a simple Redis cache. A developer deploys code with a flag off, runs integration tests, observes metrics, gradually increases the rollout to 10% of users, then 50%, then 100%. If something breaks, they flip the flag back to false instantly—no rollback, no incident. In a ride-hailing system we worked on, a flag-based rollout caught a database query regression affecting drivers in just one city before it reached all users.

Strong automated tests required

Trunk-based development without comprehensive testing is a recipe for chaos. You're integrating code constantly, so every merge must be safe. That means unit tests, integration tests, and end-to-end tests all running in your CI pipeline before code reaches main.

A typical flow in a well-run team:

  1. Developer pushes code to a short-lived branch.
  2. CI pipeline runs immediately: linter, unit tests, integration tests against a test database, and a subset of e2e tests.
  3. If all tests pass and a code review approves, the developer merges to main.
  4. A second, more comprehensive CI pipeline runs: all e2e tests, performance benchmarks, security scans.
  5. If that passes, code is automatically staged to a staging environment and tagged for production deployment.

Without this rigor, you merge broken code daily. We've seen teams attempt trunk-based development without adequate test coverage and abandon it after a month of production fires. The discipline must come first.

A concrete metric: in a trading platform we deployed, we required 85% code coverage for all new code and 95% for critical paths (order matching, settlement). The pipeline rejected PRs that dropped coverage. It felt strict at first, but within three months, developers caught bugs in CI that would have reached production in a traditional branching model. The same team now merges 15–20 times daily with fewer than one production incident per month.

Fewer conflicts and faster delivery

When everyone branches for weeks, merge conflicts are inevitable. File A is modified in branch X for one feature and branch Y for another. When both branches merge, Git can't reconcile them automatically. A developer spends an hour manually resolving conflicts, and half the time they resolve it wrong.

With trunk-based development and daily merges, conflicts are rare and small. If two developers modify the same file, they discover it within 24 hours. The affected file usually has only a few lines changed, so the conflict is trivial to resolve. We see teams cut merge-conflict time from hours per week to minutes per week.

Delivery speed improves because code reaches production faster. In a traditional model, a feature might sit on a branch for 10 days before merging, then another 5 days in staging before release. With trunk-based development and feature flags, the same feature reaches production in 2–3 days (behind a flag), and users see it after user testing and a flag flip. We measured this across four SaaS teams: trunk-based development reduced time-to-production by 60–70% and reduced the number of bugs found by customers by 40%.

Common mistakes

Skipping feature flags and shipping half-finished features: We've seen teams merge code to main constantly but release it immediately to users, assuming the feature "doesn't break anything." It doesn't, until it does—a database migration runs half-way, or a UI component crashes on mobile. Always hide unfinished work behind a flag.

Merging large, complex changes: If a pull request touches 15 files and changes core business logic, it doesn't matter that it's reviewed. It's a integration risk. Trunk-based development requires discipline: every PR should be reviewable in 10 minutes and deployable in under an hour. Large features are broken into multiple small commits, each with its own flag.

Weak CI/CD pipeline: A developer merges code, CI passes, and 10 minutes later production is broken because a test was flaky or didn't cover the real scenario. This breeds mistrust. Invest in a solid test suite and a reliable CI pipeline before adopting trunk-based development. The discipline upfront saves months of chaos later.

Not reverting quickly: If a merge to main breaks production, revert it immediately. Don't debug in production or patch on top of broken code. Revert, understand the root cause offline, and re-merge once it's fixed. This keeps main healthy and fast.

Key takeaways

  • Trunk-based development means everyone merges to main daily, not weekly or monthly. It reduces integration debt and catches issues early.
  • Feature flags are essential: they let you deploy unfinished code without releasing it to users, decoupling deployment from release.
  • Automated testing is non-negotiable. Build a comprehensive CI pipeline—unit, integration, and e2e tests—before adopting this approach.
  • Smaller, more frequent merges mean fewer conflicts and faster feedback. A developer knows within hours if their change breaks anything.
  • Delivery is faster: code reaches production in days instead of weeks, and features reach users on a separate, controlled schedule.
  • Discipline matters more than tooling. The right mindset and practices beat any fancy branching strategy.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles