Branch by Abstraction

Branch by Abstraction is a powerful technique for making large-scale changes to production code without using long-lived feature branches. It allows you to swap out major dependencies—databases, APIs, libraries, entire subsystems—while keeping your code continuously deployable and your team working from a single mainline branch.

This pattern is essential for teams practicing Continuous Integration and Continuous Delivery, where the goal is to keep the main branch always in a deployable state.

Video Summary

The video presentation walks through Branch by Abstraction in detail:

  • The problems with long-lived feature branches for major changes
  • How abstraction enables safe dependency swapping
  • Step-by-step implementation process with concrete examples
  • Managing the transitional state where both implementations coexist
  • Cleaning up after the migration is complete
  • Real-world scenarios where this pattern excels

Key Concepts

1. The Problem: Large Changes on Feature Branches

Traditional approach to major changes:

  1. Create long-lived feature branch
  2. Spend weeks/months rewriting component
  3. Try to merge back to main
  4. Deal with massive merge conflicts
  5. Struggle with integration issues

Why this fails:

  • Merge conflicts grow exponentially with time
  • Integration issues discovered late
  • Can't easily release other features
  • Team is divided between branches
  • Code review becomes nearly impossible

2. The Solution: Abstract, Migrate, Clean Up

Branch by Abstraction follows a three-phase approach:

Phase 1: Create Abstraction

// Before: Direct dependency on old implementation
class OrderService {
  constructor(private mysqlDatabase: MySQLDatabase) {}

  async getOrder(id: string) {
    return this.mysqlDatabase.query('SELECT * FROM orders WHERE id = ?', [id]);
  }
}

// After: Abstraction introduced
interface OrderRepository {
  getOrder(id: string): Promise<Order>;
}

class OrderService {
  constructor(private orderRepo: OrderRepository) {}

  async getOrder(id: string) {
    return this.orderRepo.getOrder(id);
  }
}

// Existing implementation wrapped in abstraction
class MySQLOrderRepository implements OrderRepository {
  constructor(private db: MySQLDatabase) {}

  async getOrder(id: string) {
    const row = await this.db.query('SELECT * FROM orders WHERE id = ?', [id]);
    return this.mapRowToOrder(row);
  }
}

Phase 2: Build New Implementation

// New implementation behind same interface
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: PostgresDatabase) {}

  async getOrder(id: string) {
    const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
    return this.mapRowToOrder(row);
  }
}

// Switch between implementations with config
function createOrderRepository(config: Config): OrderRepository {
  if (config.usePostgres) {
    return new PostgresOrderRepository(postgresDb);
  }
  return new MySQLOrderRepository(mysqlDb);
}

Phase 3: Remove Old Implementation

// After migration complete: remove abstraction if no longer needed
class OrderService {
  constructor(private db: PostgresDatabase) {}

  async getOrder(id: string) {
    const row = await this.db.query('SELECT * FROM orders WHERE id = $1', [id]);
    return this.mapRowToOrder(row);
  }
}

3. Continuous Integration Friendly

All changes happen on the main branch:

  • Abstraction added in small commits
  • New implementation built incrementally
  • Feature flag controls which implementation runs
  • Old implementation removed after migration
  • Main branch is always deployable

4. Gradual Migration

Like Strangler Fig, migration happens gradually:

class RoutingOrderRepository implements OrderRepository {
  constructor(
    private mysql: MySQLOrderRepository,
    private postgres: PostgresOrderRepository,
    private featureFlags: FeatureFlags
  ) {}

  async getOrder(id: string): Promise<Order> {
    // Gradual rollout by user ID
    if (this.featureFlags.isEnabledForUser('postgres-orders', id)) {
      return this.postgres.getOrder(id);
    }
    return this.mysql.getOrder(id);
  }
}

Real-World Applications

Example 1: Switching Payment Processors

Scenario: Migrating from Stripe to Adyen for lower fees.

// Step 1: Create abstraction
interface PaymentProcessor {
  charge(amount: number, token: string): Promise<ChargeResult>;
  refund(chargeId: string): Promise<RefundResult>;
}

// Step 2: Wrap existing Stripe code
class StripePaymentProcessor implements PaymentProcessor {
  async charge(amount: number, token: string) {
    const charge = await stripe.charges.create({
      amount,
      currency: 'usd',
      source: token,
    });
    return { id: charge.id, status: charge.status };
  }

  async refund(chargeId: string) {
    const refund = await stripe.refunds.create({ charge: chargeId });
    return { id: refund.id, status: refund.status };
  }
}

// Step 3: Build new Adyen implementation
class AdyenPaymentProcessor implements PaymentProcessor {
  async charge(amount: number, token: string) {
    const payment = await adyen.payments.create({
      amount: { value: amount, currency: 'USD' },
      paymentMethod: { type: 'scheme', encryptedCardNumber: token },
    });
    return { id: payment.pspReference, status: payment.resultCode };
  }

