SOLID without dogma
In short: SOLID is five principles for code that survives change: one reason to change, extension without modification, substitutable subtypes, small interfaces and depending on abstractions.
Problem
The email change that broke accounting
The online shop emails the customer an invoice. Every evening it also gives accounting a file with a line for each order. One class does both:
record Order(string Number, string Customer, decimal Net);class InvoiceService{ public decimal Vat(Order order) { return order.Net * 0.20m; }
// Accounting: a line for the file it imports into its software. public string ExportLine(Order order) { decimal total = order.Net + Vat(order); return string.Join(";", order.Number, Money(order.Net), Money(Vat(order)), Money(total)); }
// The customer: the text of the email with the invoice. public string EmailText(Order order) { decimal total = order.Net + Vat(order); return $"Hi, {order.Customer}! Order {order.Number}: €{Money(total)}"; }
private string Money(decimal amount) { return amount.ToString("F2"); }}var invoices = new InvoiceService();var order = new Order("2026-0142", "Maria", 80);Console.WriteLine(invoices.ExportLine(order));Console.WriteLine(invoices.EmailText(order));2026-0142;80.00;16.00;96.00Hi, Maria! Order 2026-0142: €96.00Marketing says: "In the email, make the amount €96, not €96.00. It looks cleaner." Someone changes the helper method Money:
private string Money(decimal amount){ if (amount == Math.Floor(amount)) { return amount.ToString("0"); } return amount.ToString("F2");}2026-0142;80;16;96Hi, Maria! Order 2026-0142: €96The email is what they wanted. The next morning the accounting software rejects the file: it expects 80.00 and gets 80. Nobody touched the accounting line. What changed is the helper method the two lines share. The email test passes, and there is no test for the file, because "nobody changed it".
The idea
One reason to change
The class serves two parties who don't know each other. The accountant needs an exact format that her software reads. Marketing wants a pleasant text for the customer. They ask for changes at different times and for different reasons. As long as their code lives in one class, a change for one can break the other.
This is the first of the five SOLID principles (collected by Robert Martin; the name comes from their first letters). The Single Responsibility Principle (SRP): a class has one reason to change.
"Reason" means who asks for the change: a person, a department, a law. It does not mean "one method", and it does not mean "a small class". The question to ask yourself: "Who will come and ask for a change here?" If the answer is "accounting or marketing", that is two classes.
See it
Each one gets its own class
The VAT and the total are a rule of the tax law, and both parties need them. They go to the order, in one place:
record Order(string Number, string Customer, decimal Net){ public decimal Vat => Net * 0.20m; public decimal Total => Net + Vat;}The accounting line and the email text become separate classes. Each one formats amounts its own way:
class AccountingExport{ public string Line(Order order) { return $"{order.Number};{order.Net:F2};{order.Vat:F2};{order.Total:F2}"; }}
class InvoiceEmail{ public string Text(Order order) { return $"Hi, {order.Customer}! Order {order.Number}: €{Money(order.Total)}"; }
private string Money(decimal amount) { if (amount == Math.Floor(amount)) { return amount.ToString("0"); } return amount.ToString("F2"); }}var order = new Order("2026-0142", "Maria", 80);Console.WriteLine(new AccountingExport().Line(order));Console.WriteLine(new InvoiceEmail().Text(order));2026-0142;80.00;16.00;96.00Hi, Maria! Order 2026-0142: €96The email has whole amounts, and the file still has two decimals. Tomorrow accounting will change the format, and you will touch only AccountingExport. The email code is not even in front of you.
The two ways of writing an amount are no longer shared. That is not duplication: they look alike today, but they are two rules that change for different reasons.
When it hurts. The other extreme is a class for every action: VatCalculator, TotalCalculator, NumberFormatter, GreetingBuilder, three lines each. They always change together, because the same person asks for them, and you open four files to read one rule. A 200-line class that serves one person is better than five classes you always open together. What changes together stays together.
More precisely, if you are curious
- Robert Martin himself writes that "one responsibility" is often read as "does one thing". His later clarification is that a module answers to one actor: a group of people who ask for changes. "Does one thing" is a rule for functions, not for classes.
- "Don't repeat yourself" (DRY) is about knowledge, not text. Two pieces of code that look the same but change for different reasons are not duplication. If you merge them into one helper method, you tie two people together with one knot. That is exactly what happened in the problem.
Try it
Who asks for the change
See it
A new delivery without touching the total
The second principle is open/closed (Open/Closed Principle, OCP): code is open for extension and closed for modification. A new variant comes as new code, and the code that already works and is tested is not touched.
You saw it in the Strategy lesson: the chain of ifs for the payment method became a table name → strategy. Deliveries are the same. Each delivery knows its price:
interface IDelivery{ decimal Cost(decimal amount);}
class ToAddress : IDelivery{ public decimal Cost(decimal amount) { return amount >= 100 ? 0 : 5.90m; }}
class ToOffice : IDelivery{ public decimal Cost(decimal amount) { return 3.90m; }}The total does not know which deliveries exist:
static decimal OrderTotal(decimal amount, IDelivery delivery){ return amount + delivery.Cost(amount);}The shop adds delivery to a parcel locker. It is a new class:
class ToLocker : IDelivery{ public decimal Cost(decimal amount) { return 2.90m; }}OrderTotal, the delivery to an address and to an office are not touched. What remains is to put the new delivery in the table the customer chooses from. That is the next card.
When it hurts. You can't close the code against everything, and you shouldn't. Every extension point is an interface, a class and a row in a table that someone has to read. You close it where changes really come: new deliveries arrive every season. The rule of thumb: the first time, write an if. When the third variant arrives, make the table (Replace Conditional with Polymorphism from the lesson on refactoring).
Try it
A new row in the table
The table from the Strategy lesson lists the deliveries the customer chooses from. Add the locker.
What goes in the blank?
var deliveries = new Dictionary<string, IDelivery>
{
["address"] = new ToAddress(),
["office"] = new ToOffice(),
["locker"] = ,
};
foreach (var (choice, delivery) in deliveries)
{
Console.WriteLine($"{choice}: €{OrderTotal(80, delivery):F2}");
}new ToLocker(): an object of the new class. The table holds objects that implement IDelivery, and the total calls their method without knowing the class.address: €85.90office: €83.90locker: €82.90Only the table changed, and all it does is list. OCP does not mean "not a single changed line". It means the rules that already work and are tested are not touched for every new variant.
Problem
The cancellation that stopped halfway
A customer pays for an order three ways: €60 by card, a €20 voucher and €16 in points. Then she cancels it. Each kind of payment knows how to give its money back:
interface IPayment{ // Gives the money back to the customer the same way they paid. void Refund();}
record CardPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Card: refunded €{Amount:F2}"); }}
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { throw new NotSupportedException("a voucher can't be refunded"); }}
record PointsPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Points: refunded €{Amount:F2}"); }}The order goes through its payments and calls Refund() on each one:
class Order{ private readonly List<IPayment> payments; public string Status { get; private set; } = "paid";
public Order(List<IPayment> payments) { this.payments = payments; }
public void Cancel() { foreach (var payment in payments) { payment.Refund(); } Status = "cancelled"; }}var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Error: " + e.Message);}Console.WriteLine("Status: " + order.Status);Card: refunded €60.00Error: a voucher can't be refundedStatus: paidThe card is refunded, the points are not, and the order is still "paid". The warehouse sees "paid" and ships the parcel. The code of Cancel() is correct. The bug is in the voucher: it is a Payment, but it does not do what Payment promises.
See it
The subtype that breaks the promise
public void Cancel(){ foreach (var payment in payments) { payment.Refund(); } Status = "cancelled";}
var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Error: " + e.Message);}Console.WriteLine("Status: " + order.Status);Step by step, in words
- The order was paid three ways: €60.00 by card, a €20.00 voucher and €16.00 in points. All three are IPayment.
- The customer cancels. Cancel() goes through the payments and calls Refund() on each one.
- The card gives back €60.00. It does exactly what IPayment promises.
- The voucher is a IPayment too, but its Refund() throws NotSupportedException. The promise is broken.
- The exception leaves the loop and Cancel(). The points (€16.00) are never refunded, and the line that sets Status to "cancelled" never runs.
- The catch in Main catches it and prints the error. The program goes on.
- The status is still "paid". The warehouse ships the order, and €60.00 is already back on the card.
This is the third principle, Barbara Liskov's substitution principle (Liskov Substitution Principle, LSP): wherever the code works with a type, any subtype can stand in, and the code won't notice. The subtype can do the job its own way, but it keeps the promise of the type.
Payment promises "I give the customer the money back". What does that mean for a voucher in a real shop? A new voucher for the same amount:
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Voucher: new voucher for €{Amount:F2}"); }}var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Error: " + e.Message);}Console.WriteLine("Status: " + order.Status);Card: refunded €60.00Voucher: new voucher for €20.00Points: refunded €16.00Status: cancelledCancel() did not change. The subtype now fits where the type is expected.
You recognize a violation by three signs:
- the subtype throws "not supported" (NotSupportedException) for a method the type promises;
- the subtype returns something the type does not promise (
-1,null, an empty string), or asks more from the caller; - the code checks is before calling the method. That is a detour around a subtype that doesn't fit.
When it hurts. Here the dogma goes the other way: forcing something into the hierarchy because "it is a payment too". If something really can't give money back (a gift from the shop, for example), it is not a Payment with refund. Give it its own type instead of writing a method that throws.
More precisely, if you are curious
IList<T>, but it does not take Add:IList<string> items = new[] { "coffee", "mug" };try{ items.Add("tea");}catch (NotSupportedException e){ Console.WriteLine("An array as IList: " + e.Message);}An array as IList: Collection was of a fixed size.Formally, after Liskov: a subtype asks no more than the type (does not strengthen the conditions on input), gives no less (does not weaken the promise about the result), and keeps what is always true for the type.
Try it
The silent violation
Delivery to a locker returns -1 when the locker is full. The total adds it, as always. What does the program print?
class ToLocker : IDelivery{ private readonly bool full;
public ToLocker(bool full) { this.full = full; }
public decimal Cost(decimal amount) { if (full) { return -1; // -1 means "no space" } return 2.90m; }}
Console.WriteLine($"€{OrderTotal(80, new ToLocker(true)):F2}");€79.00. The total is written for the promise "the price of the delivery, 0 or more". It adds 80 and -1, and the customer pays €1 less for a delivery that can't happen. Nothing throws, which makes this violation more dangerous than the exception. The fix: a full locker is not a price. The delivery says whether it is possible with a separate method, and the total does not offer it.
Problem
The SMS that never went out
The shop sends notifications to customers. Someone described everything that can be sent in one place:
interface INotifier{ void Email(string to, string text);
void Sms(string phone, string text);}Try it
Find the bug
The customer gets an email that the parcel has shipped. The courier SMS never comes:
INotifier notifier = new MailProvider();notifier.Email("maria@example.com", "Order 2026-0142 has shipped.");notifier.Sms("0888 123 456", "The courier comes today between 2 and 4 pm.");Email to maria@example.com: Order 2026-0142 has shipped.Which line has the bug?
Pick the line with the bug.
The SMS method is empty. It is a lie nobody hears: the code calls it, nothing goes out, and there is no error. The provider wrote it because the interface demanded it. The empty method breaks LSP too: the promise "I send an SMS" is not kept. The real fix is not on this line but in the contract: the next card.
See it
Small interfaces
The fourth principle: interface segregation (Interface Segregation Principle, ISP). Nobody should depend on methods they don't use. The contract is written from the side of whoever uses it, and it is as big as they need.
Emails and SMS become two interfaces:
interface IEmailSender{ void Email(string to, string text);}
interface ISmsSender{ void Sms(string phone, string text);}Each provider does only what it can, and the courier notice asks only for SMS:
class MailProvider : IEmailSender{ public void Email(string to, string text) { Console.WriteLine("Email to " + to + ": " + text); }}
class SmsGateway : ISmsSender{ public void Sms(string phone, string text) { Console.WriteLine("SMS to " + phone + ": " + text); }}
class ShippingNotice{ private readonly ISmsSender sender;
public ShippingNotice(ISmsSender sender) { this.sender = sender; }
public void CourierToday(string phone) { sender.Sms(phone, "The courier comes today between 2 and 4 pm."); }}IEmailSender email = new MailProvider();email.Email("maria@example.com", "Order 2026-0142 has shipped.");new ShippingNotice(new SmsGateway()).CourierToday("0888 123 456");Email to maria@example.com: Order 2026-0142 has shipped.SMS to 0888 123 456: The courier comes today between 2 and 4 pm.If someone passes the email provider where an SMS is needed, the program does not compile:
new ShippingNotice(new MailProvider()).CourierToday("0888 123 456");error CS1503: Argument 1: cannot convert from 'MailProvider' to 'ISmsSender'The bug from the previous card is impossible: nobody asks for an empty method.
Small interfaces help tests too: the double for the notice implements one method, not everything the shop can send. One class can implement both if the provider really sends emails and SMS: class MailAndSms : IEmailSender, ISmsSender.
When it hurts. An interface for every method "because of ISP" is the other extreme: Readable, Writable, Deletable, Countable in front of a repository that is used in one place and always as a whole. If everyone who uses the contract uses all of it, it is one contract.
Problem
The test that sends a real email
The invoice goes out by email. The service makes its own mailer:
class SmtpMailer{ public void Send(string to, string text) { Console.WriteLine("SMTP to " + to + ": " + text); }}
class InvoiceService{ private readonly SmtpMailer mailer = new SmtpMailer();
public void Issue(Order order, string email) => mailer.Send(email, $"Invoice {order.Number}: €{order.Total:F2}");}var order = new Order("2026-0142", "Maria", 80);new InvoiceService().Issue(order, "maria@example.com");SMTP to maria@example.com: Invoice 2026-0142: €96.00It works, but:
- the invoice test sends a real email, and on a machine without an SMTP server it fails;
- the shop moves from SMTP to a service with an API, and you edit the class with the invoice rules to change the mailer.
The rule (what goes on the invoice) depends on the detail (how the email goes out).
See it
The abstraction belongs to the rule
The fifth principle: dependency inversion (Dependency Inversion Principle, DIP). The code with the rules does not depend on the details. Both depend on an abstraction, and it belongs to the rules: it says what they need ("send this text to this address"), not how SMTP does it.
interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Invoice {order.Number}: €{order.Total:F2}");}The details are separate. The mailer and the test double implement the service's abstraction:
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP to " + to + ": " + text);}
class FakeSender : IInvoiceSender{ public List<string> Sent { get; } = [];
public void Send(string to, string text) => Sent.Add(to + " | " + text);}var order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .Issue(order, "maria@example.com");
var fake = new FakeSender();var service = new InvoiceService(fake);service.Issue(order, "maria@example.com");Console.WriteLine("The test saw: " + fake.Sent[0]);SMTP to maria@example.com: Invoice 2026-0142: €96.00The test saw: maria@example.com | Invoice 2026-0142: €96.00interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Invoice {order.Number}: €{order.Total:F2}");}
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP to " + to + ": " + text);}
var order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .Issue(order, "maria@example.com");
var fake = new FakeSender();var service = new InvoiceService(fake);service.Issue(order, "maria@example.com");Console.WriteLine("The test saw: " + fake.Sent[0]);Step by step, in words
- Before, the invoices pointed down, at SmtpMailer. Now the service writes down what it needs: IInvoiceSender, next to itself.
- InvoiceService gets the sender in its constructor and knows only IInvoiceSender. It knows nothing about SMTP.
- SmtpSender implements the service's IInvoiceSender. The arrow in the code points up: the detail depends on the rule. That is the inversion.
- At run time the call still goes down: from the service to SMTP. The dependency in the code is inverted, not the direction of the call.
- In the test the same service gets FakeSender: a second detail that points at the same abstraction. No email goes out: the double writes down what it got in Sent.
- The test sees exactly the text of the invoice, without a mail server.
The "inversion" is of the arrow in the code. Before, the invoices knew about SMTP. Now SMTP knows about IInvoiceSender, which sits next to the invoices. At run time the call still goes from the invoices to SMTP.
DIP and DI. In the lesson Singleton and Dependency Injection you saw passing from outside: the object gets its dependency in the constructor. That is the mechanism. DIP says what to depend on: an abstraction that is yours. You can have one without the other:
-
passing without DIP:
InvoiceService(SmtpMailer mailer). The mailer comes from outside, but the service still knows the SMTP class, and a test without SMTP is not possible; - DIP without a DI library: in Main,
InvoiceServicegetsSmtpSender, and that is all the wiring. There is one place where the program puts the objects together.
When it hurts. An interface in front of every class "because of DIP" is the most common dogma. An abstraction is worth it in front of a detail that is slow, external or going to change: mail, payment, database, clock, network. In front of Order or in front of formatting an amount it is code to read with no benefit.
More precisely, if you are curious
IInvoiceSender lives in the invoices project (or namespace), and SmtpSender lives in the mail project, which references the invoices. The invoices project does not reference the mail. That is the inverted arrow, visible in the project references.Try it
Put the test in order
The test checks the invoice without a real mailer. The order already exists. Put the lines in order:
Console.WriteLine("The test saw: " + fake.Sent[0]);var service = new InvoiceService(fake);var fake = new FakeSender();service.Issue(order, "maria@example.com");
The double comes first, because the service wants it in its constructor. Then the invoice is issued, and only then is there something to check. The output: The test saw: maria@example.com | Invoice 2026-0142: €96.00
In short
When SOLID hurts
Here is the VAT, written "by all the rules":
interface ITaxPolicy{ decimal Vat(decimal net);}
class StandardVat : ITaxPolicy{ public decimal Vat(decimal net) { return net * 0.20m; }}
static class TaxPolicyFactory{ public static ITaxPolicy Create() { return new StandardVat(); }}Console.WriteLine($"VAT: €{TaxPolicyFactory.Create().Vat(80):F2}");VAT: €16.00And the same without dogma:
const decimal VatRate = 0.20m;Console.WriteLine($"VAT: €{80 * VatRate:F2}");VAT: €16.00The output is the same. The first is an interface, a class and a factory for a number that changes once every ten years, and in one place anyway. That is the smell called speculative generality: code for changes nobody asked for. When a second rate arrives (9% for food), that is the moment for a table.
The principles are questions, not rules. Ask them when the code hurts:
| The question | The dogma to avoid | |
|---|---|---|
| SRP | Who will ask for a change here? One party or two? | A class for every method |
| OCP | What really comes new every month? | Extension points everywhere |
| LSP | Can every subtype stand here without an if? |
A hierarchy because of the word "is" |
| ISP | Does this client use everything it asks for? | An interface for every method |
| DIP | Is the detail slow, external or replaceable? | An interface in front of every class |
Exercise
A gift card without touching the cancellation
Solve the task in your editor.
Gift card. The shop also takes gift cards. On cancellation the money goes back onto the card. Add a GiftCardPayment that keeps the promise of Payment, and cancel an order paid with €40 by card and €25 by gift card. Cancel() and the other payments are not touched.
Card: refunded €40.00Gift card: €25.00 back on the cardStatus: cancelledMore tasks
- The monthly report. A class computes the month's revenue, writes a CSV for accounting and a text for the manager, and sends the email itself. Split it by who asks for the changes. What stays shared?
- Push notification. Add a notification in the mobile app when the parcel has shipped. Which small contract do you need, and which classes do you not touch?
- Remove one abstraction. Find in your own project an interface with one implementation with no slow or external detail behind it. Remove the wrapper and run the tests.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Singleton and Dependency Injection · Functions: one job, few parameters, no hidden side effects · Code smells: long method, large class, duplication, primitives instead of types, long if chains · Refactoring in small steps
- Continues in: Exercise: a messy class, cleaned up step by step · Layers · Hexagonal and clean architecture · Monolith, modular monolith, services
- Tasks on this topic: Clean code and refactoring