Skip to content

SOLID and principles

The five SOLID principles and three heuristics — DRY, KISS and YAGNI: what they actually say, what violations look like in C# and in .NET itself, and where the principles stop helping. Anyone can list the letters; at Senior level you are asked about the cost of each principle and its limits.

Before this topic: OOP.

Junior+

What do the Single Responsibility Principle (SRP) and the Open/Closed Principle (OCP) mean?

Section titled “What do the Single Responsibility Principle (SRP) and the Open/Closed Principle (OCP) mean?”
Answer

What it is

  • SRP, Single Responsibility Principle. A class has one reason to change: one source of requirements drives it.
  • OCP, Open/Closed Principle. A class is open for extension and closed for modification: you add new behavior without rewriting finished code.

Why

Changing one feature does not break its neighbor, and tested code is not touched for the sake of a new feature.

How to use

  1. Find the parts of a class that change for different reasons: calculation, formatting, persistence. Move them into separate classes.
  2. Replace a growing switch on type or format with an interface and one implementation per variant.

Common mistake

Reading SRP as “a class does one action” and splitting code into one-method classes. The principle is about reasons to change, not the number of methods.

Takeaway: SRP — one reason to change; OCP — extend with new code instead of editing old code.

How it works

  • SRP according to Martin. In “Clean Architecture” a module is responsible to one actor — a group of people who request changes. Martin’s example: Employee with CalculatePay (accounting), ReportHours (operations) and Save (the technical department). If the first two share a helper method, a change for accounting silently alters the report.
  • OCP. Bertrand Meyer formulated it in 1988. Meyer extended a module through implementation inheritance; Martin does it through abstractions: the client depends on an interface, and new behavior is a new implementation.
  • Strategy. The typical way to follow OCP: the varying algorithm goes behind an interface, and the class receives the implementation it needs from outside.

Pitfalls

  • The same switch on type in several places is the main sign of an OCP violation: a new variant requires edits in all of them.
  • SRP does not mean “one method”. A class with ten methods around one rule is fine.
  • Fixing a bug does not violate OCP: the principle is about adding behavior.

Rule of thumb

Split code by who requests changes. Add an extension point once a change along that axis has already happened, not “just in case”.

Takeaway: SRP groups code by sources of change, OCP hides the varying part behind an abstraction.

Under the hood

  • Martin restates SRP like this: gather together the things that change for the same reasons, and separate those that change for different reasons. It is cohesion, phrased in terms of people and the organization.
  • “A function should do one thing” is a separate principle for functions. Martin says explicitly that it is not SRP.
  • Closure is never complete: a module is closed only against anticipated changes. Martin calls this strategic closure — the author chooses what to protect against.

Cost

  • Every extension point is an interface, extra types, an indirect call and one more hop when reading code.
  • A wrongly guessed axis is worse than none. The change arrives across the abstraction and rewrites it along with all its implementations.

Trade-offs

  • Make the first change directly in the code and extract an abstraction on the second similar one: by then the axis is visible in the history.
  • At the architecture level OCP means plugins. Dependencies point toward the core, and a new module plugs in without editing the core.

How to check in production

  • git log per file: a class edited in every other PR for unrelated tasks, and merge conflicts between teams in one file, point to an SRP violation.
  • A switch on the same attribute in several places is a candidate for polymorphism.

Takeaway: SRP and OCP protect against cascading changes, and you pick their axis from the real change history instead of guessing.

Before: two reasons to change and a switch on format
var report = new SalesReport();
Console.WriteLine(report.Export([120, 80], "csv")); // total;200
public class SalesReport
{
public string Export(int[] sales, string format)
{
int total = sales.Sum(); // accounting changes the calculation rule
return format switch // report consumers change the format
{
"csv" => $"total;{total}",
"json" => $$"""{"total":{{total}}}""",
_ => throw new NotSupportedException(format)
}; // a new format means editing this method
}
}
After: calculation apart, format as a strategy
var report = new SalesReport(new SalesCalculator());
Console.WriteLine(report.Export([120, 80], new CsvFormatter())); // total;200
Console.WriteLine(report.Export([120, 80], new JsonFormatter())); // {"total":200}
// One reason to change: the calculation rules
public class SalesCalculator
{
public int Total(int[] sales) => sales.Sum();
}
public interface IReportFormatter { string Format(int total); }
// A new format is a new class; SalesReport does not change
public class CsvFormatter : IReportFormatter
{
public string Format(int total) => $"total;{total}";
}
public class JsonFormatter : IReportFormatter
{
public string Format(int total) => $$"""{"total":{{total}}}""";
}
public class SalesReport(SalesCalculator calculator)
{
public string Export(int[] sales, IReportFormatter formatter) =>
formatter.Format(calculator.Total(sales));
}
Junior+

