Sertaç Yıldırım field notes

Home → Engineering

SOLID: A Tool, Not a Goal — When to Use It, When to Skip It

A pull request in code review: “Welcome message.” 5 files, 2 interfaces, 2 classes, 1 factory, 30 lines. The whole job was one string: “Welcome, Ayşe!” The reply to my comment: “To follow SOLID.” They were following the rule — partly: a year earlier, I was the one who wrote “every service has an interface” into our code review guide, and that line explains IGreetingService. The provider and the factory were added “because we might need other languages later”.

Summary
  • SOLID is a tool, not a goal. Each of the five principles exists to remove a specific pain. No pain, no need for the tool.
  • The right place: where things change and where there is more than one of something. Business rules that change often, several implementations, classes you cannot test, a realistic provider switch.
  • The wrong place: one implementation, short life, “we might need it later”. There the principles do not produce flexibility. They produce files.
  • The measure is not compliance. It is how easy the code is to read and how many files one change touches.
  • The right question: not “does this follow SOLID?” but “which problem does this structure solve, and does that problem really exist here?”

What is SOLID there to solve?

Five letters, five separate pains. For each one, first the pain, then the code. The examples are in C#; the idea does not depend on the language.

S — Single Responsibility: not one job, one reason to change

If the class that calculates the invoice total also sends it by e-mail, it changes for two different reasons: when the tax rate changes and when the e-mail template or the sending method changes. When both live in one class, an e-mail change puts the calculation side at risk too and needs a re-test, and the calculation tests get harder because of the e-mail dependency.

Two reasons, one class
public class InvoiceService
{
    public decimal CalculateTotal(Invoice invoice) { /* calculation */ }
    public void SendByEmail(Invoice invoice)       { /* e-mail sending */ }
}
One class per reason
public class InvoiceCalculator
{
    public decimal CalculateTotal(Invoice invoice) { /* calculation */ }
}

public class InvoiceEmailSender
{
    public void Send(Invoice invoice) { /* e-mail sending */ }
}

Careful: “single responsibility” does not mean “single method”. The measure is how many different reasons make the class change.

O — Open/Closed: not a new else if, a new class

If every new payment method means opening the same method and adding one more branch, you put working code at risk every time. Every new method means re-testing all the previous ones.

Every method = open and change
public void Pay(string method, decimal amount)
{
    if (method == "CreditCard")   { /* credit card */ }
    else if (method == "PayPal")  { /* PayPal */ }
    // new method = new else if = re-test working code
}
Every method = new class, old code untouched
public interface IPaymentMethod
{
    void Pay(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public void Pay(decimal amount) { /* credit card */ }
}

public class PayPalPayment : IPaymentMethod
{
    public void Pay(decimal amount) { /* PayPal */ }
}

To be honest: the decision of which class to pick for the method string does not disappear. It moves to one place — a factory, a dictionary or the DI registration. That place still changes with every new method. The gain is not that the if chain is gone; it is that the working payment code is not put at risk every time.

L — Liskov Substitution: not “true in mathematics”, a behavior contract

When a subclass replaces its parent, the expectations of the code that uses the parent type — its contract — must not break. Different behavior is normal; that is what polymorphism is for. What must not break is the correctness of the calling code. The classic trap: a square is a rectangle, yes — in mathematics. In code, if Square inherits from Rectangle and changing the width also changes the height, code that expects a rectangle calculates the wrong area.

Expected 20, got 16
public class Rectangle
{
    public virtual int Width  { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width  { set { base.Width = base.Height = value; } }
    public override int Height { set { base.Width = base.Height = value; } }
}

Rectangle r = new Square();
r.Width  = 5;
r.Height = 4;
Console.WriteLine(r.Area);   // expected 20, prints 16
Not inheritance, a shared abstraction
public interface IShape
{
    int Area { get; }
}

public class Rectangle : IShape
{
    public int Width  { get; set; }
    public int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : IShape
{
    public int Side { get; set; }
    public int Area => Side * Side;
}

I — Interface Segregation: not one giant interface, small focused ones

If a basic printer has to implement scan and fax methods too, the interface was cut in the wrong place. The symptom is clear: classes full of throw new NotSupportedException().

Implementing what you cannot do
public interface IMachine
{
    void Print(Document doc);
    void Scan(Document doc);
    void Fax(Document doc);
}

public class BasicPrinter : IMachine
{
    public void Print(Document doc) { /* print */ }
    public void Scan(Document doc)  => throw new NotSupportedException();
    public void Fax(Document doc)   => throw new NotSupportedException();
}
Everyone signs only what they can do
public interface IPrinter { void Print(Document doc); }
public interface IScanner { void Scan(Document doc); }

public class BasicPrinter : IPrinter
{
    public void Print(Document doc) { /* print */ }
}

public class MultiFunctionPrinter : IPrinter, IScanner
{
    public void Print(Document doc) { /* print */ }
    public void Scan(Document doc)  { /* scan */ }
}

D — Dependency Inversion: not the detail, an abstraction you define yourself

If the business logic creates SqlServerOrderRepository itself, testing that class needs a real SQL Server. And when the database changes, you touch the business logic. The principle says this: the high-level module (order logic) must not depend on the low-level detail (SQL Server). Both depend on an abstraction, and the high-level side defines that abstraction for its own needs. That is why IOrderRepository belongs to the order logic, not to the data access layer. The practical way to apply it is to pass the dependency in from outside: dependency injection. The two are not the same thing. Taking a concrete SqlServerOrderRepository in the constructor “passes it in from outside” but does not invert the dependency.

The dependency is born inside
public class OrderService
{
    private readonly SqlServerOrderRepository _repository = new();

