How to Refactor a Switch Statement to Polymorphism

Transform your code from rigid switch statements to flexible, maintainable polymorphic designs. Follow this step-by-step guide with practical C# examples to improve your object-oriented programming skills.

Why Switch Statements Become a Maintenance Nightmare in C#

Large switch statements in C# applications often start simple enough. They’re straightforward to implement and easy to understand initially. However, as your application grows, these switch statements can quickly become code smells that violate core object-oriented principles.

The Common Code Smell: Real-World C# Example

Consider this typical scenario in a payment processing system:

public decimal CalculateFee(PaymentMethod paymentMethod, decimal amount)
{
    switch (paymentMethod)
    {
        case PaymentMethod.CreditCard:
            return amount * 0.03m; // 3% fee
        case PaymentMethod.DebitCard:
            return amount * 0.01m; // 1% fee
        case PaymentMethod.BankTransfer:
            return amount * 0.005m + 1.0m; // 0.5% + $1 fee
        case PaymentMethod.PayPal:
            return amount * 0.035m; // 3.5% fee
        case PaymentMethod.Crypto:
            return amount * 0.02m; // 2% fee
        default:
            throw new ArgumentException("Unsupported payment method", nameof(paymentMethod));
    }
}

public enum PaymentMethod
{
    CreditCard,
    DebitCard,
    BankTransfer,
    PayPal,
    Crypto
}

This implementation creates several significant problems:

  1. ❌ Violates the Open/Closed Principle: Adding a new payment method requires modifying existing code
  2. ❌ Creates scattered logic: When payment method behavior appears in multiple switch statements, you must update each one
  3. ❌ Leads to maintenance challenges: As conditions grow, the switch statement becomes harder to read and maintain
  4. ❌ Results in duplicate code: Similar switch structures often appear throughout the codebase

“Switch statements are one of the most common code smells in object-oriented programming. They’re a clear indicator that your code might benefit from polymorphism.” – Robert C. Martin, Clean Code

The Step-by-Step Guide to Replacing Switch Statements with Polymorphism in C#

Polymorphism is a core principle of object-oriented programming that allows us to treat different types through a common interface. By refactoring switch statements to polymorphic designs, we create more flexible, maintainable code that follows SOLID principles.

Step 1: Create an Interface or Abstract Base Class in C#

First, define an interface or abstract class that represents the behavior:

public interface IPaymentMethod
{
    string Name { get; }
    decimal CalculateFee(decimal amount);
}

This interface establishes the contract that all payment methods must follow, providing a foundation for our polymorphic design.

Step 2: Implement Concrete C# Classes for Each Case

Next, create concrete implementations for each case in your original switch statement:

public class CreditCardPayment : IPaymentMethod
{
    public string Name => "Credit Card";
    
    public decimal CalculateFee(decimal amount)
    {
        return amount * 0.03m; // 3% fee
    }
}

public class DebitCardPayment : IPaymentMethod
{
    public string Name => "Debit Card";
    
    public decimal CalculateFee(decimal amount)
    {
        return amount * 0.01m; // 1% fee
    }
}

public class BankTransferPayment : IPaymentMethod
{
    public string Name => "Bank Transfer";
    
    public decimal CalculateFee(decimal amount)
    {
        return amount * 0.005m + 1.0m; // 0.5% + $1 fee
    }
}

public class PayPalPayment : IPaymentMethod
{
    public string Name => "PayPal";
    
    public decimal CalculateFee(decimal amount)
    {
        return amount * 0.035m; // 3.5% fee
    }
}

public class CryptoPayment : IPaymentMethod
{
    public string Name => "Cryptocurrency";
    
    public decimal CalculateFee(decimal amount)
    {
        return amount * 0.02m; // 2% fee
    }
}

Each class encapsulates the behavior for a specific payment method, making the code more modular and easier to maintain.

Step 3: Implement a Factory Method or Registry in C#

To manage the creation of appropriate payment method instances, implement a factory pattern:

