SOLID без догма
Накратко: SOLID са пет принципа за код, който понася промени: една причина за промяна, разширение без промяна, заменими подтипове, малки интерфейси и зависимост от абстракции.
Проблем
Промяната за имейла, която счупи счетоводството
Онлайн магазинът праща на клиента фактура по имейл. Всяка вечер дава и на счетоводството файл с ред за всяка поръчка. Двете неща прави един клас:
record Order(string Number, string Customer, decimal Net);class InvoiceService{ public decimal Vat(Order order) { return order.Net * 0.20m; }
// Счетоводството: ред за файла, който внася в програмата си. public string ExportLine(Order order) { decimal total = order.Net + Vat(order); return string.Join(";", order.Number, Money(order.Net), Money(Vat(order)), Money(total)); }
// Клиентът: текстът на имейла с фактурата. public string EmailText(Order order) { decimal total = order.Net + Vat(order); return $"Здравей, {order.Customer}! Поръчка {order.Number}: {Money(total)} €"; }
private string Money(decimal amount) { return amount.ToString("F2"); }}var invoices = new InvoiceService();var order = new Order("2026-0142", "Мария", 80);Console.WriteLine(invoices.ExportLine(order));Console.WriteLine(invoices.EmailText(order));2026-0142;80.00;16.00;96.00Здравей, Мария! Поръчка 2026-0142: 96.00 €Маркетингът казва: „В имейла сумата да е 96 €, а не 96.00 €. Изглежда по-чисто.“ Някой сменя помощния метод Money:
private string Money(decimal amount){ if (amount == Math.Floor(amount)) { return amount.ToString("0"); } return amount.ToString("F2");}2026-0142;80;16;96Здравей, Мария! Поръчка 2026-0142: 96 €Имейлът е какъвто го искаха. На сутринта счетоводната програма отказва файла: очаква 80.00, а получава 80. Редът за счетоводството никой не е пипал. Пипнат е помощният метод, който двата реда делят. Тестът за имейла минава, а тест за файла няма, защото „него никой не е сменял“.
Идеята
Една причина за промяна
Класът служи на двама, които не се познават. Счетоводителката иска точен формат, който нейната програма чете. Маркетингът иска приятен текст за клиента. Двамата искат промени по различно време и по различни причини. Щом кодът им е в един клас, промяната за единия може да счупи другия.
Това е първият от петте принципа SOLID (събрани от Робърт Мартин; името е от първите им букви на английски). Принципът за единствената отговорност (Single Responsibility Principle, SRP): класът има една причина за промяна.
„Причина“ значи кой иска промяната: човек, отдел, закон. Не значи „един метод“ и не значи „малък клас“. Въпросът, който си задаваш: „Кой ще дойде да поиска промяна тук?“ Ако отговорът е „счетоводството или маркетингът“, класовете са два.
Виж го
Всеки със своя клас
ДДС-то и сумата са правило на данъчния закон и трябват и на двамата. Те отиват при поръчката, на едно място:
record Order(string Number, string Customer, decimal Net){ public decimal Vat => Net * 0.20m; public decimal Total => Net + Vat;}Редът за счетоводството и текстът на имейла стават отделни класове. Всеки форматира сумите по своя начин:
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 $"Здравей, {order.Customer}! Поръчка {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", "Мария", 80);Console.WriteLine(new AccountingExport().Line(order));Console.WriteLine(new InvoiceEmail().Text(order));2026-0142;80.00;16.00;96.00Здравей, Мария! Поръчка 2026-0142: 96 €Имейлът е с цели суми, а файлът пак е с две цифри. Утре счетоводството ще смени формата, и ще пипаш само AccountingExport. Кодът на имейла дори не е пред теб.
Двата начина да напишеш сумата вече не са общи. Това не е дублиране: днес си приличат, но са две правила, които се сменят по различни причини.
Кога пречи. Другата крайност е клас за всяко действие: VatCalculator, TotalCalculator, NumberFormatter, GreetingBuilder, по три реда всеки. Сменят се винаги заедно, защото ги иска един и същ човек, а ти ги отваряш в четири файла, за да прочетеш едно правило. Клас от 200 реда, който служи на един човек, е по-добре от пет класа, които винаги се отварят заедно. Каквото се сменя заедно, стои заедно.
По-точно, за любопитните
- Робърт Мартин сам пише, че „една отговорност“ често се чете като „прави едно нещо“. Неговото по-късно уточнение е, че модулът отговаря пред един актьор: група хора, които искат промени. „Прави едно нещо“ е правило за функциите, а не за класовете.
- „Не се повтаряй“ (DRY) е за знание, не за текст. Два реда код, които изглеждат еднакво, но се сменят по различни причини, не са дублиране. Ако ги слееш в един помощен метод, връзваш двама души с един възел. Точно това стана в проблема.
Пробвай
Кой иска промяната
Виж го
Нова доставка, без да пипаш сметката
Вторият принцип е отворено/затворено (Open/Closed Principle, OCP): кодът е отворен за разширение и затворен за промяна. Нов вариант идва с нов код, а кодът, който вече работи и е проверен, не се пипа.
Видя го в урока Strategy: веригата от if за начина на плащане стана таблица име → стратегия. Доставките са същото. Всяка доставка знае цената си:
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; }}Сметката не знае какви доставки има:
static decimal OrderTotal(decimal amount, IDelivery delivery){ return amount + delivery.Cost(amount);}Магазинът добавя доставка до автомат. Тя е нов клас:
class ToLocker : IDelivery{ public decimal Cost(decimal amount) { return 2.90m; }}OrderTotal, доставката до адрес и до офис не се пипат. Остава новата доставка да влезе в таблицата, от която клиентът избира. Това е следващата карта.
Кога пречи. Не можеш да затвориш кода за всичко, и не трябва. Всяка точка за разширение е интерфейс, клас и ред в таблица, които някой трябва да чете. Затваряш там, където промените наистина идват: нови доставки има всеки сезон. Правилото на опита: първия път пиши if. Когато дойде третият вариант, направи таблицата (Replace Conditional with Polymorphism от урока за refactoring).
Пробвай
Нов ред в таблицата
Таблицата от урока Strategy изброява доставките, от които клиентът избира. Добави автомата.
Какво пише на празното място?
var deliveries = new Dictionary<string, IDelivery>
{
["адрес"] = new ToAddress(),
["офис"] = new ToOffice(),
["автомат"] = ,
};
foreach (var (choice, delivery) in deliveries)
{
Console.WriteLine($"{choice}: {OrderTotal(80, delivery):F2} €");
}new ToLocker(): обект от новия клас. Таблицата пази обекти, които имплементират IDelivery, а сметката вика метода им, без да знае класа.адрес: 85.90 €офис: 83.90 €автомат: 82.90 €Сменена е само таблицата, а тя само изброява. OCP не значи „нито един сменен ред“. Значи, че правилата, които вече работят и са проверени, не се пипат при всеки нов вариант.
Проблем
Отказът, който спря по средата
Клиентката плаща поръчка с три неща: 60 € с карта, ваучер за 20 € и 16 € от точки. После я отказва. Всеки вид плащане знае как да върне парите си:
interface IPayment{ // Връща парите на клиента по същия начин, по който е платил. void Refund();}
record CardPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Карта: върнати {Amount:F2} €"); }}
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { throw new NotSupportedException("ваучер не се връща"); }}
record PointsPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Точки: върнати {Amount:F2} €"); }}Поръчката минава по плащанията и вика Refund() на всяко:
class Order{ private readonly List<IPayment> payments; public string Status { get; private set; } = "платена";
public Order(List<IPayment> payments) { this.payments = payments; }
public void Cancel() { foreach (var payment in payments) { payment.Refund(); } Status = "отказана"; }}var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Грешка: " + e.Message);}Console.WriteLine("Статус: " + order.Status);Карта: върнати 60.00 €Грешка: ваучер не се връщаСтатус: платенаКартата е върната, точките не са, а поръчката е още „платена“. Складът вижда „платена“ и изпраща пратката. Кодът на Cancel() е правилен. Грешката е във ваучера: той е Payment, но не прави това, което Payment обещава.
Виж го
Подтипът, който не пази обещанието
public void Cancel(){ foreach (var payment in payments) { payment.Refund(); } Status = "отказана";}
var order = new Order(new List<IPayment>{ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),});try{ order.Cancel();}catch (NotSupportedException e){ Console.WriteLine("Грешка: " + e.Message);}Console.WriteLine("Статус: " + order.Status);Стъпка по стъпка, с думи
- Поръчката е платена с три неща: 60.00 € с карта, ваучер за 20.00 € и 16.00 € от точки. И трите са IPayment.
- Клиентката отказва. Cancel() минава по плащанията и вика Refund() на всяко.
- Картата връща 60.00 €. Тя прави точно това, което IPayment обещава.
- Ваучерът също е IPayment, но неговият Refund() хвърля NotSupportedException. Обещанието е нарушено.
- Изключението излиза от цикъла и от Cancel(). Точките (16.00 €) не са върнати, а редът, който прави Status „отказана“, не се изпълнява.
- catch в Main го хваща и печата грешката. Програмата продължава.
- Статусът е още „платена“. Складът ще изпрати пратката, а 60.00 € вече са върнати на картата.
Това е третият принцип, принципът на заместването на Барбара Лисков (Liskov Substitution Principle, LSP): където кодът работи с типа, може да застане всеки подтип, и кодът да не разбере. Подтипът може да върши работата по свой начин, но спазва обещанието на типа.
Payment обещава „връщам парите на клиента“. Какво значи това за ваучер в истински магазин? Нов ваучер за същата сума:
record VoucherPayment(decimal Amount) : IPayment{ public void Refund() { Console.WriteLine($"Ваучер: нов ваучер за {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("Грешка: " + e.Message);}Console.WriteLine("Статус: " + order.Status);Карта: върнати 60.00 €Ваучер: нов ваучер за 20.00 €Точки: върнати 16.00 €Статус: отказанаCancel() не е сменен. Подтипът вече пасва там, където се иска типът.
Нарушението се познава по три белега:
- подтипът хвърля „не се поддържа“ (NotSupportedException) за метод, който типът обещава;
- подтипът връща нещо, което типът не обещава (
-1,null, празен текст), или иска повече от извикващия; - кодът пита is, преди да извика метода. Това е заобиколка около подтип, който не пасва.
Кога пречи. Тук догмата е обратна: да натикаш нещо в йерархията, защото „и то е плащане“. Ако нещо наистина не може да върне пари (например подарък от магазина), то не е Payment с refund. Дай му свой тип, вместо да пишеш метод, който хвърля.
По-точно, за любопитните
IList<T>, но не приема Add:IList<string> items = new[] { "кафе", "чаша" };try{ items.Add("чай");}catch (NotSupportedException e){ Console.WriteLine("Масивът като IList: " + e.Message);}Масивът като IList: Collection was of a fixed size.Формално, по Лисков: подтипът не иска повече от типа (не засилва условията на входа), не дава по-малко (не отслабва обещанието за резултата) и пази това, което за типа винаги е вярно.
Пробвай
Тихото нарушение
Доставката до автомат връща -1, когато автоматът е пълен. Сметката събира, както винаги. Какво печата програмата?
class ToLocker : IDelivery{ private readonly bool full;
public ToLocker(bool full) { this.full = full; }
public decimal Cost(decimal amount) { if (full) { return -1; // -1 значи „няма място“ } return 2.90m; }}
Console.WriteLine($"{OrderTotal(80, new ToLocker(true)):F2} €");79.00 €. Сметката е писана за обещанието „цената на доставката, 0 или повече“. Тя събира 80 и -1, и клиентът плаща 1 € по-малко за доставка, която не може да стане. Никой не хвърля нищо, затова това нарушение е по-опасно от изключението. Поправката: пълният автомат не е цена. Доставката казва дали е възможна с отделен метод, а сметката не я предлага.
Проблем
SMS-ът, който никога не тръгна
Магазинът праща известия на клиентите. Някой е описал всичко, което може да се праща, на едно място:
interface INotifier{ void Email(string to, string text);
void Sms(string phone, string text);}Пробвай
Намери грешката
Клиентката получава имейл, че пратката е тръгнала. SMS-ът за куриера не идва:
INotifier notifier = new MailProvider();notifier.Email("maria@example.com", "Поръчка 2026-0142 е изпратена.");notifier.Sms("0888 123 456", "Куриерът идва днес между 14 и 16 ч.");Имейл до maria@example.com: Поръчка 2026-0142 е изпратена.На кой ред е грешката?
Избери реда с грешката.
Методът за SMS е празен. Той е лъжа, която никой не чува: кодът го вика, нищо не тръгва и няма грешка. Доставчикът го написа, защото интерфейсът го искаше. Празният метод нарушава и LSP: обещанието „изпращам SMS“ не е спазено. Истинската поправка не е в този ред, а в договора: следващата карта.
Виж го
Малки интерфейси
Четвъртият принцип: разделяне на интерфейсите (Interface Segregation Principle, ISP). Никой не трябва да зависи от методи, които не ползва. Договорът се пише от страната на този, който го ползва, и е толкова голям, колкото му трябва.
Имейлите и SMS-ите стават два интерфейса:
interface IEmailSender{ void Email(string to, string text);}
interface ISmsSender{ void Sms(string phone, string text);}Всеки доставчик прави само каквото може, а известието за куриера иска само SMS:
class MailProvider : IEmailSender{ public void Email(string to, string text) { Console.WriteLine("Имейл до " + to + ": " + text); }}
class SmsGateway : ISmsSender{ public void Sms(string phone, string text) { Console.WriteLine("SMS до " + phone + ": " + text); }}
class ShippingNotice{ private readonly ISmsSender sender;
public ShippingNotice(ISmsSender sender) { this.sender = sender; }
public void CourierToday(string phone) { sender.Sms(phone, "Куриерът идва днес между 14 и 16 ч."); }}IEmailSender email = new MailProvider();email.Email("maria@example.com", "Поръчка 2026-0142 е изпратена.");new ShippingNotice(new SmsGateway()).CourierToday("0888 123 456");Имейл до maria@example.com: Поръчка 2026-0142 е изпратена.SMS до 0888 123 456: Куриерът идва днес между 14 и 16 ч.Ако някой подаде доставчика на имейли там, където трябва SMS, програмата не се компилира:
new ShippingNotice(new MailProvider()).CourierToday("0888 123 456");error CS1503: Argument 1: cannot convert from 'MailProvider' to 'ISmsSender'Грешката от картата преди е невъзможна: празен метод няма кой да поиска.
Малките интерфейси помагат и на тестовете: двойникът за известието имплементира един метод, а не всичко, което магазинът може да праща. Един клас може да имплементира и двата, ако доставчикът наистина праща и имейли, и SMS: class MailAndSms : IEmailSender, ISmsSender.
Кога пречи. Интерфейс за всеки метод „по ISP“ е другата крайност: Readable, Writable, Deletable, Countable пред хранилище, което се ползва на едно място и винаги цялото. Ако всеки, който ползва договора, ползва целия договор, той е един.
Проблем
Тестът, който праща истински имейл
Фактурата тръгва по имейл. Услугата сама си прави пощата:
class SmtpMailer{ public void Send(string to, string text) { Console.WriteLine("SMTP до " + to + ": " + text); }}
class InvoiceService{ private readonly SmtpMailer mailer = new SmtpMailer();
public void Issue(Order order, string email) => mailer.Send(email, $"Фактура {order.Number}: {order.Total:F2} €");}var order = new Order("2026-0142", "Мария", 80);new InvoiceService().Issue(order, "maria@example.com");SMTP до maria@example.com: Фактура 2026-0142: 96.00 €Работи, но:
- тестът за фактурата праща истински имейл, а на машина без SMTP сървър пада;
- магазинът минава от SMTP на услуга с API, и пипаш класа с правилата за фактурите, за да смениш пощата.
Правилото (какво пише във фактурата) зависи от детайла (как тръгва имейлът).
Виж го
Абстракцията е на правилото
Петият принцип: обръщане на зависимостите (Dependency Inversion Principle, DIP). Кодът с правилата не зависи от детайлите. И двете зависят от абстракция, и тя е на правилата: казва какво им трябва („прати този текст на този адрес“), не как го прави SMTP.
interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Фактура {order.Number}: {order.Total:F2} €");}Детайлите са отделно. Пощата и тестовият двойник имплементират абстракцията на услугата:
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP до " + 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", "Мария", 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("Тестът видя: " + fake.Sent[0]);SMTP до maria@example.com: Фактура 2026-0142: 96.00 €Тестът видя: maria@example.com | Фактура 2026-0142: 96.00 €interface IInvoiceSender{ void Send(string to, string text);}
class InvoiceService(IInvoiceSender sender){ public void Issue(Order order, string email) => sender.Send(email, $"Фактура {order.Number}: {order.Total:F2} €");}
class SmtpSender : IInvoiceSender{ public void Send(string to, string text) => Console.WriteLine("SMTP до " + to + ": " + text);}
var order = new Order("2026-0142", "Мария", 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("Тестът видя: " + fake.Sent[0]);Стъпка по стъпка, с думи
- Преди фактурите сочеха надолу, към SmtpMailer. Сега услугата записва какво ѝ трябва: IInvoiceSender, до себе си.
- InvoiceService получава пощата в конструктора и знае само IInvoiceSender. За SMTP не знае нищо.
- SmtpSender имплементира IInvoiceSender на услугата. Стрелката в кода сочи нагоре: детайлът зависи от правилото. Това е обръщането.
- При работа извикването пак върви надолу: от услугата до SMTP. Обърната е зависимостта в кода, не посоката на извикването.
- В теста същата услуга получава FakeSender: втори детайл, който сочи към същата абстракция. Имейл не тръгва: двойникът записва в Sent какво е получил.
- Тестът вижда точно текста на фактурата, без пощенски сървър.
„Обръщането“ е на стрелката в кода. Преди фактурите знаеха за SMTP. Сега SMTP знае за IInvoiceSender, който стои до фактурите. Извикването при работа пак върви от фактурите към SMTP.
DIP и DI. В урока Singleton и Dependency Injection видя подаването отвън: обектът получава зависимостта си в конструктора. Това е механизмът. DIP казва от какво да зависиш: от абстракция, която е твоя. Едното може без другото:
-
подаване без DIP:
InvoiceService(SmtpMailer mailer). Пощата идва отвън, но услугата пак знае класа на SMTP, и тест без SMTP не става; - DIP без библиотека за DI: в Main
InvoiceServiceполучаваSmtpSender, и това е цялото сглобяване. Мястото, където програмата сглобява обектите, е едно.
Кога пречи. Интерфейс пред всеки клас „заради DIP“ е най-честата догма. Абстракция си заслужава пред детайл, който е бавен, външен или ще се сменя: поща, плащане, база, часовник, мрежа. Пред Order или пред форматирането на сума тя е код за четене без полза.
По-точно, за любопитните
IInvoiceSender е в проекта (или пространството от имена) на фактурите, а SmtpSender е в проекта за пощата, който сочи към фактурите. Проектът на фактурите не сочи към пощата. Това е обърнатата стрелка, видяна в зависимостите на проектите.Пробвай
Подреди теста
Тестът проверява фактурата без истинска поща. Поръчката order вече я има. Подреди редовете:
var fake = new FakeSender();Console.WriteLine("Тестът видя: " + fake.Sent[0]);service.Issue(order, "maria@example.com");var service = new InvoiceService(fake);
Двойникът се прави първи, защото услугата го иска в конструктора. После фактурата се издава, и чак тогава има какво да провериш. Изходът: Тестът видя: maria@example.com | Фактура 2026-0142: 96.00 €
Накратко
Кога SOLID пречи
Ето ДДС-то, написано „по всички правила“:
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($"ДДС: {TaxPolicyFactory.Create().Vat(80):F2} €");ДДС: 16.00 €И същото без догма:
const decimal VatRate = 0.20m;Console.WriteLine($"ДДС: {80 * VatRate:F2} €");ДДС: 16.00 €Изходът е един и същ. Първото е интерфейс, клас и фабрика за число, което се сменя веднъж на десет години, и то пак на едно място. Това е миризмата спекулативна общност: код за промени, които никой не е поискал. Когато дойде втора ставка (9% за храни), тогава е моментът за таблица.
Принципите са въпроси, не правила. Задавай ги, когато кодът боли:
| Въпросът | Догмата, която да избягваш | |
|---|---|---|
| SRP | Кой ще поиска промяна тук? Един или двама? | Клас за всеки метод |
| OCP | Кое наистина идва ново всеки месец? | Точка за разширение навсякъде |
| LSP | Може ли всеки подтип да застане тук без if? |
Йерархия заради думата „е“ |
| ISP | Ползва ли този клиент всичко, което иска? | Интерфейс за всеки метод |
| DIP | Бавен, външен или сменяем ли е детайлът? | Интерфейс пред всеки клас |
Упражнение
Подаръчна карта, без да пипаш отказа
Реши задачата в редактора си.
Подаръчна карта. Магазинът приема и подаръчни карти. При отказ парите се връщат обратно в картата. Добави GiftCardPayment, който спазва обещанието на Payment, и откажи поръчка, платена с 40 € от карта и 25 € от подаръчна карта. Cancel() и другите плащания не се пипат.
Карта: върнати 40.00 €Подаръчна карта: 25.00 € обратно в картатаСтатус: отказанаОще задачи
- Отчетът за месеца. Клас смята оборота за месеца, пише CSV за счетоводството и текст за управителя, и сам праща имейла. Раздели го по това кой иска промените. Кое остава общо?
- Push известие. Добави известие в мобилното приложение, когато пратката е тръгнала. Кой малък договор ти трябва и кои класове не пипаш?
- Махни една абстракция. Намери в свой проект интерфейс с една имплементация, зад която няма бавен или външен детайл. Махни обвивката и пусни тестовете.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Singleton и Dependency Injection · Функции: една работа, малко параметри, без скрити странични ефекти · Миризмите: дълъг метод, голям клас, дублиране, примитиви вместо типове, дълга верига if · Refactoring на малки стъпки
- Продължава в: Упражнение: разхвърлян клас, изчистен стъпка по стъпка · Слоеве · Hexagonal и clean architecture · Монолит, модулен монолит, услуги
- Задачи по темата: Чист код и refactoring