  async refund(chargeId: string) {
    const modification = await adyen.modifications.refund({
      originalReference: chargeId,
    });
    return { id: modification.pspReference, status: modification.response };
  }
}

// Step 4: Route based on feature flag
class ConfigurablePaymentProcessor implements PaymentProcessor {
  private processor: PaymentProcessor;

  constructor(config: Config) {
    this.processor = config.useAdyen
      ? new AdyenPaymentProcessor()
      : new StripePaymentProcessor();
  }

  charge(amount: number, token: string) {
    return this.processor.charge(amount, token);
  }

  refund(chargeId: string) {
    return this.processor.refund(chargeId);
  }
}

Migration path:

  1. Deploy abstraction with Stripe implementation (no behavior change)
  2. Add Adyen implementation behind feature flag (disabled)
  3. Test Adyen with internal accounts
  4. Roll out to 5% of transactions
  5. Increase to 100% over several weeks
  6. Remove Stripe code and simplify abstraction

Example 2: Replacing Logging Library

Scenario: Migrating from Winston to Pino for better performance.

// Abstraction
interface Logger {
  info(message: string, meta?: object): void;
  error(message: string, error?: Error): void;
  warn(message: string, meta?: object): void;
}

// Old implementation
class WinstonLogger implements Logger {
  info(message: string, meta?: object) {
    winston.info(message, meta);
  }
  error(message: string, error?: Error) {
    winston.error(message, { error });
  }
  warn(message: string, meta?: object) {
    winston.warn(message, meta);
  }
}

// New implementation
class PinoLogger implements Logger {
  info(message: string, meta?: object) {
    pino.info(meta, message);
  }
  error(message: string, error?: Error) {
    pino.error({ err: error }, message);
  }
  warn(message: string, meta?: object) {
    pino.warn(meta, message);
  }
}

// Factory with feature flag
function createLogger(): Logger {
  if (process.env.USE_PINO === 'true') {
    return new PinoLogger();
  }
  return new WinstonLogger();
}

Example 3: API Client Migration

Scenario: Updating from REST API v1 to GraphQL.

// Abstraction over data fetching
interface UserRepository {
  getUserById(id: string): Promise<User>;
  getUsersByTeam(teamId: string): Promise<User[]>;
}

// Old REST implementation
class RestUserRepository implements UserRepository {
  async getUserById(id: string) {
    const response = await fetch(`/api/v1/users/${id}`);
    return response.json();
  }

  async getUsersByTeam(teamId: string) {
    const response = await fetch(`/api/v1/teams/${teamId}/users`);
    return response.json();
  }
}

// New GraphQL implementation
class GraphQLUserRepository implements UserRepository {
  async getUserById(id: string) {
    const { data } = await graphqlClient.query({
      query: gql`
        query GetUser($id: ID!) {
          user(id: $id) {
            id name email team { id name }
          }
        }
      `,
      variables: { id },
    });
    return data.user;
  }

  async getUsersByTeam(teamId: string) {
    const { data } = await graphqlClient.query({
      query: gql`
        query GetTeamUsers($teamId: ID!) {
          team(id: $teamId) {
            users { id name email }
          }
        }
      `,
      variables: { teamId },
    });
    return data.team.users;
  }
}

Common Pitfalls

1. Abstraction Too Coupled to Old Implementation

Problem: The abstraction mirrors the old implementation too closely.

// Bad: Abstraction tied to SQL
interface UserRepository {
  query(sql: string, params: any[]): Promise<any>;
}

// Good: Abstraction represents domain operations
interface UserRepository {
  getUserById(id: string): Promise<User>;
  saveUser(user: User): Promise<void>;
}

Solution: Design abstractions around your domain needs, not implementation details.

2. Leaving Abstractions Permanent

Problem: The abstraction was temporary but never gets removed.

Why it fails: Unnecessary abstractions add complexity and cognitive load.

Solution: Clean up after migration. If you only have one implementation, consider removing the abstraction (unless you anticipate another change).

3. Incomplete Abstraction

Problem: Abstraction covers 90% of use cases, forcing some code to bypass it.

// Incomplete abstraction
interface Cache {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}

// Some code needs TTL, bypasses abstraction
const value = await redis.setex('key', 3600, 'value'); // Oops!

Solution: Ensure abstraction covers all current use cases before switching implementations.

4. Parallel Implementations Drift

Problem: While both implementations exist, only new one gets updates.

Why it fails: If you need to rollback, old implementation is now buggy.

Solution: Keep both implementations in sync until migration is complete and old one is removed.

Key Takeaways

  • Branch by Abstraction enables large-scale changes without long-lived feature branches
  • The pattern has three phases: abstract, migrate, clean up
  • Abstractions should represent domain concepts, not implementation details
  • All work happens on the main branch, keeping code continuously deployable
  • Feature flags enable gradual migration and easy rollback
  • Clean up temporary abstractions after migration to avoid accumulating complexity
  • This technique is essential for teams practicing Continuous Integration

Further Reading

Sunsetting - Branch by Abstraction | Sunsetting Learn