Back to articles
Case Study

Case Study: Transforming If-Else Hell into Clean Code

A real-world refactoring journey. See how we took 200 lines of nested conditionals and turned them into maintainable, testable code using patterns like Strategy, Guard Clauses, and Lookup Tables.

10 min read

The Horror: Real Code from a Production Codebase

Last week, I was asked to add a new payment method to an e-commerce app. I opened the checkout file and found this:

function processPayment(order: Order, paymentMethod: string, user: User) {
  if (paymentMethod === 'credit_card') {
    if (user.country === 'US') {
      if (order.total > 1000) {
        if (user.isVerified) {
          // Process with Stripe, high-value US customer
          return stripeProcess(order, 'premium');
        } else {
          // Require verification first
          return { error: 'Verification required for orders over $1000' };
        }
      } else {
        // Standard US credit card
        return stripeProcess(order, 'standard');
      }
    } else if (user.country === 'EU') {
      if (order.total > 500) {
        // EU high value needs 3DS
        return stripeProcess(order, '3ds_required');
      } else {
        return stripeProcess(order, 'standard');
      }
    } else {
      // International
      if (SUPPORTED_COUNTRIES.includes(user.country)) {
        return stripeProcess(order, 'international');
      } else {
        return { error: 'Country not supported' };
      }
    }
  } else if (paymentMethod === 'paypal') {
    if (user.country === 'US' || user.country === 'EU') {
      if (order.total > 2000) {
        return { error: 'PayPal limit exceeded' };
      }
      return paypalProcess(order);
    } else {
      return { error: 'PayPal not available in your region' };
    }
  } else if (paymentMethod === 'crypto') {
    if (user.isVerified) {
      if (order.total > 10000) {
        return { error: 'Crypto limit exceeded' };
      }
      return cryptoProcess(order);
    } else {
      return { error: 'Verification required for crypto' };
    }
  } else if (paymentMethod === 'bank_transfer') {
    // ... another 50 lines
  }
  return { error: 'Unknown payment method' };
}