public static class PaymentMethodFactory
{
    private static readonly Dictionary<string, IPaymentMethod> _paymentMethods = new Dictionary<string, IPaymentMethod>
    {
        { "CREDIT", new CreditCardPayment() },
        { "DEBIT", new DebitCardPayment() },
        { "BANK", new BankTransferPayment() },
        { "PAYPAL", new PayPalPayment() },
        { "CRYPTO", new CryptoPayment() }
    };

    public static IPaymentMethod GetPaymentMethod(string code)
    {
        if (_paymentMethods.TryGetValue(code.ToUpper(), out var paymentMethod))
        {
            return paymentMethod;
        }
        
        throw new ArgumentException($"Payment method '{code}' not supported", nameof(code));
    }
    
    public static IEnumerable<IPaymentMethod> GetAllPaymentMethods()
    {
        return _paymentMethods.Values;
    }
}

The factory provides a centralized mechanism for getting the right payment method implementation based on a code or identifier.

Step 4: Use the Polymorphic Design in Your C# Application

Now we can rewrite our original code to use the polymorphic design:

public decimal CalculateFee(string paymentMethodCode, decimal amount)
{
    IPaymentMethod paymentMethod = PaymentMethodFactory.GetPaymentMethod(paymentMethodCode);
    return paymentMethod.CalculateFee(amount);
}

That’s it! With just a few lines of code, we’ve transformed a complex switch statement into a clean, extensible design that respects the Open/Closed Principle.

Payment Method Class Diagram – Polymorphic Design with Factory Pattern

<<interface>>
IPaymentMethod
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
implements
Concrete Payment Methods
CreditCardPayment
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
DebitCardPayment
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
BankTransferPayment
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
PayPalPayment
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
CryptoPayment
+ string Name { get; }
+ decimal CalculateFee(decimal amount)
Factory
PaymentMethodFactory
– Dictionary<string, IPaymentMethod> _paymentMethods
+ IPaymentMethod GetPaymentMethod(string code)
+ IEnumerable<IPaymentMethod> GetAllPaymentMethods()
Service
PaymentProcessor
– IDictionary<string, IPaymentMethod> _paymentMethods
+ PaymentProcessor(IEnumerable<IPaymentMethod> paymentMethods)
+ decimal CalculateFee(string paymentMethodName, decimal amount)
Key Relationships
PaymentMethodFactory → creates → IPaymentMethod instances
Concrete classes → implement → IPaymentMethod interface
PaymentProcessor → uses → IPaymentMethod through dependency injection

Implementing Dependency Injection with Polymorphism in C#

In modern C# applications, dependency injection is the preferred way to manage dependencies. Here’s how to incorporate DI with our polymorphic design:

public class PaymentProcessor
{
    private readonly IDictionary<string, IPaymentMethod> _paymentMethods;

    public PaymentProcessor(IEnumerable<IPaymentMethod> paymentMethods)
    {
        _paymentMethods = paymentMethods.ToDictionary(
            p => p.Name.ToUpperInvariant(),
            p => p
        );
    }

    public decimal CalculateFee(string paymentMethodName, decimal amount)
    {
        if (_paymentMethods.TryGetValue(paymentMethodName.ToUpperInvariant(), out var paymentMethod))
        {
            return paymentMethod.CalculateFee(amount);
        }
        
        throw new ArgumentException($"Unsupported payment method: {paymentMethodName}");
    }
}

Then register your payment methods with the Microsoft.Extensions.DependencyInjection container:

// Example using Microsoft.Extensions.DependencyInjection
services.AddSingleton<IPaymentMethod, CreditCardPayment>();
services.AddSingleton<IPaymentMethod, DebitCardPayment>();
services.AddSingleton<IPaymentMethod, BankTransferPayment>();
services.AddSingleton<IPaymentMethod, PayPalPayment>();
services.AddSingleton<IPaymentMethod, CryptoPayment>();
services.AddSingleton<PaymentProcessor>();

This approach leverages the full power of C#’s dependency injection system to create a highly maintainable solution.

5 Advanced C# Design Patterns for Replacing Switch Statements

Let’s explore some powerful design patterns that can help you tackle different types of switch statements in your C# code.

1. Strategy Pattern in C# – The Most Common Replacement