What are DRY, KISS and YAGNI, and how do they relate?

Section titled “What are DRY, KISS and YAGNI, and how do they relate?”
Answer

What it is

Principle Meaning
DRY, Don’t Repeat Yourself every piece of knowledge has one place in the system
KISS, Keep It Simple, Stupid the simplest solution that works
YAGNI, You Aren’t Gonna Need It do not write what is not needed yet

How to use

  • DRY. A tax rate or a validation rule lives in one place, otherwise someone forgets to update one of the copies.
  • KISS. A plain if instead of a pattern, as long as the simple solution copes.
  • YAGNI. “We might need a second payment provider” is no reason to build support for it now.

How they relate

DRY pushes you to merge, KISS and YAGNI push you not to complicate things up front. The compromise is the rule of three: tolerate two similar fragments, extract the common part on the third.

Common mistake

Merging code just because it looks the same. If the fragments express different rules, a shared method couples them.

Takeaway: DRY — one place for each piece of knowledge; KISS and YAGNI — no more complexity than needed.

How it works

  • DRY. Hunt and Thomas (1999): every piece of knowledge must have a single, unambiguous, authoritative representation within a system. In the second edition (2019) they clarified that DRY is about duplicating knowledge and intent, not identical lines.
  • Knowledge is duplicated in different forms too. A rule in code and in a database constraint, a contract in a DTO and in the docs — those violate DRY as well.
  • Accidental duplication. The checks “name up to 50 characters” and “no more than 50 items per order” look alike but change for different reasons.
  • YAGNI comes from Extreme Programming (Kent Beck). It forbids speculative functionality, not refactoring or tests.

When to choose what

Situation What to do
One rule in two places move it to a single source
Similar code, different reasons to change keep the duplicate
Third repetition of the same logic extract the common part
“We’ll need it later” do not write it

Pitfalls

  • DRY via a shared base class or Utils glues together modules that would otherwise not depend on each other.
  • YAGNI does not justify skipping tests: you need them now.

Rule of thumb

Ask “is this one piece of knowledge?” first, not “is this similar code?”.

Takeaway: DRY removes duplicated knowledge, KISS and YAGNI keep you from paying for complexity up front, and the rule of three reconciles them.

Cost

  • The wrong abstraction costs more than a duplicate. Sandi Metz describes the typical path: a shared method grows parameters and ifs for every new caller. Her advice is to inline the code back into the call sites and extract the abstraction anew.
  • Fowler splits the cost of a premature feature into four parts: cost of build, cost of delay, cost of carry and cost of repair if the guess was wrong. Even a correctly guessed feature carries complexity until the day it is needed.

Trade-offs

  • DRY across services is more dangerous than within one: a shared model library couples releases. Between bounded contexts duplication is often right.
  • Knowledge that must change in lockstep stays in one place even at the cost of coupling: a message schema is generated from one contract.
  • YAGNI works only when change is cheap: there are tests and frequent releases. A public API or a storage format cannot be reworked that way, so they are designed up front.

How to check in production

  • The same bug fixed in several places is a missed DRY.
  • A method with flag parameters and branches for each caller is a false DRY.
  • Code with no callers and no test coverage is a trace of “future” features.

Takeaway: choosing between a duplicate and an abstraction is a bet on whether the fragments change together; when in doubt, waiting is cheaper.

Example
Console.WriteLine(UserRules.IsValidName("Ann")); // True
Console.WriteLine(OrderRules.IsValidQuantity(3)); // True
// One piece of knowledge, one place: validation and the DB schema
// (HasMaxLength(UserRules.MaxNameLength)) take the limit from here
public static class UserRules
{
public const int MaxNameLength = 50;
public static bool IsValidName(string name) =>
name.Length is > 0 and <= MaxNameLength;
}
// Looks like a duplicate, but it is different knowledge: the business changes order limits.
// A shared IsInRange(x, 1, 50) would couple two independent rules.
public static class OrderRules
{
public const int MaxQuantity = 50;
public static bool IsValidQuantity(int quantity) =>
quantity is > 0 and <= MaxQuantity;
}
Middle+

What are the Liskov Substitution Principle (LSP) and the Interface Segregation Principle (ISP)?

Section titled “What are the Liskov Substitution Principle (LSP) and the Interface Segregation Principle (ISP)?”
Answer

