Trunk-based development không có nghĩa là commit thẳng lên main không qua review. Nó có nghĩa là branch của bạn không tồn tại quá 1-2 ngày.

Phân biệt này quan trọng vì nhiều team nghe “trunk-based” là nghĩ đến cowboy coding — push thẳng lên main, không PR, không review. Thực tế, các team làm trunk-based tốt nhất vẫn dùng PR, vẫn có CI gate, nhưng branch của họ nhỏ đến mức merge trong vòng vài giờ.


Vấn đề thật sự của long-lived branch

Feature branch không phải xấu. Vấn đề là khi branch tồn tại quá 3-5 ngày — lúc đó nó bắt đầu diverge khỏi main đủ nhiều để tạo ra integration debt mà cả team phải trả khi merge.

Mình đã trải qua cảnh “merge Friday” ở một team 8 người: mọi người code cả tuần trên branch riêng, cuối tuần merge vào main trước khi deploy. Lần đó có 4 branch cùng merge trong 2 tiếng — conflict ở 47 file (không phải số tròn: mình đếm được trong git diff), 3 integration bug chỉ thấy sau khi merge, và một bug silent data corruption chỉ được phát hiện 2 ngày sau khi khách hàng báo cáo. Cái tuần đó mình hiểu tại sao “merge hell” là khái niệm có thật.

Vấn đề cốt lõi của long-lived branch là integration testing không xảy ra liên tục. Mỗi developer đang làm việc với giả định về trạng thái của codebase mà giả định đó ngày càng sai khi main tiếp tục thay đổi. Conflict git chỉ là phần nổi — integration bug ẩn trong logic mới merge mới đáng sợ hơn.


Trunk-based không phải cho mọi team

Vậy trunk-based development yêu cầu gì để hoạt động được? Ba thứ không thể thiếu:

CI pipeline phải chạy dưới 10 phút. Nếu CI chạy 45 phút, developer không thể merge nhiều lần mỗi ngày. Trunk-based với CI chậm = queue merge request tắc nghẽn = team frustrated. Pipeline của mình hiện chạy 6.4 phút (unit test + integration test + build) — đây là constraint mình optimize cố ý để trunk-based khả thi.

Test coverage đủ để trust main luôn deployable. Đây là phần khó nhất. “Đủ” ở đây không phải 80% coverage (con số vô nghĩa) — mà là team có confidence rằng nếu CI green thì main có thể deploy production ngay lập tức. Xây dựng confidence đó mất 3-6 tháng, không phải 2 tuần.

Feature flag infrastructure. Không phải mọi feature đều hoàn thành trong 1-2 ngày. Trunk-based không có feature flag là bạn đang force merge code chưa xong vào main — đó là thảm họa.

Team thiếu bất kỳ một trong ba thứ trên mà cố làm trunk-based thường kết thúc với main branch liên tục broken và developer mất tin tưởng vào process.


Decision framework theo team size

Hiểu nôm na thì lựa chọn branching strategy phụ thuộc vào một câu hỏi đơn giản: bao lâu thì một thay đổi cần đến tay người dùng? Team size ảnh hưởng đến câu trả lời đó, nhưng không phải yếu tố duy nhất.

Team ≤ 5 người

Trunk-based hoàn toàn khả thi và thường là lựa chọn tốt nhất. Với team nhỏ, coordination overhead của long-lived branch tốn nhiều hơn giá trị nó mang lại. Merge conflict ít vì ít người, communication overhead thấp, và trust giữa các thành viên cao hơn để có thể merge nhanh.

# Workflow trunk-based cho team nhỏ: branch tồn tại < 1 ngày
git checkout -b fix/user-auth-timeout
# ...code, test locally...
git push origin fix/user-auth-timeout
# Tạo PR, reviewer approve trong vài giờ, merge, xóa branch
git branch -d fix/user-auth-timeout

Team 5-15 người

Short-lived branch (≤ 2 ngày) là sweet spot. Branch đủ nhỏ để không gây merge hell, nhưng vẫn có isolation để reviewer có context rõ ràng. Quy tắc 2 ngày cần được enforce bằng automation — không phải chờ developer tự giác.

# GitHub Action cảnh báo khi branch quá cũ
name: Stale Branch Warning
on:
  schedule:
    - cron: "0 9 * * *" # chạy mỗi sáng 9h UTC

jobs:
  check-branches:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Cảnh báo branch tồn tại hơn 2 ngày
        run: |
          git branch -r --format='%(refname:short) %(committerdate:unix)' | \
          while read branch date; do
            age=$(( ($(date +%s) - date) / 86400 ))
            if [ $age -gt 2 ] && [[ $branch != *"main"* ]] && [[ $branch != *"HEAD"* ]]; then
              echo "::warning::Branch $branch đã $age ngày tuổi — cần merge hoặc xóa"
            fi
          done          

Team > 15 người

