Levelwise
English
Code design

SOLID principles

Five simple principles that help object-oriented code not break when it changes. Each class should have one reason to change, new behavior should come with a new class, and the important parts should not depend on details. The goal is code that is easier to change, not more classes.

Not reviewedWritten with AI helpReading time: 16 minExample of orders and payment in an online shopC# and .NET 10 code

Author: bezzad

The problem: code that breaks with every change

Our online shop has an OrderService class. At first it was small. Now it calculates the price, reserves the items, takes the money and sends promotional emails.

Every month, the same thing happens:

  1. The marketing team changes the email text. A developer opens OrderService.
  2. One wrong line in the same file breaks the price calculation. Nobody expected it.
  3. To test the email, you must set up the database, the payment gateway and the email server. So no test gets written.

The SOLID principles are five simple rules for this exact problem. Each letter is one principle:

Letter Full name One simple sentence
S Single Responsibility Each class should have only one reason to change.
O Open/Closed Add new behavior with new code, not by changing old code.
L Liskov Substitution Each implementation must be able to take the place of the base type without surprises.
I Interface Segregation Do not force anyone to depend on a method they do not need.
D Dependency Inversion The important parts should depend on an interface, not on details.

First principle (S): one reason to change

What does “one responsibility” mean? “Only one job” is a vague definition. A better definition is this: a class should answer to only one group of people. Each group has its own reason for change.

FinanceWarehouseMarketingBefore: three reasons to changeOrderServiceCalculateTotal()ReserveStock()SendPromoEmail()SplitAfter: each class, one reasonPriceCalculatorStockReservationPromoEmailSender
Three teams, three reasons to change one file. After splitting, each team only touches its own class.

Why is this important?

  1. A change by one team does not break another team’s work. The email text is in a class that has nothing to do with the price.
  2. Classes become small. Each one has only its own dependencies.
  3. Testing becomes simple. To test the price, you only need the price class.

After splitting, OrderService only coordinates the work. It does not run any rule itself:

public sealed class PlaceOrderService(
    PriceCalculator prices,
    IStockReservation stock,
    IOrderRepository orders)
{
    public async Task<Guid> PlaceAsync(Order order, CancellationToken ct)
    {
        order.SetTotal(prices.Calculate(order));
        await stock.ReserveAsync(order, ct);
        await orders.AddAsync(order, ct);
        return order.Id;
    }
}
Signs that this principle is broken: The class constructor has seven or eight dependencies. The class name has words like Manager or Service with no explanation. Or, to describe what the class does, you use the word “and” many times.

Second principle (O): open for extension, closed for change

Every few weeks, the sales team wants a new discount: a VIP customer discount, a Nowruz discount, a first purchase discount. If they are all in one switch, each new discount means opening and changing tested code.

Each new discount reopens this classPriceCalculatorswitch (discount.Type)case Vip: ...case Nowruz: ...case FirstOrder: ...case ???: ...Old, tested code changes againRisk of breaking older discountsNew discount, new classPriceCalculatorIDiscountRuleVipDiscountNowruzDiscountFirstOrderDiscountOld classes stay untouchedAdd only one class and one DI line

A better way is this: make an interface for a “discount rule”. Each discount is a separate class.

public interface IDiscountRule
{
    decimal Apply(Order order, decimal amount);
}

public sealed class VipDiscount : IDiscountRule
{
    public decimal Apply(Order order, decimal amount) =>
        order.Customer.IsVip ? amount * 0.9m : amount;
}

public sealed class PriceCalculator(IEnumerable<IDiscountRule> rules)
{
    public decimal Calculate(Order order) =>
        rules.Aggregate(order.Subtotal, (amount, rule) => rule.Apply(order, amount));
}

Registering in DI:

builder.Services.AddScoped<IDiscountRule, VipDiscount>();
builder.Services.AddScoped<IDiscountRule, NowruzDiscount>();
builder.Services.AddScoped<PriceCalculator>();

Now a new discount means one new class and one registration line. The PriceCalculator class never changes. This is the Strategy pattern that you see in the design patterns lesson.

You do not need it from day one. If you only have two fixed cases and they will not grow, a simple if is enough. When you see that one place in the code is opened again and again for the same kind of change, then use this principle.

Third principle (L): substitution without surprises

Every class that implements an interface makes a promise. The code that works with the interface trusts this promise. The Liskov principle says: each implementation must keep the same promise.

Our shop has three payment methods: bank card, wallet and gift card. They all have one interface:

public interface IPaymentMethod
{
    Task PayAsync(Order order, CancellationToken ct);
    Task RefundAsync(Order order, CancellationToken ct);
}

public sealed class GiftCardPayment : IPaymentMethod
{
    public Task PayAsync(Order order, CancellationToken ct) => Task.CompletedTask; // simplified

    public Task RefundAsync(Order order, CancellationToken ct) =>
        throw new NotSupportedException("Gift cards cannot be refunded.");
}

The order cancel code calls RefundAsync for all methods. It works with a bank card. With a gift card, it throws an error at run time. The compiler gave no warning.

A good implementation keeps these three rules:

  1. It does not ask for stricter input. If the interface accepts any order, the implementation must not say “only orders above one million”.
  2. It does not give weaker output. If it promised to always return a result, it must not sometimes return null.
  3. It does not throw surprising errors. An error like NotSupportedException for a promised method is a sign that this principle is broken.
