Introduction

SOLID principles help developers design object-oriented code that is easier to maintain, test, extend, and modify.

However, identifying a SOLID violation is not always as simple as checking whether a class contains multiple methods. The real question is how responsibilities and reasons for change are distributed across the design.

Consider the following Account class:

public class Account
{
    public decimal Balance { get; set; }

    public decimal CalculateInterest(string accountType)
    {
        decimal interest = 0;

        if (accountType.Equals("Regular"))
        {
            interest = Balance * 0.4m;

            if (Balance < 1000)
                interest -= Balance * 0.2m;
            else if (Balance < 50000)
                interest += Balance * 0.4m;
        }
        else if (accountType.Equals("Salary"))
        {
            interest = Balance * 0.5m;
        }

        return interest;
    }
}

At first glance, the class looks simple. However, as more account types and interest rules are added, the design becomes increasingly difficult to maintain.

This article examines the problems in this code and demonstrates two ways to improve the design:

  1. Polymorphism using inheritance.

  2. The Strategy Pattern using composition.

What Issues Exist in the Code?

The main design problems are:

The two SOLID principles most relevant to this example are:

The other SOLID principles are not directly demonstrated by this particular example.

Single Responsibility Principle

The Single Responsibility Principle states that a class should have one reason to change.

The important part is the phrase "reason to change."

The original Account class contains:

public decimal Balance { get; set; }

which represents account state, and:

public decimal CalculateInterest(string accountType)

which contains business rules for different account types.

If the account's data model changes, the class may need to change.

If the interest rules for regular accounts change, the same class also needs to change.

If salary-account rules change, the class needs to change again.

This creates multiple potential reasons for modification.

Example

Suppose the business changes the regular-account interest calculation.

The following code must be modified:

if (accountType.Equals("Regular"))
{
    interest = Balance * 0.4m;
    // ...
}

Now suppose the salary-account calculation also changes.

The same class must be modified again.

As the number of account types grows, more business rules accumulate inside Account.

This is a sign that the responsibilities could be separated.

Open/Closed Principle

The Open/Closed Principle states that software entities should be:

The original implementation violates this principle because adding a new account type requires modifying CalculateInterest().

For example, suppose the application introduces a Premium account.

We would need to add another condition:

else if (accountType.Equals("Premium"))
{
    interest = Balance * 0.7m;
}

The existing class has now been modified to support a new business rule.

If another account type is added later, the method must be modified again.

As the number of account types grows, this can result in a large conditional method.

Why String-Based Account Types Are a Problem

The method accepts:

string accountType

This means the behavior depends on specific string values:

Regular
Salary
Premium

String-based type selection has several disadvantages.

For example:

CalculateInterest("regular");

and:

CalculateInterest("Regular");

are different strings.

A typo can also result in unexpected behavior:

CalculateInterest("Regualr");

The compiler cannot detect this mistake.

Polymorphism or a strategy-based design moves the behavior into types instead of relying on string comparisons.

Approach 1: Using Polymorphism

One way to improve the design is to use inheritance and polymorphism.

Create an abstract base class:

public abstract class Account
{
    public decimal Balance { get; set; }

    public abstract decimal CalculateInterest();
}

The base class contains the common account state while leaving interest calculation to derived classes.

Create a Regular Account

public class RegularAccount : Account
{
    public override decimal CalculateInterest()
    {
        decimal interest = Balance * 0.4m;

        if (Balance < 1000)
        {
            interest -= Balance * 0.2m;
        }
        else if (Balance < 50000)
        {
            interest += Balance * 0.4m;
        }

        return interest;
    }
}

The regular-account rules are now isolated from other account types.

Create a Salary Account

public class SalaryAccount : Account
{
    public override decimal CalculateInterest()
    {
        return Balance * 0.5m;
    }
}

The salary-account rules are now contained within SalaryAccount.

Adding a New Account Type

Suppose a new premium account is required.

We can add another class:

public class PremiumAccount : Account
{
    public override decimal CalculateInterest()
    {
        return Balance * 0.7m;
    }
}

The existing RegularAccount and SalaryAccount classes do not need to be modified.

This demonstrates the Open/Closed Principle more clearly.

Using the Polymorphic Classes

The calling code can work with the base type:

Account account = new RegularAccount
{
    Balance = 5000
};