What it is

  • LSP, the Liskov Substitution Principle. Code that works with a base type works correctly with any subclass without knowing about it.
  • ISP, the Interface Segregation Principle. A client depends only on the methods it calls.

Why

  • LSP lets you rely on polymorphism. An if (shape is Square) check in client code is a sign of a violation.
  • ISP reduces coupling: changing someone else’s method does not affect the client, and writing a test stub is easier.

How to use

  • Inherit only if the subclass fulfills everything the base type promises.
  • If an implementation has to throw NotImplementedException, the interface is too wide: split it.

Common mistake

Inheriting by the real-world “is a” relation. In geometry a square is a rectangle, but a mutable Square is not a subtype of a mutable Rectangle: setting the width changes the height.

Takeaway: a subclass replaces the base type without surprises, and an interface contains only what the client needs.

How it works

LSP is about a method’s contract: preconditions (what is true before the call), postconditions (what is guaranteed after) and invariants (what is always true). A subtype must:

  1. not strengthen preconditions — not reject arguments the base method accepts;
  2. not weaken postconditions — not promise less than the base;
  3. preserve the base type’s invariants;
  4. not throw exceptions a client of the base type does not expect.

The compiler checks signatures, not behavior.

Classic violations

  • Square : Rectangle with mutable sides: it breaks the postcondition “the width setter does not change the height”.
  • NotImplementedException in an interface implementation.
  • ReadOnlyCollection<T> passed as ICollection<T>: Add throws NotSupportedException.

ISP

A “fat” interface, such as an IRepository with twenty methods, forces clients to depend on what they do not need. The fix is role interfaces shaped by client needs: IOrderReader and IOrderWriter. One class can implement both.

Rule of thumb

Accept the narrowest interface the method needs: IEnumerable<T> or IReadOnlyList<T>, not List<T>.

Takeaway: LSP — a subclass honors the base type’s contract; ISP — a client sees only its own role.

Under the hood

  • Informally, LSP appeared in Barbara Liskov’s keynote “Data Abstraction and Hierarchy” (1987). Formally, in Liskov and Wing’s “A Behavioral Notion of Subtyping” (1994): if a property is provable for objects of type T, it must hold for objects of a subtype S.
  • The paper adds the history constraint: a subtype does not allow state changes the base type does not allow. That is why a mutable point is not a subtype of an immutable one, even if all methods match.
  • In “Clean Architecture” Martin applies LSP beyond inheritance: to interface implementations and even to REST services sharing a contract.

Trade-offs

  • Stream with CanSeek and ICollection<T> with IsReadOnly formally honor LSP: the base contract itself permits NotSupportedException. The cost is that every client checks the flag, and the check is easy to forget.
  • IReadOnlyCollection<T> and IReadOnlyList<T> (.NET Framework 4.5) are ISP after the fact: a method that only reads no longer demands a mutable collection.
  • Interfaces that are too small fragment the model. Fowler contrasts role interfaces with “header interfaces” that copy every public method of a class; split by client roles, not one interface per method.

How to check in production

  • is, as and GetType() checks for concrete subtypes in client code are an LSP smell.
  • A code search finds NotImplementedException in implementations.
  • A shared abstract test class run against every implementation of an interface checks the contract, not just the signatures.

Takeaway: LSP is a behavioral contract the compiler does not check, and ISP makes it cheaper to honor by narrowing the contract to the client’s role.

LSP violation: square and rectangle
Rectangle r = new Square();
r.Width = 2;
r.Height = 5;
Console.WriteLine(r.Area); // 25, while a Rectangle client expects 10
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
// The square adds the invariant "width == height" and breaks
// the base type's postcondition: the setter changes both sides
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.Width = value; base.Height = value; }
}
}
LSP and ISP in .NET collections
IList<int> list = new List<int> { 1, 2 }.AsReadOnly();
Console.WriteLine(list.IsReadOnly); // True: the contract allows refusal
try
{
list.Add(3);
}
catch (NotSupportedException)
{
Console.WriteLine("NotSupportedException");
}
// ISP: the method only reads, so it asks for a narrow interface
Console.WriteLine(Sum(new List<int> { 1, 2 })); // 3
Console.WriteLine(Sum(new[] { 1, 2, 3 })); // 6
static int Sum(IReadOnlyList<int> items)
{
int sum = 0;
for (int i = 0; i < items.Count; i++) sum += items[i];
return sum;
}
Middle+

What is the Dependency Inversion Principle (DIP), and how does it differ from dependency injection (DI) and IoC?