    public void PlaceOrder(Order order) => _repository.Save(order);
}
The dependency comes from outside, through an abstraction
public interface IOrderRepository
{
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void PlaceOrder(Order order) => _repository.Save(order);
}

Now OrderService works the same way with SQL Server, PostgreSQL or a fake repository in tests. Of the five, this is the one that pays off most often, because “a fake object for tests” is a real need in almost every project.

SOLID is a tool, not a goal. A tool helps where the problem is; where the problem is not, it is just weight.

When does it really help?

The principles were made for specific signals. If the signal is really present in your project, you get more back than you put in. Here is the signal, the principle that answers it and what you gain:

SignalPrincipleWhat you gain
Business rules change often (campaigns, tax, pricing)S, OA change stays inside one class; the regression area shrinks
The same thing has several implementations (2 payment providers, e-mail + SMS + push)O, DNew provider = new class; existing code stays untouched
Testing a class needs a real DB or external serviceDTests become possible with a fake object
A provider switch is realistic (library, cloud, storage)D, IThe migration becomes a change in one class
An inheritance tree has “this method does not work for me”L, ISubclasses stop producing surprises; NotSupportedException disappears
The code will live for years and more than one team will touch itAll — in the parts that change and multiplyNobody has to ask who changed whose code and why. But this signal does not say “abstract every part”; a long-lived project also has parts with one implementation

When does it hurt?

This side of the coin gets less attention. Applying the principles without a need does not make the code better. It makes it longer. The signals:

  • One implementation. A class has one implementation and no second one in sight. Here an interface is not flexibility; it is one more file.
  • Small or short-lived project. An internal tool with a few screens, a two-week prototype. Layered architecture, factories and abstraction hierarchies are not worth the effort.
  • “We might need it later.” Most abstractions written with this sentence are never used. But they stay in the project as code that has to be read, understood and maintained.
  • Five layers for a few lines of work. A simple read that goes Controller → Service → Manager → Repository → Mapper. Adding one field means touching 5 files.
  • SOLID as a checklist. The focus moves from the problem being solved to code that “looks right”. The reply in that PR was exactly this: “to follow SOLID.”
The abstraction earns its place
  • A second implementation exists today or is in the sprint plan
  • The class cannot be tested without a fake object
  • The same pattern has shown up for the third time
  • Every change opens the same if chain
The abstraction is a burden
  • One implementation; the second is “maybe one day”
  • The interface has one implementation, and only the DI registration knows about it
  • The factory produces only one type
  • Layers pass the parameter straight down without doing anything

From the field: between 30 lines and 4 lines

The PR from the opening. The feature: show the user a welcome message. The “fully SOLID compliant” version looked like this:

2 interfaces, 2 classes, 1 factory
public interface IGreetingMessageProvider
{
    string GetMessage(string userName);
}

public class DefaultGreetingMessageProvider : IGreetingMessageProvider
{
    public string GetMessage(string userName) => $"Welcome, {userName}!";
}

public interface IGreetingService
{
    string Greet(string userName);
}

public class GreetingService : IGreetingService
{
    private readonly IGreetingMessageProvider _provider;