decimal interest = account.CalculateInterest();

Console.WriteLine($"Interest: {interest}");

The correct implementation is selected through polymorphism.

There is no need to pass:

"Regular"

to the method.

Advantages of the Polymorphism Approach

This approach provides several benefits:

However, inheritance is not always the best choice.

The important design question is whether the interest calculation is actually a fundamental type of account behavior or whether it is a behavior that should be interchangeable independently of the account.

For the latter case, the Strategy Pattern can be a better fit.

Approach 2: Using the Strategy Pattern

The Strategy Pattern separates an algorithm or behavior from the object that uses it.

In this example, the behavior that varies is interest calculation.

Instead of making every interest rule a subclass of Account, we can create an interface:

public interface IInterestCalculator
{
    decimal CalculateInterest(decimal balance);
}

The interface defines what every interest-calculation strategy must implement.

Create the Regular Account Strategy

public class RegularAccountInterestCalculator : IInterestCalculator
{
    public decimal CalculateInterest(decimal balance)
    {
        decimal interest = balance * 0.4m;

        if (balance < 1000)
        {
            interest -= balance * 0.2m;
        }
        else if (balance < 50000)
        {
            interest += balance * 0.4m;
        }

        return interest;
    }
}

The regular-account interest rules now have their own class.

Create the Salary Account Strategy

public class SalaryAccountInterestCalculator : IInterestCalculator
{
    public decimal CalculateInterest(decimal balance)
    {
        return balance * 0.5m;
    }
}

The salary-account calculation is completely independent of the regular-account calculation.

Update the Account Class

The Account class can now receive an interest calculator through its constructor:

public class Account
{
    public decimal Balance { get; set; }

    private readonly IInterestCalculator _interestCalculator;

    public Account(IInterestCalculator interestCalculator)
    {
        _interestCalculator = interestCalculator;
    }

    public decimal CalculateInterest()
    {
        return _interestCalculator.CalculateInterest(Balance);
    }
}

The Account class no longer needs to know which specific interest rule is being used.

It simply delegates the calculation to the configured strategy.

Using the Strategy Pattern

A regular account can be configured like this:

IInterestCalculator regularCalculator =
    new RegularAccountInterestCalculator();

Account regularAccount = new Account(regularCalculator)
{
    Balance = 5000
};

Console.WriteLine(
    $"Regular Account Interest: " +
    $"{regularAccount.CalculateInterest()}");

A salary account can use a different strategy:

IInterestCalculator salaryCalculator =
    new SalaryAccountInterestCalculator();

Account salaryAccount = new Account(salaryCalculator)
{
    Balance = 5000
};

Console.WriteLine(
    $"Salary Account Interest: " +
    $"{salaryAccount.CalculateInterest()}");

The same Account class works with both strategies.

Adding a New Strategy

Suppose the application introduces a premium interest calculation.

Create a new strategy:

public class PremiumAccountInterestCalculator
    : IInterestCalculator
{
    public decimal CalculateInterest(decimal balance)
    {
        return balance * 0.7m;
    }
}

No modification is required in Account.

The new strategy can simply be supplied:

var premiumCalculator =
    new PremiumAccountInterestCalculator();

var premiumAccount =
    new Account(premiumCalculator)
    {
        Balance = 5000
    };

Console.WriteLine(
    $"Premium Account Interest: " +
    $"{premiumAccount.CalculateInterest()}");

This is a practical example of extending behavior without modifying the existing Account implementation.

Polymorphism vs Strategy Pattern

Both approaches can improve the original design, but they model the problem differently.

Aspect

Inheritance

Strategy Pattern

Main mechanism

Class inheritance

Composition

Behavior location

Derived account class

Separate strategy class

Account type represented by

Derived type

Account + selected strategy

Behavior can be changed at runtime

Less convenient

Easier

Reuse calculation independently

Limited

Easy

Coupling

Account coupled to hierarchy

Account depends on interface

Best suited for

Fundamental type differences

Interchangeable behaviors

The Strategy Pattern is particularly useful when the behavior may change independently of the object that uses it.

Which Approach Should You Choose?

There is no universal rule that inheritance is always better than Strategy, or vice versa.

Use polymorphism when different account types genuinely represent different domain objects with their own behavior and identity.