Section titled “What is the Dependency Inversion Principle (DIP), and how does it differ from dependency injection (DI) and IoC?”
Answer

What it is

DIP is the D in SOLID. Martin states it in two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

High level means business rules, such as InvoiceService. Low level means details: the database, email, a payment gateway.

Why

  • Business logic can be tested with a stub instead of the real gateway.
  • Switching the database or provider does not touch the rules.

How to use

  1. Declare the interface next to the business logic: IPaymentGateway.
  2. Make the class depend on the interface, not on a specific provider’s client.
  3. Supply the implementation from outside — through the constructor.

Common mistake

Treating DIP and DI as the same thing. DIP is about the direction of dependencies, DI is about how an object gets passed in.

Takeaway: rules depend on an abstraction, and details implement it.

How it works

Without DIP, the code dependency follows the flow of calls: InvoiceService → CardGatewayClient. With DIP, InvoiceService calls IPaymentGateway and CardGateway implements it. The call goes the same way, but the source-code dependency points from the detail to the business rules.

Who owns the abstraction

The interface belongs to the client: it lives in the client’s module and is named in the client’s terms — Charge(invoiceId, amount), not PostJson(path, body). If the interface lives in the infrastructure assembly, the business module still references it, and nothing is inverted.

DIP, DI and IoC

Concept What it is
DIP a principle: which way dependencies point
DI a technique: a dependency comes from outside
IoC a principle: the framework is in control, not your code

DI helps you follow DIP but does not guarantee it. How the ASP.NET Core container does this is covered in Dependency injection.

Pitfalls

  • Interface for the sake of an interface. An IOrderService with a single implementation that mirrors all its methods and sits right next to it. It inverts nothing and only adds a hop.
  • Leaky abstraction. An IRepository that returns IQueryable<T> or DbSet<T> ties the client to EF Core.

Rule of thumb

The interface is declared by whoever uses it, with only the methods it needs.

Takeaway: DIP decides whose module owns the abstraction, and DI merely delivers the implementation.

Under the hood

  • The flow of calls and source-code dependencies are different graphs. An interface lets you flip any dependency arrow without changing the flow of calls. In “Clean Architecture” Martin calls this the real power of OO.
  • Martin stresses that not only the dependency is inverted but also the ownership of the interface. The client declares what it needs, and the server adapts.
  • Clean architecture and ports and adapters rest on this: the core declares ports, infrastructure implements them, and the composition root — the place where the object graph is assembled at startup — wires everything together.

Cost

  • Every seam is an interface, a registration and an extra hop when navigating and debugging.
  • An abstraction extracted from a single implementation mirrors its shape, and a second implementation does not fit.

Trade-offs

  • Stable versus volatile. Depending on string, List<T> and the rest of the BCL is fine. Invert dependencies on what changes or is non-deterministic: databases, external APIs, frameworks, time.
  • Concrete classes are fine inside a module. DIP belongs at boundaries: domain and infrastructure, module and module.

How to check in production

  • Architecture tests (NetArchTest, ArchUnitNET) enforce the rule “the domain does not reference infrastructure”.
  • Project references in .csproj: if the domain project references EF Core or the infrastructure project, nothing is inverted.

Takeaway: DIP is a tool for module boundaries, not for every class: the interface belongs to the client, details live outside.

Before: business rules depend on a detail
var service = new InvoiceService();
Console.WriteLine(service.Pay(42)); // Invoice 42 paid
public class InvoiceService
{
// The policy creates and knows a specific provider's HTTP client
private readonly CardGatewayClient _client = new();
public string Pay(int invoiceId) =>
_client.PostJson("/charges", $$"""{"invoice":{{invoiceId}}}""") == 200
? $"Invoice {invoiceId} paid"
: "Payment declined";
}
public class CardGatewayClient
{
public int PostJson(string path, string body) => 200; // HTTP call to the provider
}
After: the interface belongs to the business rules
using Billing;
using Infrastructure;
// Composition root — the only place that knows both sides
var service = new InvoiceService(new CardGateway());
Console.WriteLine(service.Pay(42)); // Invoice 42 paid
namespace Billing
{
// The abstraction lives with the client and is named in its terms
public interface IPaymentGateway
{
bool Charge(int invoiceId, decimal amount);
}
public class InvoiceService(IPaymentGateway gateway)
{
public string Pay(int invoiceId) =>
gateway.Charge(invoiceId, 100m) ? $"Invoice {invoiceId} paid" : "Payment declined";
}
}
namespace Infrastructure
{
// The detail depends on the business rules' abstraction, not the other way round
public class CardGateway : Billing.IPaymentGateway
{
public bool Charge(int invoiceId, decimal amount) => true; // HTTP call to the provider
}
}
Senior+