Cần branching strategy rõ ràng với lifecycle được document. Gitflow hoặc GitHub Flow với convention cụ thể — quan trọng là mọi người biết: branch này tồn tại bao lâu, ai có quyền merge vào đâu, và khi nào thì xóa.

Với team lớn, vấn đề không còn là kỹ thuật mà là coordination. Mình từng thấy team 20+ người làm trunk-based thành công (đây là ngoại lệ, không phải quy tắc) — điểm chung là họ có platform team riêng lo feature flag, CI infrastructure, và deployment pipeline. Team còn lại không cần nghĩ đến những thứ đó.


Branch by abstraction — refactor lớn mà không long-lived branch

Vấn đề hay gặp: bạn cần refactor một module lớn — thay engine payment processor, đổi ORM, migrate database schema. Công việc mất 2-3 tuần, không thể nhét vào 1-2 ngày. Đây là lý do nhiều team tạo long-lived branch “vì không còn cách nào khác.”

Branch by abstraction là cách giải quyết bài toán này mà không cần long-lived branch. Ý tưởng:

  1. Tạo abstraction layer (interface) trước khi viết code mới
  2. Implement new code phía sau abstraction, song song với old code
  3. Dần dần switch traffic từ old sang new theo từng endpoint hoặc user group
  4. Khi new code ổn định, xóa old code và abstraction layer
# Bước 1: Tạo abstraction — commit lên main ngay
from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    async def charge(self, amount: int, currency: str, token: str) -> dict:
        pass

# Bước 2: Old implementation vẫn chạy bình thường
class StripeV1Processor(PaymentProcessor):
    async def charge(self, amount: int, currency: str, token: str) -> dict:
        return await stripe_v1.charge(amount, currency, token)

# Bước 3: New implementation commit dần lên main (flag off, không ảnh hưởng user)
class StripeV2Processor(PaymentProcessor):
    async def charge(self, amount: int, currency: str, token: str) -> dict:
        return await stripe_v2.payment_intents.create(
            amount=amount, currency=currency, payment_method=token
        )

# Bước 4: Switch dựa trên feature flag — deploy bất cứ lúc nào
def get_processor() -> PaymentProcessor:
    if feature_flag.is_enabled("stripe_v2"):
        return StripeV2Processor()
    return StripeV1Processor()

Mỗi bước là một commit riêng lên main. Không có branch tồn tại 2 tuần, không có merge hell, không có “big bang” deploy đáng sợ.


Feature flag thay thế branch

Feature flag là infrastructure cơ bản của trunk-based development — không có nó, trunk-based không scale được.

Ý tưởng đơn giản: deploy code ngay khi xong, nhưng wrap trong flag và để flag off. Turn on flag khi sẵn sàng — có thể theo user group, theo percentage traffic, hoặc theo environment.

// Dùng với LaunchDarkly, Unleash, hoặc custom flag service
async function getRecommendations(userId: string): Promise<Recommendation[]> {
  const useNewAlgo = await featureFlags.isEnabled("new-recommendation-algo", {
    userId,
  });

  if (useNewAlgo) {
    // Code mới đã trên main từ 3 ngày trước, flag off trong suốt thời gian đó
    return await newRecommendationEngine.getRecommendations(userId);
  }

  return await legacyRecommendationEngine.getRecommendations(userId);
}

Deploy code, flag off → không ảnh hưởng người dùng. Muốn test: turn on cho internal users trước. Muốn rollout: turn on cho 5% traffic, monitor error rate và latency, tăng dần. Muốn rollback: flip flag, không cần revert code.

Lưu ý: Feature flag không có cleanup process = flag debt. Sau 6 tháng bạn sẽ có 47 flag không ai biết cái nào còn active. Quy ước cơ bản: mỗi flag có owner và expiry date. Khi feature rollout 100%, xóa flag trong sprint tiếp theo — không để flag “mãi mãi phòng khi rollback”.

Trunk-based mà không có CI là disaster

Mình muốn kết với điều này vì nhiều team nghe về trunk-based, thấy hay, implement ngay — nhưng quên rằng CI là prerequisite, không phải nice-to-have.

Trunk-based mà CI flaky (test thi thoảng fail không phải do code) = developer bypass CI để merge = main broken = mất tin tưởng hoàn toàn vào process. Mình đã thấy team abandon trunk-based sau 2 tuần vì lý do này — không phải vì trunk-based sai, mà vì CI không đủ trustworthy.

Trước khi migrate sang trunk-based, invest 1-2 sprint để ổn định CI: fix flaky tests, tách unit test khỏi integration test, set timeout hợp lý, và đảm bảo mọi test failure đều có root cause rõ ràng. Sau đó mới bắt đầu rút ngắn lifecycle của branch.

Branch strategy không phải technical decision — nó là team process decision. Team 3 người làm startup và team 50 người làm enterprise product có constraint hoàn toàn khác nhau, và lựa chọn đúng cho team này có thể là disaster cho team kia.