The SOLID Principles

SOLID is five object-design heuristics from Robert C. Martin’s school of thought. They are about where change lands, not about “more interfaces.” Use them when a class has started to rot under two reasons to change. Ignore them as a scorecard for every DTO.

S — Single Responsibility

A module should have one reason to change (one actor / one axis of change), not “one method.” A User entity that also sends email and writes audit rows will change when HR policy, SMTP, or schema changes. Split along those axes: entity, EmailGateway, AuditLog.

UserManager changes when the email rule, SMTP, or the audit table changes.

sealed class UserManager
{
    public void ChangeEmail(User user, string email)
    {
        user.ChangeEmail(email);
        SendSmtp(email, "changed");
        InsertAuditRow("email changed");
    }

    static void SendSmtp(string to, string body) { }
    static void InsertAuditRow(string message) { }
}

The entity changes the email. The other two types change when their own side changes.

sealed class User
{
    public string Email { get; private set; } = "";
    public void ChangeEmail(string email) => Email = email;
}

interface IEmailGateway { void Send(string to, string body); }
interface IAuditLog { void Write(string message); }

sealed class ChangeEmail
{
    private readonly IEmailGateway email;
    private readonly IAuditLog audit;
    public ChangeEmail(IEmailGateway email, IAuditLog audit)
    {
        this.email = email;
        this.audit = audit;
    }

    public void Handle(User user, string next)
    {
        user.ChangeEmail(next);
        email.Send(next, "changed");
        audit.Write("email changed");
    }
}

Trap: a thousand one-line classes is not SRP. Cohesion still matters.

O — Open/Closed

Open for extension, closed for modification: add a new variant without editing the switch you already tested. Mechanisms: new implementations of an interface, composition, data-driven rules. Not “never touch a file.”

// A switch you edit twice a year can stay. This one gained a carrier every week.
static decimal WeeklySwitch(string carrier, decimal weight) => carrier switch
{
    "ups" => weight * 2m,
    "fedex" => weight * 3m,
    "dhl" => weight * 4m,
    _ => throw new ArgumentOutOfRangeException(nameof(carrier))
};

interface IShipper { decimal Price(decimal weight); }

static decimal Price(IShipper shipper, decimal weight) => shipper.Price(weight);

The new carrier is a new IShipper. The twice-a-year switch in the trap below does not need this.

Trap: premature strategy hierarchies for two cases. A switch you edit twice a year is fine.

L — Liskov Substitution

If client code works with type T, it must still work with any subtype of T. Subtypes must honor the parent’s contract: preconditions no stronger, postconditions no weaker, invariants preserved. No surprising exceptions, no no-op methods that the parent promised would do work.

The penguin example is only an LSP bug if Bird guarantees Fly(). Then Penguin : Bird is the wrong model. Fix the contract: Bird does not fly; FlyingBird does. Square/rectangle is the same bug: a Rectangle.SetWidth that a Square silently couples to height breaks callers who set width then height.

This is a bug only because Rectangle lets a caller set the sides independently. Stretch expects 20. A Square returns 25. The repair is two types that do not inherit.

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

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

    public override int Height
    {
        get => base.Height;
        set { base.Height = value; base.Width = value; }
    }
}

static int Stretch(Rectangle shape)
{
    shape.Width = 4;
    shape.Height = 5;
    return shape.Area; // 20 for a Rectangle, 25 for a Square
}
sealed record Box(int Width, int Height)
{
    public int Area => Width * Height;
}

sealed record SquareShape(int Side)
{
    public int Area => Side * Side;
}

I — Interface Segregation

Do not force clients to depend on methods they never call. Fat IMachine with Print/Scan/Fax makes a simple printer fake a scanner. Split roles the clients actually use. This is about call sites, not about one interface per method.

interface IMachine
{
    void Print();
    void Scan();
    void Fax();
}

sealed class SimplePrinter : IMachine
{
    public void Print() { }
    public void Scan() { } // this printer has nothing to scan
    public void Fax() { }
}

interface IPrinter { void Print(); }

sealed class Printer : IPrinter
{
    public void Print() { }
}

IPrinter is the role the caller uses. It is not one interface per method.

D — Dependency Inversion

High-level policy should depend on abstractions it owns, not on SQL or HTTP details. Details depend inward. Dependency injection is a technique (constructor args, a container). DIP is the direction of the source dependency. You can inject SqlOrderRepository everywhere and still violate DIP if the domain references that concrete type.

static void PlaceBroken(SqlConnection db)
{
    _ = db.ConnectionString;
}

static void Place(IOrders orders) => orders.Add(new Order());

Passing a SqlConnection into the use case is the break, even if a container constructed it. Place depends on the port.

Pitfalls

  • SOLID as a lint rule: “must have an interface for every class.”
  • LSP via inheritance of convenience (Stack : List).
  • DIP as “I used the container,” with the domain still importing Entity Framework.

See also hexagonal architecture and code smells.