What we’ve implemented so far is essentially the Strategy Pattern, which defines a family of algorithms, encapsulates each one, and makes them interchangeable.

This pattern is ideal when you have:

  • Different variations of an algorithm
  • Multiple conditional statements performing similar tasks
  • A need to avoid exposing complex, algorithm-specific data structures

2. State Pattern in C# – For Status Transitions

Switch statements often manage state transitions. Consider this document processing system:

public void ProcessDocument(Document document)
{
    switch (document.Status)
    {
        case DocumentStatus.Draft:
            ValidateDraft(document);
            document.Status = DocumentStatus.Pending;
            break;
        case DocumentStatus.Pending:
            ReviewDocument(document);
            document.Status = DocumentStatus.Approved;
            break;
        case DocumentStatus.Approved:
            PublishDocument(document);
            document.Status = DocumentStatus.Published;
            break;
        case DocumentStatus.Published:
            ArchiveDocument(document);
            document.Status = DocumentStatus.Archived;
            break;
        default:
            throw new ArgumentException($"Cannot process document in {document.Status} status");
    }
}

We can refactor this using the State Pattern:

public interface IDocumentState
{
    void Process(Document document);
    DocumentStatus Status { get; }
}

public class DraftState : IDocumentState
{
    public DocumentStatus Status => DocumentStatus.Draft;
    
    public void Process(Document document)
    {
        // Validate draft
        Console.WriteLine($"Validating draft document: {document.Title}");
        // Update status
        document.TransitionTo(new PendingState());
    }
}

public class PendingState : IDocumentState
{
    public DocumentStatus Status => DocumentStatus.Pending;
    
    public void Process(Document document)
    {
        // Review document
        Console.WriteLine($"Reviewing pending document: {document.Title}");
        // Update status
        document.TransitionTo(new ApprovedState());
    }
}

// Additional state implementations...

public class Document
{
    private IDocumentState _state;
    
    public string Title { get; set; }
    public DocumentStatus Status => _state.Status;
    
    public Document(string title)
    {
        Title = title;
        // Start in draft state
        _state = new DraftState();
    }
    
    public void TransitionTo(IDocumentState state)
    {
        Console.WriteLine($"Document '{Title}': Transitioning from {_state.Status} to {state.Status}");
        _state = state;
    }
    
    public void Process()
    {
        _state.Process(this);
    }
}

The State Pattern is particularly useful in C# applications that model workflows, document processing, or order management systems.

3. Command Pattern in C# – For Operations and Actions

If your switch statement handles different operations or commands, the Command Pattern is an excellent choice:

// Before
public void ExecuteOperation(OperationType type, object data)
{
    switch (type)
    {
        case OperationType.Save:
            SaveData(data);
            break;
        case OperationType.Delete:
            DeleteData(data);
            break;
        case OperationType.Export:
            ExportData(data);
            break;
        // More cases...
    }
}

// After - Using Command Pattern
public interface ICommand
{
    void Execute(object data);
}

public class SaveCommand : ICommand
{
    public void Execute(object data)
    {
        Console.WriteLine($"Saving data: {data}");
        // Implementation
    }
}

public class DeleteCommand : ICommand
{
    public void Execute(object data)
    {
        Console.WriteLine($"Deleting data: {data}");
        // Implementation
    }
}

// Usage
public class OperationExecutor
{
    private readonly Dictionary<OperationType, ICommand> _commands;
    
    public OperationExecutor()
    {
        _commands = new Dictionary<OperationType, ICommand>
        {
            { OperationType.Save, new SaveCommand() },
            { OperationType.Delete, new DeleteCommand() },
            // Register other commands
        };
    }
    
    public void ExecuteOperation(OperationType type, object data)
    {
        if (_commands.TryGetValue(type, out var command))
        {
            command.Execute(data);
        }
        else
        {
            throw new ArgumentException($"Unsupported operation: {type}");
        }
    }
}

The Command Pattern excels in:

  • UI operations (buttons, menu items)
  • Transaction processing
  • Task scheduling and queuing
  • Undo/redo functionality

