SOLID is five design principles that show up in nearly every senior software engineering interview, and gets asked about far more often than it gets properly explained. Most explanations state what each letter stands for and move on. This post goes one level deeper for each principle: the plain-English meaning, an analogy, a real C# example of the violation, and the refactored fix - the actual code smell each principle is designed to catch, not just the definition.
SOLID applies to any object-oriented language. The examples here are in C#, but the underlying reasoning transfers directly to Java, TypeScript, Python, or anything else built around classes and interfaces.
S — Single Responsibility Principle
A class should have one reason to change.
This is commonly misquoted as "a class should do one thing" or "a class should have one method," which is not quite right and leads to unnecessarily fragmented code. A class can have several methods and still follow this principle perfectly well, as long as every method exists to serve one single, cohesive responsibility. The actual test is: if a change request comes in, does it map to exactly one reason, or could two unrelated business changes both force an edit to the same class for
completely different reasons?
Think of a restaurant kitchen. A single station handles one job - the grill station only grills, the salad station only assembles salads. If one station tried to grill, plate desserts, and manage the wine list all at once, changing how desserts are plated would risk breaking something on the grill line, even though the two have nothing to do with each other. Single Responsibility is keeping each station focused on the one thing that station is actually responsible for.
The violation, and why it hurts:
// Violates SRP - this class has at least three
// separate reasons to change: how an invoice is
// calculated, how it's formatted for printing, and
// how it's saved to the database
public class Invoice
{
public decimal CalculateTotal(List<LineItem> items)
{
return items.Sum(i => i.Price * i.Quantity);
}
public string FormatForPrint(Invoice invoice)
{
return $"Invoice Total: ${invoice.Total:F2}\n" +
$"Items: {invoice.Items.Count}";
}
public void SaveToDatabase(Invoice invoice)
{
// direct SQL connection code here
using var connection = new SqlConnection(_connString);
connection.Open();
// ... save logic
}
}
// A change to the print FORMAT (marketing wants a new
// receipt layout) has no business relationship to a
// change in how totals are CALCULATED, or how invoices
// are SAVED - but all three live in one class, and a
// bug introduced while editing one can silently break
// the others
The fix - separating by actual reason to change:
// Each class now has exactly one reason to change
public class InvoiceCalculator
{
public decimal CalculateTotal(List<LineItem> items)
{
return items.Sum(i => i.Price * i.Quantity);
}
}
public class InvoicePrinter
{
public string FormatForPrint(Invoice invoice)
{
return $"Invoice Total: ${invoice.Total:F2}\n" +
$"Items: {invoice.Items.Count}";
}
}
public class InvoiceRepository
{
private readonly string _connString;
public InvoiceRepository(string connString)
{
_connString = connString;
}
public void Save(Invoice invoice)
{
using var connection = new SqlConnection(_connString);
connection.Open();
// ... save logic
}
}
// A print format change now only touches InvoicePrinter.
// A calculation change now only touches InvoiceCalculator.
// Neither can accidentally break the other.
O — Open/Closed Principle
Software entities should be open for extension, but closed for modification.
New behavior should be addable without editing code that already works and is already tested. The most common real violation is a growing switch statement or if/else chain that needs a new branch every time a new case shows up - each new case means reopening code that was previously finished, tested, and working, with the risk of breaking an existing case while adding a new one.
Think of a power strip. Adding a new device means plugging it into an open socket - it does not require opening up the strip itself and rewiring it. The strip is "closed" against modification but "open" for extension through its sockets. Every new case in a growing switch statement is the equivalent of opening the power strip itself just to add one more device.
The violation:
// Every new discount type means editing this method
// and re-testing every existing case along with it
public class DiscountCalculator
{
public decimal ApplyDiscount(string discountType,
decimal amount)
{
switch (discountType)
{
case "Percentage":
return amount * 0.9m;
case "FixedAmount":
return amount - 10m;
case "BuyOneGetOne":
return amount * 0.5m;
// Adding "LoyaltyPoints" discount next month
// means opening this method again
default:
return amount;
}
}
}
The fix - polymorphism instead of branching:
public interface IDiscount
{
decimal Apply(decimal amount);
}
public class PercentageDiscount : IDiscount
{
private readonly decimal _percentage;
public PercentageDiscount(decimal percentage)
=> _percentage = percentage;
public decimal Apply(decimal amount)
=> amount * (1 - _percentage);
}
public class FixedAmountDiscount : IDiscount
{
private readonly decimal _amount;
public FixedAmountDiscount(decimal amount)
=> _amount = amount;
public decimal Apply(decimal amount)
=> amount - _amount;
}
public class BuyOneGetOneDiscount : IDiscount
{
public decimal Apply(decimal amount) => amount * 0.5m;
}
// Adding LoyaltyPointsDiscount next month means writing
// ONE NEW CLASS. Nothing that already works gets touched.
public class LoyaltyPointsDiscount : IDiscount
{
private readonly int _points;
public LoyaltyPointsDiscount(int points) => _points = points;
public decimal Apply(decimal amount)
=> amount - (_points * 0.01m);
}
public class DiscountCalculator
{
public decimal ApplyDiscount(IDiscount discount,
decimal amount)
=> discount.Apply(amount);
}
// Usage
var calculator = new DiscountCalculator();
var total = calculator.ApplyDiscount(
new PercentageDiscount(0.1m), 100m); // 90
L — Liskov Substitution Principle
Objects of a superclass should be replaceable with objects of any subclass without breaking the correctness of the program.
This is the SOLID principle most explanations get slightly wrong by stopping at "subclasses should properly inherit from their base class." The actual requirement is stricter: code written against the base type must behave correctly no matter which specific subtype it actually receives at runtime, without needing to know or check which one it got.
Think of a universal remote's power button. Pressing "power" should turn on whatever device the remote is paired with - a TV, a soundbar, a projector - without the person pressing the button needing to know or care which device it actually is. If pressing power on one particular device did something completely unexpected (muted the volume instead of turning on), that device would violate the substitution expectation, even if it technically "inherited" from the same remote-compatible interface.
The classic violation - Square inheriting from Rectangle:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
// Looks reasonable - a Square IS-A Rectangle
// mathematically, so inheritance seems natural
public 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; }
}
}
// This code is written against Rectangle and expects
// setting Width and Height independently to work
public void Resize(Rectangle rect)
{
rect.Width = 5;
rect.Height = 10;
// For a real Rectangle: Area() == 50, as expected
// For a Square substituted in: setting Height also
// silently changed Width, so Area() == 100, NOT 50
Console.WriteLine(rect.Area());
}
// Passing a Square here breaks the caller's expectation
// silently - this is a genuine LSP violation, even
// though Square "inherited properly" in a syntax sense
Resize(new Square());
The fix - do not force an inheritance relationship where
the substitution guarantee cannot actually hold:
// Model the actual shared behavior via an interface
// rather than forcing Square to inherit from Rectangle
public interface IShape
{
int Area();
}
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;
}
// Code written against IShape only ever calls Area(),
// so both types can be substituted safely - there is
// no independent Width/Height setter contract to violate
public void PrintArea(IShape shape)
{
Console.WriteLine(shape.Area());
}
PrintArea(new Rectangle { Width = 5, Height = 10 }); // 50
PrintArea(new Square { Side = 5 }); // 25
I — Interface Segregation Principle
No client should be forced to depend on methods it does not use.
Fat interfaces - ones with many methods bundled together - quietly punish every class that implements them, even implementations that only ever need a small subset of those methods. The usual symptom is a method body that throws NotImplementedException or does nothing, purely to satisfy an interface contract that does not actually apply to that implementer.
Think of a Swiss Army knife with fifteen tools bolted into one handle. If someone only ever needs the blade, they still carry the weight and bulk of the corkscrew, the saw, and the toothpick, whether or not those tools are ever used. Interface Segregation is choosing to carry several small, purpose-built tools instead, so nobody carries functionality they never asked for.
The violation:
// One fat interface trying to cover every worker type
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
public class HumanWorker : IWorker
{
public void Work() => Console.WriteLine("Working");
public void Eat() => Console.WriteLine("Eating lunch");
public void Sleep() => Console.WriteLine("Resting");
}
// A robot worker is forced to implement Eat() and
// Sleep(), neither of which makes any sense for it
public class RobotWorker : IWorker
{
public void Work() => Console.WriteLine("Working");
public void Eat()
=> throw new NotImplementedException(
"Robots don't eat");
public void Sleep()
=> throw new NotImplementedException(
"Robots don't sleep");
}
// Any code calling Eat() or Sleep() on an IWorker
// now risks a runtime exception depending on which
// concrete type it actually received
The fix - smaller, purpose-specific interfaces:
public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface ISleepable { void Sleep(); }
// Human implements all three - genuinely needs all three
public class HumanWorker : IWorkable, IFeedable, ISleepable
{
public void Work() => Console.WriteLine("Working");
public void Eat() => Console.WriteLine("Eating lunch");
public void Sleep() => Console.WriteLine("Resting");
}
// Robot implements only what actually applies to it -
// no forced empty or throwing implementations
public class RobotWorker : IWorkable
{
public void Work() => Console.WriteLine("Working");
}
// Code that only cares about work capability depends
// only on IWorkable - it never even knows Eat() or
// Sleep() exist as concepts, and neither implementer
// is forced to fake behavior it doesn't have
public void AssignTask(IWorkable worker)
{
worker.Work();
}
AssignTask(new HumanWorker());
AssignTask(new RobotWorker());
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.
This is the principle behind nearly every "inject an interface, not a concrete class" pattern in modern software. Without it, high-level business logic ends up directly wired to specific low-level implementation details - a specific database, a specific email provider, a specific file system - and swapping any of those out later means editing the high-level code itself.
Think of a wall power socket. A lamp does not wire itself directly into the electrical grid's specific generator - it plugs into a standard socket, and the socket is the abstraction that both the lamp (high-level) and the power plant (low-level) depend on. The lamp works identically whether the electricity behind that socket comes from a coal plant, a solar farm, or a generator - because neither side depends on the other directly, both depend on the socket standard.
The violation:
// OrderService (high-level business logic) is directly
// wired to SqlOrderRepository (a low-level, specific
// implementation detail)
public class SqlOrderRepository
{
public void Save(Order order)
{
// direct SQL Server specific code
}
}
public class OrderService
{
private readonly SqlOrderRepository _repository;
public OrderService()
{
// OrderService is now permanently wired to
// SQL Server specifically - switching to
// Cosmos DB later means editing THIS class
_repository = new SqlOrderRepository();
}
public void PlaceOrder(Order order)
{
// business logic
_repository.Save(order);
}
}
The fix - both sides depend on an abstraction instead:
// The abstraction both sides depend on
public interface IOrderRepository
{
void Save(Order order);
}
// Low-level detail depends on the abstraction
public class SqlOrderRepository : IOrderRepository
{
public void Save(Order order)
{
// SQL Server specific code
}
}
// A second implementation can be added without
// touching OrderService at all
public class CosmosOrderRepository : IOrderRepository
{
public void Save(Order order)
{
// Cosmos DB specific code
}
}
// High-level module now depends only on the abstraction,
// not on any specific low-level implementation
public class OrderService
{
private readonly IOrderRepository _repository;
// The concrete implementation is injected from
// outside - OrderService never needs to know or
// care which one it actually received
public OrderService(IOrderRepository repository)
{
_repository = repository;
}
public void PlaceOrder(Order order)
{
// business logic
_repository.Save(order);
}
}
// Usage - swap the implementation with zero changes
// to OrderService itself
var sqlService = new OrderService(new SqlOrderRepository());
var cosmosService = new OrderService(new CosmosOrderRepository());
This is exactly the mechanism behind ASP.NET Core's built-in dependency injection container - registering IOrderRepository against a concrete implementation in Program.cs, then having it injected automatically into
any constructor that asks for the interface, is Dependency Inversion applied at the framework level.
How the Five Principles Reinforce Each Other
These principles are not five unrelated rules - they support each other in practice. A class that violates Single Responsibility is often also harder to keep Open/Closed, because a class doing several unrelated things tends to need modification for more unrelated reasons. A fat interface that violates Interface Segregation often also makes Liskov Substitution harder to guarantee, because implementers are forced to fake methods that don't genuinely apply to them, and code depending on the interface can no longer trust that every implementation behaves consistently. Dependency Inversion is frequently what makes Open/Closed actually achievable in real code - injecting an interface is what allows new behavior to be added by writing a new implementation rather than modifying existing code.
Key Lessons
Single Responsibility is about one reason to change, not one method - a class can have several methods and still be well-designed if they all serve the same responsibility.
Open/Closed is most often violated by a growing switch statement or if/else chain - polymorphism through an interface is the standard fix.
Liskov Substitution requires that code written against a base type work correctly with any subtype, without needing to check which one it got - the classic Square/Rectangle example shows how syntactically correct inheritance can still violate this.
Interface Segregation is violated by fat interfaces that force implementers to fake methods that don't apply to them - smaller, purpose-specific interfaces are the fix.
Dependency Inversion is the reasoning behind constructor injection of interfaces rather than concrete classes - both high-level and low-level code depend on the same abstraction, and neither depends on the other directly.
Summary
SOLID is five related principles for keeping object-oriented code changeable without breaking what already works. Single Responsibility limits what forces a class to change. Open/Closed enables new behavior without editing tested code. Liskov Substitution ensures polymorphism is actually trustworthy rather than just syntactically valid. Interface Segregation prevents fat interfaces from forcing fake implementations. Dependency Inversion decouples high-level logic from low-level details through shared abstractions. Understanding the specific code smell each principle is designed to catch - not just the acronym - is what makes the difference between reciting SOLID in an interview and actually applying it in a real codebase.
Originally published at TechStack Blog:
https://www.techstackblog.com/post.html?slug=solid-principles-explained
More from TechStack Blog:
CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals
C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
Top comments (0)