This is If-Else Hell. The code works, but:

  • Adding a new payment method means modifying this giant function
  • Testing requires mocking every possible path
  • One wrong bracket breaks everything
  • Nobody wants to touch it
  • Step 1: Extract Guard Clauses

    The first refactor: move validation checks to the top and return early.

    function processPayment(order: Order, paymentMethod: string, user: User) {
      // Guard clauses - fail fast
      if (!VALID_PAYMENT_METHODS.includes(paymentMethod)) {
        return { error: 'Unknown payment method' };
      }
      
      if (!SUPPORTED_COUNTRIES.includes(user.country)) {
        return { error: 'Country not supported' };
      }
      
      if (paymentMethod === 'crypto' && !user.isVerified) {
        return { error: 'Verification required for crypto' };
      }
      
      if (paymentMethod === 'credit_card' && order.total > 1000 && !user.isVerified) {
        return { error: 'Verification required for orders over $1000' };
      }
      
      // Now the happy path is cleaner...
    }

    Guard clauses reduce nesting by handling edge cases first.

    Step 2: Replace Conditionals with Lookup Tables

    Instead of if-else chains for configuration, use objects:

    const PAYMENT_LIMITS = {
      paypal: { US: 2000, EU: 2000, default: 0 },
      crypto: { default: 10000 },
      credit_card: { default: Infinity },
      bank_transfer: { default: 50000 },
    };
    
    const REGION_RESTRICTIONS = {
      paypal: ['US', 'EU', 'UK', 'CA'],
      crypto: ['US', 'EU', 'UK', 'SG', 'JP'],
      credit_card: SUPPORTED_COUNTRIES,
      bank_transfer: ['US', 'EU'],
    };
    
    function getPaymentLimit(method: string, country: string): number {
      const limits = PAYMENT_LIMITS[method];
      return limits[country] ?? limits.default ?? 0;
    }
    
    function isRegionSupported(method: string, country: string): boolean {
      return REGION_RESTRICTIONS[method]?.includes(country) ?? false;
    }

    Now adding a new country or adjusting limits is a config change, not a code change.

    Step 3: Strategy Pattern for Payment Processors

    Each payment method has different processing logic. Use the Strategy pattern:

    interface PaymentProcessor {
      process(order: Order, user: User): Promise<PaymentResult>;
      validate(order: Order, user: User): ValidationResult;
    }
    
    class StripeProcessor implements PaymentProcessor {
      async process(order: Order, user: User): Promise<PaymentResult> {
        const tier = this.determineTier(order, user);
        return stripeProcess(order, tier);
      }
      
      private determineTier(order: Order, user: User): string {
        if (user.country === 'EU' && order.total > 500) return '3ds_required';
        if (user.country === 'US' && order.total > 1000) return 'premium';
        if (!['US', 'EU'].includes(user.country)) return 'international';
        return 'standard';
      }
      
      validate(order: Order, user: User): ValidationResult {
        if (order.total > 1000 && !user.isVerified) {
          return { valid: false, error: 'Verification required' };
        }
        return { valid: true };
      }
    }
    
    class PayPalProcessor implements PaymentProcessor {
      async process(order: Order, user: User): Promise<PaymentResult> {
        return paypalProcess(order);
      }
      
      validate(order: Order, user: User): ValidationResult {
        const limit = getPaymentLimit('paypal', user.country);
        if (order.total > limit) {
          return { valid: false, error: 'PayPal limit exceeded' };
        }
        return { valid: true };
      }
    }
    
    class CryptoProcessor implements PaymentProcessor {
      // Similar implementation
    }

    Step 4: Factory to Wire It Together

    const processors: Record<string, PaymentProcessor> = {
      credit_card: new StripeProcessor(),
      paypal: new PayPalProcessor(),
      crypto: new CryptoProcessor(),
      bank_transfer: new BankTransferProcessor(),
    };
    
    async function processPayment(
      order: Order, 
      paymentMethod: string, 
      user: User
    ): Promise<PaymentResult> {
      // Get processor
      const processor = processors[paymentMethod];
      if (!processor) {
        return { error: 'Unknown payment method' };
      }
      
      // Check region
      if (!isRegionSupported(paymentMethod, user.country)) {
        return { error: `${paymentMethod} not available in your region` };
      }
      
      // Validate
      const validation = processor.validate(order, user);
      if (!validation.valid) {
        return { error: validation.error };
      }
      
      // Process
      return processor.process(order, user);
    }

    The Result: Before vs After

    Before:

  • 200+ lines in one function
  • 6 levels of nesting
  • Impossible to test individual paths
  • Adding payment method = modify giant if-else
  • After:

  • Main function: 20 lines
  • Each processor: 30-40 lines, single responsibility
  • Easy to test each processor independently
  • Adding payment method = add new class + config entry
  • When to Refactor If-Else

    Not every if-else needs refactoring. Use these patterns when:

    1. More than 3 branches - Simple if/else/else is fine

    2. Nesting deeper than 2 levels - Guard clauses help

    3. Same condition checked multiple places - Lookup tables

    4. Different behavior per type - Strategy pattern

    5. Adding new cases frequently - Any of the above

    Quick Wins You Can Apply Today

    1. Flip conditions for early returns

    // Before
    if (user) {
      if (user.isActive) {
        // 50 lines of logic
      }
    }
    
    // After
    if (!user) return null;
    if (!user.isActive) return null;
    // 50 lines of logic (no nesting)

    2. Use object literals instead of switch

    // Before
    switch (status) {
      case 'pending': return 'yellow';
      case 'approved': return 'green';
      case 'rejected': return 'red';
      default: return 'gray';
    }
    
    // After
    const statusColors = {
      pending: 'yellow',
      approved: 'green', 
      rejected: 'red',
    };
    return statusColors[status] ?? 'gray';

    3. Extract complex conditions

    // Before
    if (user.age >= 18 && user.country === 'US' && user.hasVerifiedEmail && !user.isBanned) {
    
    // After
    const canPurchase = user.age >= 18 
      && user.country === 'US' 
      && user.hasVerifiedEmail 
      && !user.isBanned;
    
    if (canPurchase) {

    Conclusion

    If-else hell is not a character flaw -- it is what happens when code grows organically. The fix is not to avoid conditionals, but to organize them:

    1. Guard clauses for validation

    2. Lookup tables for configuration

    3. Strategy pattern for behavior variation

    Next time you see nested conditionals, do not just add another else-if. Take 30 minutes to refactor. Your future self will thank you.

    Found this helpful?Share this article with your network to help others discover useful AI insights.