An example in .NET itself: An array in .NET implements the generic version of the IList interface, but its Add method throws a NotSupportedException error. Because of this, code must first check the IsReadOnly property. This is an old compromise, not a pattern for our code.

Fourth principle (I): small interfaces

Where did the gift card problem come from? From a big interface. The interface forced all payment methods to also have a refund. The fourth principle says: do not force anyone to depend on a method they do not need.

One big interfaceIPaymentMethodPay()Refund()CardPaymentRefund() → OKGiftCardPaymentRefund() → throwThe gift card breaks the interface promise.Code that calls refund fails at run time.Two small interfacesIPaymentMethodPay()IRefundableRefund()GiftCardPaymentPay onlyCardPaymentPay and refundEach class only promises what it keeps.The compiler blocks gift card refunds.
By splitting the interface, the problem of the third principle is also solved. Now the compiler does not allow a refund call for a gift card.
public interface IPaymentMethod
{
    Task PayAsync(Order order, CancellationToken ct);
}

public interface IRefundable
{
    Task RefundAsync(Order order, CancellationToken ct);
}

public sealed class CardPayment : IPaymentMethod, IRefundable { /* ... */ }
public sealed class GiftCardPayment : IPaymentMethod { /* ... */ }

The order cancel code now works only with IRefundable. If a payment method has no refund, it knows this from the start, and it can, for example, give new gift credit.

  • Sign of a bad interface: implementations that leave some methods empty or throw errors.
  • The right size: build the interface based on the needs of the user of the interface, not on everything the class can do.

Fifth principle (D): dependency inversion

After placing an order, the order service tells the customer. The first version creates the email class directly. Now if we want to send an SMS, we must change the order logic. This means the important part (business policy) depends on the less important part (technical details).

Before: policy depends on detailsPlaceOrderServicenewSmtpEmailSenderSMS instead of email: change order serviceAfter: both depend on one contractOrder part (high level)PlaceOrderServiceIOrderNotifierEmailOrderNotifierDependency direction is flippedDetails depend on policy, not the reverse

The solution has three steps:

  1. The order part defines an interface. This interface speaks the language of orders: “tell the customer that the order is placed.” The word email is not in it.
  2. The order service knows only this interface. It takes it in the constructor.
  3. The email class implements this interface. Now the details depend on the policy.
// Owned by the ordering code. It speaks the business language.
public interface IOrderNotifier
{
    Task OrderPlacedAsync(Order order, CancellationToken ct);
}

// Infrastructure detail. It depends on the interface above.
public sealed class EmailOrderNotifier(IEmailClient email) : IOrderNotifier
{
    public Task OrderPlacedAsync(Order order, CancellationToken ct) =>
        email.SendAsync(order.CustomerEmail, "Your order is placed", ct);
}

builder.Services.AddScoped<IOrderNotifier, EmailOrderNotifier>();

Do you need SMS tomorrow? You write an SmsOrderNotifier class and only change the registration line. In tests, a fake version of IOrderNotifier is enough.

Dependency inversion is not the same as dependency injection. Dependency injection (DI) is a technical method: the dependency is given from outside, through the constructor. Dependency inversion is a design decision: who owns the interface. If the interface sits next to the email class and speaks the language of email, the dependency is not inverted, even though you have DI. This same idea is the base of Clean Architecture.

The principles work together

These five principles are not separate from each other. In our example:

  • By splitting responsibilities (S), we have small classes, and each one has one job.
  • With small interfaces (I), each class only makes a promise it keeps. So substitution (L) is safe.
  • With dependency inversion (D), a new class plugs in without changing old code. So the code is open for extension (O).

Common mistakes

Mistake Why is it bad? The right way
One interface for every class, always The number of files doubles, with no benefit. An interface when there are two implementations, or a real test boundary.
Splitting into tiny one-method classes To understand one simple task, you must open ten files. Split based on “reason to change”, not the number of lines.
An implementation that throws a NotSupported error The interface promise is broken, and the error is found at run time. Make the interface smaller.
An interface in technical language, next to technical code Even though you have DI, the business logic still depends on details. An interface in business language, owned by the part that uses it.
Building structure for a change that may never come Complexity today, for a benefit that may never come (YAGNI). Write it simple first. When the change really repeats, add structure.

When should we be strict?

Worth it

  • Important business logic that changes often, like price and discounts.
  • Places where we have several real implementations, like payment methods.
  • The boundary with the outside world: database, email, bank gateway.
  • Code that several teams work on.

Not worth it

  • A small script or a one-time tool.
  • Simple CRUD pages with no special rules.
  • A class that has only one implementation and is never replaced in tests.
  • When you do not know yet what is going to change.
One question for every decision: “Does this design make the next change cheaper, or does it only make the code bigger?” The SOLID principles are tools, not the goal.

Summary in six lines

  1. Each class should answer to only one group of people and have one reason to change.
  2. Add new behavior with a new class, not by opening tested code.
  3. Each implementation must fully keep the interface promise. A NotSupported error is a red flag.
  4. Make interfaces small and based on the needs of their users.
  5. The important logic owns the interface. The technical details implement it.
  6. Use these principles when you see real change, not for “maybe one day”.