4. Visitor Design Pattern – For Operations on Object Structures

When your switch statement operates on a collection of different object types, the Visitor Pattern provides an elegant solution:

// Interface for elements that can be visited
public interface IElement
{
    void Accept(IVisitor visitor);
}

// Concrete elements
public class TextElement : IElement
{
    public string Text { get; set; }
    
    public void Accept(IVisitor visitor)
    {
        visitor.Visit(this);
    }
}

public class ImageElement : IElement
{
    public string Source { get; set; }
    
    public void Accept(IVisitor visitor)
    {
        visitor.Visit(this);
    }
}

// Visitor interface
public interface IVisitor
{
    void Visit(TextElement element);
    void Visit(ImageElement element);
}

// Concrete visitor
public class HtmlExportVisitor : IVisitor
{
    public string Output { get; private set; } = "";
    
    public void Visit(TextElement element)
    {
        Output += $"<p>{element.Text}</p>";
    }
    
    public void Visit(ImageElement element)
    {
        Output += $"<img src=\"{element.Source}\" />";
    }
}

5. Chain of Responsibility in C# – For Sequential Processing

When your switch has fall-through logic or a sequence of handlers, consider the Chain of Responsibility:

public abstract class Handler
{
    protected Handler _successor;
    
    public void SetSuccessor(Handler successor)
    {
        _successor = successor;
    }
    
    public abstract void HandleRequest(Request request);
}

public class AuthenticationHandler : Handler
{
    public override void HandleRequest(Request request)
    {
        if (string.IsNullOrEmpty(request.AuthToken))
        {
            throw new UnauthorizedException("Authentication required");
        }
        
        // Validate token...
        
        _successor?.HandleRequest(request);
    }
}

public class AuthorizationHandler : Handler
{
    public override void HandleRequest(Request request)
    {
        // Check permissions...
        
        _successor?.HandleRequest(request);
    }
}

// Usage
var authenticationHandler = new AuthenticationHandler();
var authorizationHandler = new AuthorizationHandler();

authenticationHandler.SetSuccessor(authorizationHandler);

// Process request
authenticationHandler.HandleRequest(request);

When to Keep Switch Statements in Your C# Code

While polymorphism often leads to cleaner code, sometimes a switch statement remains the most appropriate solution:

  1. Simple, Limited Cases: If you have 2-3 cases that are unlikely to change, refactoring might introduce unnecessary complexity
  2. Performance-Critical Code: Polymorphic dispatch involves a level of indirection that might impact performance in critical sections
  3. Primitive Operations: When operating directly on primitive values like enums or integers
  4. One-Off Code: For code that won’t need to be extended or maintained extensively

💡 Pro Tip: If your switch statement handles fewer than 3 cases and you don’t anticipate adding more, it might be simpler to keep it as is. Refactoring small switch statements can sometimes introduce more complexity than it solves.

Complete C# Example: Notification System Refactoring

Let’s see a comprehensive example combining these concepts in a notification system:

// Before refactoring
public void SendNotification(Notification notification)
{
    switch (notification.Channel)
    {
        case NotificationChannel.Email:
            var emailSettings = GetEmailSettings();
            using (var client = new SmtpClient(emailSettings.Server))
            {
                // Email-specific logic
                client.Send(new MailMessage
                {
                    From = new MailAddress(emailSettings.FromAddress),
                    To = { new MailAddress(notification.Recipient) },
                    Subject = notification.Subject,
                    Body = notification.Body
                });
            }
            break;
            
        case NotificationChannel.SMS:
            var smsSettings = GetSmsSettings();
            var smsClient = new TwilioClient(smsSettings.AccountId, smsSettings.Token);
            // SMS-specific logic
            smsClient.SendMessage(
                smsSettings.FromNumber,
                notification.Recipient,
                notification.Body);
            break;
            
        case NotificationChannel.PushNotification:
            var pushSettings = GetPushSettings();
            var pushClient = new FirebaseMessaging(pushSettings.ApiKey);
            // Push notification-specific logic
            pushClient.Send(new PushMessage
            {
                Token = notification.Recipient,
                Title = notification.Subject,
                Body = notification.Body
            });
            break;
            
        // More channels...
        
        default:
            throw new ArgumentException($"Unsupported notification channel: {notification.Channel}");
    }
    
    // Common logging
    LogNotification(notification);
}

