Feature branches create merge nightmares. Develop for weeks
in isolation? Integration hell on merge day. GitFlow with long-lived branches? Conflicts, broken builds, deployment
delays. Trunk-based development with feature flags solves this by keeping everyone on main while hiding incomplete
features behind toggles—transforming terror merges into continuous integration.
This guide covers production-ready trunk-based development
with feature flags that enable continuous deployment without feature branch chaos. We’ll ship to production daily
while keeping incomplete features hidden.
Why Feature Flags Transform Development
The Feature Branch Problem
Long-lived feature branches suffer from:
- Merge conflicts: Weeks of divergence create integration nightmares
- Integration delays: Features sit unmerged for weeks
- Broken main: Large merges break builds
- Deployment blocks: Can’t deploy until feature complete
- Stale branches: Diverge from main, become unmergeable
- Code review bottlenecks: Huge PRs impossible to review
Feature Flags Benefits
- Merge daily: Always integrate with main
- Deploy incomplete features: Hide behind flags
- Gradual rollouts: 1% → 10% → 100%
- Instant rollback: Toggle flag off if issues
- A/B testing: Different users see different features
- Small PRs: Merge frequently, review easily
Pattern 1: Basic Feature Flags
Simple Boolean Toggles
// Feature flag service
class FeatureFlagService {
private flags: Map<string, boolean> = new Map();
constructor() {
// Load from config/database
this.flags.set('new-checkout-flow', false);
this.flags.set('enhanced-search', true);
this.flags.set('ai-recommendations', false);
}
isEnabled(flagName: string): boolean {
return this.flags.get(flagName) ?? false;
}
enable(flagName: string): void {
this.flags.set(flagName, true);
}
disable(flagName: string): void {
this.flags.set(flagName, false);
}
}
// Usage in code
const flags = new FeatureFlagService();
// Old code path (production)
function processCheckout(cart: Cart) {
if (flags.isEnabled('new-checkout-flow')) {
// New implementation (behind flag)
return processCheckoutV2(cart);
}
// Old implementation (default)
return processCheckoutV1(cart);
}
// Hide UI elements
function renderDashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Always visible */}
<OrderHistory />
{/* Feature flag controlled */}
{flags.isEnabled('ai-recommendations') && (
<AIRecommendations />
)}
</div>
);
}
// Benefits:
// - Deploy incomplete AI recommendations to production
// - Toggle on when ready
// - Instant rollback if issues
Pattern 2: Percentage Rollouts
Gradual Feature Release
from typing import Optional
import hashlib
class FeatureFlags:
def __init__(self):
self.flags = {
'new-algorithm': {
'enabled': True,
'percentage': 10 # 10% of users
},
'premium-features': {
'enabled': True,
'percentage': 100,
'whitelist': ['user_123', 'user_456'] # Always on for these
}
}
def is_enabled(self, flag_name: str, user_id: str) -> bool:
if flag_name not in self.flags:
return False
flag = self.flags[flag_name]
# Check if globally disabled
if not flag.get('enabled', False):
return False
# Check whitelist
if user_id in flag.get('whitelist', []):
return True
# Check percentage rollout
percentage = flag.get('percentage', 0)
if percentage >= 100:
return True
if percentage <= 0:
return False
# Consistent hashing for stable rollout
hash_input = f"{flag_name}:{user_id}".encode()
hash_value = int(hashlib.md5(hash_input).hexdigest(), 16)
user_percentage = hash_value % 100
return user_percentage < percentage
def set_percentage(self, flag_name: str, percentage: int):
"""Gradually increase rollout"""
if flag_name in self.flags:
self.flags[flag_name]['percentage'] = percentage
# Usage
flags = FeatureFlags()
def get_search_results(query: str, user_id: str):
if flags.is_enabled('new-algorithm', user_id):
# 10% of users get new algorithm
return enhanced_search(query)
else:
# 90% get old algorithm
return legacy_search(query)
# Gradual rollout strategy:
# Day 1: 1% (test with small audience)
flags.set_percentage('new-algorithm', 1)
# Day 2: No issues, increase to 10%
flags.set_percentage('new-algorithm', 10)
# Day 5: Looking good, 50%
flags.set_percentage('new-algorithm', 50)
# Day 7: Full rollout
flags.set_percentage('new-algorithm', 100)
# If issues at any point: rollback to 0% instantly!
Pattern 3: User Targeting
Feature Flags with Attributes
public class UserContext
{
public string UserId { get; set; }
public string Email { get; set; }
public string Country { get; set; }
public string Tier { get; set; } // free, premium, enterprise
public bool IsBetaTester { get; set; }
}
public class FeatureFlagService
{
public bool IsEnabled(string flagName, UserContext user)
{
return flagName switch
{
// Enable for beta testers only
"experimental-ui" => user.IsBetaTester,
// Enable for premium users
"advanced-analytics" => user.Tier == "premium" || user.Tier == "enterprise",
// Regional rollout
"eu-compliance-mode" => user.Country == "DE" || user.Country == "FR",
// Specific user whitelist
"admin-panel" => IsInWhitelist(user.UserId, "admin-panel"),
// Complex targeting
"new-payment-flow" => EvaluateComplexRule(flagName, user),
_ => false
};
}
private bool EvaluateComplexRule(string flagName, UserContext user)
{
// Get targeting rules from database/config
var rules = GetTargetingRules(flagName);
return rules.Any(rule => rule.Matches(user));
}
}
// Targeting rule example
public class TargetingRule
{
public string Attribute { get; set; } // "country", "tier", etc.
public string Operator { get; set; } // "equals", "in", "contains"
public List<string> Values { get; set; }
public bool Matches(UserContext user)
{
var userValue = GetUserAttribute(user, Attribute);
return Operator switch
{
"equals" => Values.Contains(userValue),
"in" => Values.Contains(userValue),
"contains" => Values.Any(v => userValue?.Contains(v) ?? false),
"not_equals" => !Values.Contains(userValue),
_ => false
};
}
}
// Usage
var user = new UserContext
{
UserId = "user_123",
Country = "US",
Tier = "premium",
IsBetaTester = true
};
if (_flags.IsEnabled("advanced-analytics", user))
{
// Premium feature
return GetAdvancedAnalytics();
}
Pattern 4: Feature Flag Frameworks
LaunchDarkly/Unleash Integration
// Using LaunchDarkly SDK
const LaunchDarkly = require('launchdarkly-node-server-sdk');
const ldClient = LaunchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY);
// Wait for initialization
await ldClient.waitForInitialization();
// User context
const user = {
key: 'user_123',
email: 'user@example.com',
country: 'US',
custom: {
tier: 'premium',
signupDate: '2023-01-15'
}
};
// Check flag
const showNewFeature = await ldClient.variation(
'new-checkout-flow',
user,
false // Default if flag not found
);
if (showNewFeature) {
// New feature code
} else {
// Old feature code
}
// Multi-variate flags (not just boolean)
const checkoutVersion = await ldClient.variation(
'checkout-version',
user,
'v1' // Default version
);
switch (checkoutVersion) {
case 'v1':
return renderCheckoutV1();
case 'v2':
return renderCheckoutV2();
case 'v3-experimental':
return renderCheckoutV3();
}
// Track metrics for A/B testing
ldClient.track('checkout-completed', user, {
revenue: order.total,
items: order.items.length
});
// LaunchDarkly dashboard shows:
// - V1: 5% conversion, $45 avg
// - V2: 7% conversion, $52 avg (winner!)
// - V3: 3% conversion, $38 avg (needs work)
Pattern 5: Kill Switches
Emergency Feature Disable
@Service
public class OrderService {
@Autowired
private FeatureFlagService featureFlags;
public Order createOrder(CreateOrderRequest request) {
// Kill switch for new inventory system
if (featureFlags.isEnabled("use-new-inventory-service")) {
try {
return createOrderWithNewInventory(request);
} catch (Exception e) {
log.error("New inventory system failed, falling back", e);
// Auto-disable flag on repeated failures
featureFlags.disableOnError("use-new-inventory-service", e);
// Fall through to old system
}
}
// Old reliable system
return createOrderWithLegacyInventory(request);
}
// Circuit breaker pattern with feature flags
public void processPayment(Payment payment) {
String provider = featureFlags.getStringValue(
"payment-provider",
"stripe" // Default
);
// Can switch payment providers instantly via flag
switch (provider) {
case "stripe":
return stripeService.process(payment);
case "braintree":
return braintreeService.process(payment);
case "fallback":
// Emergency fallback if both fail
return manualProcessing.queue(payment);
}
}
}
// Kill switch service
@Service
public class KillSwitchService {
@Scheduled(fixedRate = 10000) // Every 10 seconds
public void monitorSystemHealth() {
// Check error rates
double errorRate = getErrorRate("new-inventory-service");
if (errorRate > 0.05) { // 5% error rate
log.warn("High error rate detected, disabling new inventory");
featureFlags.disable("use-new-inventory-service");
// Alert team
slack.sendAlert("🚨 New inventory disabled due to high errors");
}
}
}
Pattern 6: Trunk-Based Workflow
Development Process
# Trunk-based development workflow
# Day 1: Start new feature
git checkout main
git pull
git checkout -b feature/new-search
# Add feature flag (always first commit!)
# config/features.json
{
"enhanced-search": {
"enabled": false,
"description": "New search algorithm with AI",
"created": "2024-01-15",
"owner": "search-team"
}
}
# Commit 1: Add flag configuration
git add config/features.json
git commit -m "feat: add enhanced-search feature flag"
git push origin feature/new-search
# Create PR, merge to main (✓ safe, flag is off)
# Day 2: Add new code behind flag
# src/search.ts
export function search(query: string): Results {
if (flags.isEnabled('enhanced-search')) {
return enhancedSearch(query); // New code
}
return legacySearch(query); // Old code
}
# Commit 2: Implement new search
git add src/search.ts
git commit -m "feat: implement enhanced search (behind flag)"
git push
# Create PR, merge to main (✓ safe, flag still off)
# Day 3: Add tests
# tests/search.test.ts
test('enhanced search finds more results', () => {
flags.enable('enhanced-search');
const results = search('machine learning');
expect(results.length).toBeGreaterThan(10);
});
# Commit 3: Add tests
git add tests/
git commit -m "test: add enhanced search tests"
git push
# Merge to main (✓ safe, tested)
# Day 4: Deploy to production
# Feature is deployed but hidden behind flag!
# No users see it yet
# Day 5: Internal testing
# Enable for team only
{
"enhanced-search": {
"enabled": true,
"percentage": 0,
"whitelist": ["team@company.com"]
}
}
# Day 6: Gradual rollout
# 1% of users
flags.setPercentage('enhanced-search', 1);
# Day 7: Monitor metrics
# No issues, increase to 10%
flags.setPercentage('enhanced-search', 10);
# Day 10: Full rollout
flags.setPercentage('enhanced-search', 100);
# Day 15: Remove flag (cleanup)
# Once stable, remove flag and old code
git checkout -b cleanup/remove-enhanced-search-flag
# Remove flag checks, delete old code
git commit -m "refactor: remove enhanced-search flag (100% rollout)"
# Benefits:
# - Merged to main daily
# - Deployed to production daily
# - No merge conflicts
# - Easy rollback (toggle flag)
# - Small reviewable PRs
Real-World Example: E-Commerce Checkout Redesign
Complete Feature Flag Strategy
// Feature flag configuration
interface CheckoutFlags {
'checkout-v2-enabled': boolean;
'checkout-v2-percentage': number;
'checkout-express-enabled': boolean;
'checkout-payment-icons': boolean;
}
class CheckoutFeatureFlags {
async getCheckoutVersion(userId: string): Promise<'v1' | 'v2'> {
// Check if v2 enabled globally
if (!await this.isEnabled('checkout-v2-enabled')) {
return 'v1';
}
// Check percentage rollout
const percentage = await this.getPercentage('checkout-v2-percentage');
const userHash = this.hashUser(userId);
if (userHash < percentage) {
return 'v2';
}
return 'v1';
}
}
// Usage in checkout flow
async function renderCheckout(userId: string) {
const version = await flags.getCheckoutVersion(userId);
if (version === 'v2') {
// New checkout with feature flags for sub-features
return (
<CheckoutV2
showExpressCheckout={await flags.isEnabled('checkout-express-enabled', userId)}
showPaymentIcons={await flags.isEnabled('checkout-payment-icons', userId)}
/>
);
}
// Old checkout (stable fallback)
return <CheckoutV1 />;
}
// Track conversion metrics
async function onCheckoutComplete(userId: string, order: Order) {
const version = await flags.getCheckoutVersion(userId);
// Send to analytics
analytics.track('checkout_completed', {
userId,
orderId: order.id,
checkoutVersion: version,
revenue: order.total,
items: order.items.length
});
}
// Rollout plan:
// Week 1: Internal (whitelist)
// Week 2: 1% of users
// Week 3: 10% of users
// Week 4: 50% of users
// Week 5: 100% of users
// Week 6: Remove flag, delete v1 code
Best Practices
- Flag first: Add flag before implementing feature
- Default to off: New flags should default to disabled
- Small PRs: Merge to main daily, hide behind flags
- Gradual rollout: 1% → 10% → 50% → 100%
- Monitor metrics: Track conversion, errors by flag state
- Clean up flags: Remove after 100% rollout (set deadline)
- Document flags: Owner, purpose, cleanup date
Common Pitfalls
- Flag sprawl: Hundreds of old flags never removed
- No cleanup plan: Flags become permanent
- Testing only one path: Test both flag on and off
- Nested flags: Complex flag dependencies
- No monitoring: Can't tell if flag caused issues
- Using for configuration: Use config system, not flags
Key Takeaways
- Feature flags enable trunk-based development without feature branches
- Deploy incomplete features to production (hidden behind flags)
- Gradual rollouts reduce risk: 1% → 10% → 100%
- Instant rollback: toggle flag off if issues
- A/B testing built-in: compare metrics by flag state
- Merge to main daily, ship to production daily
- Clean up flags after 100% rollout (set cleanup deadline)
- Use frameworks like LaunchDarkly for advanced targeting
Feature flags transform development from risky big-bang releases into safe, incremental deployments. By
decoupling deployment from release, you ship to production daily while controlling who sees what. The result:
faster delivery, lower risk, and the confidence to deploy anytime. Master trunk-based development with feature
flags, and you'll never fear merge conflicts again.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.