    public GreetingService(IGreetingMessageProvider provider)
    {
        _provider = provider;
    }

    public string Greet(string userName) => _provider.GetMessage(userName);
}

public class GreetingServiceFactory
{
    public IGreetingService Create() =>
        new GreetingService(new DefaultGreetingMessageProvider());
}

The actual need was this:

The whole requirement
public class GreetingService
{
    public string Greet(string userName) => $"Welcome, {userName}!";
}

The first version has two interfaces, two classes and a factory, and none of them solves a real problem. One message format, one implementation, a rule that is unlikely to change. Check it against the table: no business rule that changes often, no second implementation, no fake object needed for tests (it is a pure function that returns a string), no provider switch, no inheritance. Five of the six signals are absent. The sixth — the product will live for years — is true; but that signal speaks for the parts of the project that change and multiply, not for a one-line message.

The conversation took five minutes and ended with this question: “What if tomorrow we need different messages per language or user type?” The answer: we extract an interface that day and register it in DI; a small refactor, its size depends on the number of call sites. If we write it today, everyone who reads this code for the next year walks through five files. The PR went from five types to one, from 30 lines to 4.

The real fix was not in the code. I deleted the line “every service has an interface” from the code review guide and replaced it with “an interface when there is a second implementation or a test need”. This rule looks at the number of implementations; the rule of three below looks at repeated code. Two different questions: “how many real implementations are there?” and “how many times was the same code written?” The old rule was short, easy to apply and wrong. A wrong rule costs more than no rule, because everyone follows it.

An abstraction written “because we might need it later” is usually never needed. But it is read every day.

How do you find the balance?

  • YAGNI. You Aren’t Gonna Need It: do not build today the flexibility you do not need today. Flexibility has a maintenance cost too; unused flexibility is pure cost.
  • The rule of three. Write it directly the first time. Notice the repetition the second time. Abstract the third time. This way the abstraction rests on a real pattern, not on a guess, and the shape of the interface is more likely to be right because it came from three concrete examples.
  • KISS. Keep It Simple, Stupid: do not add complexity you do not need. If you have to visit three files to see what a method does, there is complexity.
  • Trust refactoring. In a code base with tests, starting simple and growing the structure when the need appears is cheaper than designing for every possibility up front. Extracting an interface from an instance method is one command in the IDE.
  • Change the question. Instead of “does this code follow SOLID?”, ask “which problem does this structure solve, and does that problem really exist?”

What to watch

Symptoms of over-abstraction in a code base
  • Number of interfaces with one implementation — every interface where the IDE shows “1 implementation” is a question mark — not counting test doubles; if the reason is testing, the interface earns its place (like IOrderRepository in the DIP section).
  • Files touched to add one field — 5 or more means the layers pass parameters without doing work.
  • Factories that produce one type — if the factory does not choose, new is enough.
  • Implementations that throw NotSupportedException — the interface was cut wrong (I) or the inheritance is wrong (L).
  • An if/switch that grows with every new case — after the third branch, a real signal for O.
  • Unit tests that need a real DB or external service — the most concrete need for D.

Checklist

Before adding an interface, a layer or a factory
  • Does this abstraction have a second implementation today, or in the sprint plan?
  • Can I test the class without a mock? (If yes, what is the reason for the interface?)
  • Is this the third time I see this pattern, or the first?
  • Does this change touch a business rule that changes often?
  • Does the layer I am adding do any work, or does it pass the parameter straight down?
  • Does the factory make a choice?
  • How many files does adding one field touch; is it 5 or more?
  • Is there a method in the hierarchy that throws NotSupportedException?
  • If I added this abstraction on the day I need it instead of today, how many minutes would it take?
  • Does my code review guide contain an unconditional rule of the form “every X gets a Y”?

Conclusion

SOLID is a valuable set of tools, made to stop code from breaking when requirements change. In projects with a lot of change, several implementations and a life of many years, it is essential. In small, simple or short-lived work, the same principles turn into unnecessary layers and a structure that is hard to understand.

Those 30 lines in the PR broke none of the five principles. The problem was not in the code; it was in the rule. I had written the principle as a goal, not as a tool. Turning five types into one took five minutes. The wrong sentence lived in the guide for a year.

The measure of good design is not how closely it follows the principles. It is how easy the code is to read and how many files a change touches. Memorising a principle and applying it everywhere is easy. Recognising the pain behind it and asking does that pain exist in this project is hard. Mastery is the second one.