When does following SOLID hurt, and how do you make design decisions?

Section titled “When does following SOLID hurt, and how do you make design decisions?”
Answer

What it is

SOLID is advice on making code easy to change. It is not free: every interface and new class is one more place to read.

When SOLID hurts

  • An interface for every class, even though there is one implementation and nothing needs to swap it.
  • A class split into five small ones, so the logic of one operation has to be pieced together from five files.
  • “Extensibility” for requirements that do not exist.

How to use

  1. Write the simple solution.
  2. Cover it with tests.
  3. When a second implementation appears or changes become painful, extract an abstraction.

Common mistake

Justifying complexity with “SOLID requires it”. A principle is a means, not a goal: if it does not solve a concrete problem, it creates one.

Takeaway: start simple, add abstractions as real needs appear.

How it works

Every abstraction adds indirection. To understand a call you have to find the implementation, its container registration and possibly a decorator. This pays off only if changes really arrive along that axis.

Signs of overdoing it

  • Mirror interface. An IOrderService for an OrderService with a single implementation, repeating all its methods.
  • Fragmentation. One operation is smeared across a Handler, Validator, Mapper, Factory and Provider, and you need to open ten files to understand it.
  • Speculative generality. Parameters, hooks and base classes “for the future” used by a single caller. That is Fowler’s name for this smell in “Refactoring”.

When to choose what

Situation Decision
Network, time, files, an external system abstraction right away — it is a seam for tests
One implementation inside a module a concrete class
A second implementation appeared extract an interface after the fact
The abstraction gets in the way of changes inline the code and extract anew

Rule of thumb

Start simple and refactor as the pain arrives. Extract an abstraction once you know at least two variants: then you can see what they share.

Takeaway: an abstraction pays off only on a real axis of change; without one it is indirection with no benefit.

Where the principles came from

Martin gathered these principles in the paper “Design Principles and Design Patterns” (2000); Michael Feathers later coined the SOLID acronym. They answered the pain of large C++ and Java systems, where changing one header triggered rebuilding and redeploying half the system.

Critique

  • In the tongue-in-cheek talk “Why Every Element of SOLID is Wrong”, Dan North called SRP “pointlessly vague” and offered one alternative for every principle — “write simple code”.
  • He later proposed CUPID: Composable, Unix philosophy, Predictable, Idiomatic, Domain-based. These are properties, not rules: code does not “comply” with them, it moves closer to them.

Cost

  • Accidental complexity, in Fred Brooks’s terms, is complexity we add ourselves rather than the problem. Interfaces, factories and layers without a second implementation are its classic source.
  • Indirection hurts reading and debugging: call stacks through decorators and proxies, “Go to Implementation” with five results.

Trade-offs

  • A decision is a bet: the likelihood of a change and its cost without an abstraction versus the cost of the abstraction now. Things that are expensive to change — a public API, a data format, module boundaries — are designed up front; the rest as needed.
  • Argue with the team in terms of cost, not principle: “the seam is here because the provider’s API is unstable and without it the tests hit the network”.
  • “Refactor as the pain arrives” only works with tests. Without them the postponed abstraction never comes.

How to check in production

  • Interfaces with one implementation that are not swapped even in tests are candidates for removal.
  • File change frequency in git log shows the real axes of change. Abstractions where nothing changes do not pay off.

Takeaway: principles are tools against specific pain; decisions are made on cost and likelihood of change, not on the letter of a principle.

Before: four types for one formula
var calculator = new DiscountCalculatorFactory().Create();
Console.WriteLine(calculator.Calculate(new DiscountRequest(200))); // 20
// One implementation, nobody swaps it — indirection with no benefit
public interface IDiscountCalculator { int Calculate(DiscountRequest request); }
public interface IDiscountCalculatorFactory { IDiscountCalculator Create(); }
public record DiscountRequest(int Amount);
public class DiscountCalculator : IDiscountCalculator
{
public int Calculate(DiscountRequest request) => request.Amount / 10;
}
public class DiscountCalculatorFactory : IDiscountCalculatorFactory
{
public IDiscountCalculator Create() => new DiscountCalculator();
}
After: start simple
Console.WriteLine(Discount.For(200)); // 20
// A pure function: tested without stubs and readable at a glance.
// An interface will appear when a second way to calculate does.
public static class Discount
{
public static int For(int amount) => amount / 10;
}