Feature Branches Are Not the Only Way to Work in Git

by Arif Ikhsanudin, Backend Developer

The Default That Nobody Questioned

Most teams use feature branches because that's what the tutorial showed them. Create branch, make changes, open PR, merge. It's a reasonable default. It's also not the only option, and for some team configurations, it's actively the wrong one.

The alternative approaches — trunk-based direct commits, stacked branches, branch-by-abstraction — each solve specific problems that feature branches introduce. Understanding them doesn't mean abandoning feature branches. It means having the vocabulary to pick the right tool.

Committing Directly to Trunk

In trunk-based development at its most extreme form, senior developers commit directly to main (trunk) without a PR. This is how many high-trust, high-discipline teams at Google and Meta have worked.

The prerequisites are strict:

  • Strong automated test coverage that runs pre-commit or immediately post-push
  • Feature flags to gate incomplete work
  • A team culture where broken trunk is treated as a P1 incident
  • Developers who are disciplined enough to keep individual commits small and self-contained

The benefit: zero PR overhead. Code is integrated immediately. No merge conflicts accumulate. Changes are visible to the entire team the moment they're pushed.

The risk: one bad commit breaks the build for everyone. Without the discipline and test coverage, this is chaotic. This approach only works in teams where everyone can be trusted to keep trunk green as a baseline commitment.

For most teams, the version of this that's practical is: short-lived branches (under one day), merged without waiting for async review from multiple approvers. The review happens synchronously via pair programming or quick in-person feedback.

Stacked Branches

Stacked branches (sometimes called stacked diffs or stacked PRs) are chains of branches where each one is based on the previous:

main
 └── feature/data-model        (PR #1)
      └── feature/service-layer  (PR #2)
           └── feature/api-endpoints (PR #3)

Each PR in the stack depends on the one below it. You can work on PR #3 without waiting for PR #1 to be reviewed and merged.

The core problem this solves: when a feature is large enough that it can't fit in one PR without violating the "keep PRs small" principle, but the parts are sequentially dependent, stacked branches let you move forward without blocking on review.

The overhead: rebasing. When PR #1 merges and its commits are squashed, the base of PR #2 needs to be rebased. Then PR #3 needs to be rebased on the new PR #2. This is manageable with tooling — Graphite CLI automates the rebasing across a stack. Without tooling, managing more than three stacked branches by hand gets painful.

# After PR #1 merges, update the stack:
git checkout feature/service-layer
git rebase origin/main  # rebase onto main now that data-model is merged

git checkout feature/api-endpoints
git rebase feature/service-layer  # rebase onto updated service-layer

Branch-by-Abstraction

Branch-by-abstraction is a technique for making large-scale changes to the codebase without a long-lived feature branch. The steps:

  1. Create an abstraction (interface or wrapper) in front of the thing you're replacing
  2. Implement the old behavior through the abstraction (everything still works)
  3. Implement the new behavior as an alternative implementation
  4. Switch over incrementally — file by file, module by module
  5. Remove the old implementation and the abstraction once migration is complete

This is how you migrate from one ORM to another, replace a legacy service with a new one, or transition from one authentication mechanism to another — without needing a branch that diverges from main for six weeks.

// Step 1: Create abstraction
interface UserRepository {
    Optional<User> findById(Long id);
    User save(User user);
}

// Step 2: Old implementation (wrapped)
class LegacyUserRepository implements UserRepository {
    // wraps existing JdbcTemplate code
}

// Step 3: New implementation
class JpaUserRepository implements UserRepository {
    // JPA-based implementation
}

// Step 4: Switch via feature flag or config
@Bean
UserRepository userRepository(
        @Value("${feature.new-user-repo:false}") boolean useNew) {
    return useNew ? new JpaUserRepository() : new LegacyUserRepository();
}

Each step is a small, independently mergeable commit to main. No long-lived branch. No big-bang cutover. Rollback at any point is trivial.

Pair Programming as a Substitute for Branches

Some teams skip branches entirely for certain kinds of work by using synchronous pair programming instead. Two developers work together, one commits directly to main (or a very short-lived branch), and the pair review replaces the async PR review.

This eliminates review wait time entirely. The tradeoff is the cost of synchronous time — two developers' time versus one, though many teams find that pair programming also reduces total cycle time enough to compensate.

When Feature Branches Are Still the Right Answer

Feature branches remain the correct choice when:

  • You need a code review record for compliance or audit reasons
  • The work is exploratory and might not merge
  • Multiple developers will contribute to the same feature before it's ready
  • Your CI gates are slow enough that real-time feedback loops don't work

The mistake is applying feature branches by default to every situation, including ones where they add overhead without solving a problem you actually have. A two-line config change on a twelve-person team going through a PR queue is process for its own sake.

Match the collaboration mechanism to the size of the change and the nature of the risk. Feature branches are a good default. They shouldn't be a rule.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Spring Data Repository Design — When findBy Methods Are Enough and When They're Not

Spring Data's derived query methods eliminate boilerplate for simple queries. They become unreadable for complex ones and break entirely for dynamic filtering. Here is where each approach belongs and how to recognize when you've outgrown derived queries.

Read more

Caching Is Not a Silver Bullet. It Is a Trade-off.

Every cache you add creates a consistency problem. Understanding the trade-off you are making — not just the performance you are gaining — is what separates caching that helps from caching that causes incidents.

Read more

Hibernate Schema Generation and Validation — What ddl-auto Actually Does in Production

The spring.jpa.hibernate.ddl-auto setting controls whether Hibernate modifies your database schema at startup. Most teams use create or update in development and then wonder why production behaves differently. Here is what each setting does and what belongs in production.

Read more

Git Reflog: The Safety Net Most Developers Don't Know They Have

Reflog is a local log of every position HEAD has ever been in. It is the reason that almost nothing in Git is permanently irreversible — and most developers have never opened it.

Read more