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.