After refactoring to use polymorphism:

public interface INotificationChannel
{
    void Send(Notification notification);
    string ChannelName { get; }
}

public class EmailChannel : INotificationChannel
{
    private readonly EmailSettings _settings;
    
    public string ChannelName => "Email";
    
    public EmailChannel(EmailSettings settings)
    {
        _settings = settings;
    }
    
    public void Send(Notification notification)
    {
        using (var client = new SmtpClient(_settings.Server))
        {
            client.Send(new MailMessage
            {
                From = new MailAddress(_settings.FromAddress),
                To = { new MailAddress(notification.Recipient) },
                Subject = notification.Subject,
                Body = notification.Body
            });
        }
    }
}

public class SmsChannel : INotificationChannel
{
    private readonly SmsSettings _settings;
    
    public string ChannelName => "SMS";
    
    public SmsChannel(SmsSettings settings)
    {
        _settings = settings;
    }
    
    public void Send(Notification notification)
    {
        var smsClient = new TwilioClient(_settings.AccountId, _settings.Token);
        smsClient.SendMessage(
            _settings.FromNumber,
            notification.Recipient,
            notification.Body);
    }
}

// Additional channel implementations...

public class NotificationService
{
    private readonly IDictionary<string, INotificationChannel> _channels;
    private readonly INotificationLogger _logger;
    
    public NotificationService(
        IEnumerable<INotificationChannel> channels,
        INotificationLogger logger)
    {
        _channels = channels.ToDictionary(
            c => c.ChannelName.ToUpperInvariant(),
            c => c
        );
        _logger = logger;
    }
    
    public void SendNotification(Notification notification)
    {
        if (_channels.TryGetValue(notification.Channel.ToUpperInvariant(), out var channel))
        {
            channel.Send(notification);
            _logger.LogNotification(notification);
        }
        else
        {
            throw new ArgumentException($"Unsupported notification channel: {notification.Channel}");
        }
    }
}

7 Key Benefits of Replacing Switch Statements with Polymorphism in C#

Refactoring switch statements to polymorphism offers numerous advantages for your C# codebase:

  1. ✅ Follows Open/Closed Principle: Add new behaviors without modifying existing code
  2. ✅ Increases Maintainability: Related logic is encapsulated within classes rather than scattered across switch statements
  3. ✅ Enhances Testability: Each implementation can be tested in isolation
  4. ✅ Improves Readability: Code intent is clearer and more structured
  5. ✅ Enables Flexibility: Runtime behavior can be configured through dependency injection
  6. ✅ Reduces Duplication: Common behavior can be shared through inheritance
  7. ✅ Simplifies Future Extensions: New implementations simply implement the interface without changing existing code

Summary: Elevate Your C# Code with Polymorphism

Replacing switch statements with polymorphic designs is a powerful technique for creating more maintainable, extensible C# code. While it requires more upfront effort, the benefits become clear as your codebase evolves and grows.

Remember that polymorphism isn’t always the answer. For simple, stable code paths, a switch statement may be more appropriate. The key is recognizing when the complexity and expected change patterns justify the refactoring effort.

By mastering these techniques, you’ll elevate your C# code to be more robust, flexible, and aligned with object-oriented best practices.

Frequently Asked Questions

Is refactoring switch statements always better?

No. For simple cases with 2-3 options that rarely change, a switch statement might be more readable and perform better.

Does using polymorphism impact performance?

Yes, there’s a slight overhead due to virtual method dispatch, but it’s negligible for most applications. Only optimize if profiling shows it’s necessary.

Can I mix both approaches?

Absolutely! You might use a factory with a switch statement to create the appropriate object, then use polymorphism for the behavior.

How does this work with C# pattern matching?

Pattern matching in C# is powerful, but it doesn’t solve the architectural issues of switch statements. Polymorphism still offers better extensibility for complex scenarios.

Scroll to Top