Use the Strategy Pattern when interest calculation is a behavior that can vary independently and potentially be selected or replaced without changing the account itself.

For this example, Strategy is a strong choice because interest calculation is an independent business rule.

Unit Testing the Strategy

One benefit of separating the calculation logic is that each strategy can be tested independently.

For example:

var calculator =
    new SalaryAccountInterestCalculator();

var result = calculator.CalculateInterest(10000);

Console.WriteLine(result);

Expected result:

5000

For the regular calculator:

var calculator =
    new RegularAccountInterestCalculator();

var result = calculator.CalculateInterest(500);

Console.WriteLine(result);

According to the supplied business rules:

Initial interest = 500 × 0.4 = 200
Adjustment       = 500 × 0.2 = 100
Final interest   = 100

Expected result:

100

Separating the strategies makes these calculations easier to test without creating a complete application or depending on unrelated account functionality.

Other Design Improvements

The SOLID redesign solves the main structural problem, but there are additional improvements worth considering.

Avoid Magic Numbers

The original implementation contains values such as:

0.4m
0.2m
0.5m
50000
1000

These values represent business rules but have no descriptive names.

They could be represented using constants:

private const decimal BaseInterestRate = 0.4m;
private const decimal LowBalanceAdjustmentRate = 0.2m;
private const decimal MediumBalanceLimit = 50000m;
private const decimal LowBalanceLimit = 1000m;

This makes the rules easier to understand and change.

Use Meaningful Property Naming

C# naming conventions generally use PascalCase for public properties:

public decimal Balance { get; set; }

rather than:

public decimal balance { get; set; }

Following standard naming conventions improves readability and consistency.

Validate Dependencies

The Strategy implementation should not silently accept a missing calculator.

For example:

public Account(IInterestCalculator interestCalculator)
{
    _interestCalculator =
        interestCalculator
        ?? throw new ArgumentNullException(
            nameof(interestCalculator));
}

This makes configuration errors easier to identify.

SOLID Principles Demonstrated

The revised design primarily demonstrates two SOLID principles.

Single Responsibility Principle

The account manages account-related state, while interest calculators manage interest-calculation rules.

Each class therefore has a more focused responsibility.

Open/Closed Principle

New interest-calculation behavior can be introduced by creating a new implementation of IInterestCalculator.

Existing implementations do not need to be modified.

For example:

public class PremiumAccountInterestCalculator
    : IInterestCalculator
{
    public decimal CalculateInterest(decimal balance)
    {
        return balance * 0.7m;
    }
}

The existing Account class remains unchanged.

What About the Other SOLID Principles?

The original example does not provide enough complexity to meaningfully demonstrate every SOLID principle.

Liskov Substitution Principle

LSP becomes relevant when a base class and derived classes are used together. The polymorphic implementation can follow LSP if every derived account correctly satisfies the contract defined by Account.

Interface Segregation Principle

The IInterestCalculator interface contains only one operation:

decimal CalculateInterest(decimal balance);

This is already a small interface, so there is no obvious ISP violation in this design.

Dependency Inversion Principle

The Strategy implementation also demonstrates DIP because Account depends on the abstraction:

IInterestCalculator

rather than a concrete calculator implementation.

This allows the concrete strategy to be supplied from outside the class.

Final Design

The Strategy-based design can be summarized as:

                    Account
                       |
                       | depends on
                       v
             IInterestCalculator
                 /          \
                /            \
               v              v
        Regular Calculator   Salary Calculator
               |
               +---- Premium Calculator

The account no longer contains a growing list of conditions for every account type.

Instead, each interest rule is isolated behind the same interface.

Conclusion

The original Account class works for a small example, but its design becomes difficult to maintain as more account types and business rules are introduced.

The main issues are the use of string-based type selection, growing conditional logic, and the combination of account state with multiple interest-calculation rules.

Polymorphism can solve the problem by representing different account types as different classes. The Strategy Pattern provides another approach by separating interest calculation into independent strategies.

For this particular example, the Strategy Pattern provides a flexible design because interest calculation is a behavior that can vary independently from the account.

The key lesson is that SOLID principles are not about adding interfaces and classes simply to make code look more complex. They are about separating responsibilities and designing code so that future changes can be made with minimal impact on existing, working behavior.