Към съдържанието
wannacode

SOLID без догма

Накратко: SOLID са пет принципа за код, който понася промени: една причина за промяна, разширение без промяна, заменими подтипове, малки интерфейси и зависимост от абстракции.

Проблем

Промяната за имейла, която счупи счетоводството

Онлайн магазинът праща на клиента фактура по имейл. Всяка вечер дава и на счетоводството файл с ред за всяка поръчка. Двете неща прави един клас:

class Order {
constructor(number, customer, net) {
this.number = number;
this.customer = customer;
this.net = net;
}
}
class InvoiceService {
vat(order) {
return order.net * 0.2;
}
// Счетоводството: ред за файла, който внася в програмата си.
exportLine(order) {
const total = order.net + this.vat(order);
return [order.number, this.#money(order.net), this.#money(this.vat(order)), this.#money(total)].join(";");
}
// Клиентът: текстът на имейла с фактурата.
emailText(order) {
const total = order.net + this.vat(order);
return `Здравей, ${order.customer}! Поръчка ${order.number}: ${this.#money(total)} €`;
}
#money(amount) {
return amount.toFixed(2);
}
}
const invoices = new InvoiceService();
const order = new Order("2026-0142", "Мария", 80);
console.log(invoices.exportLine(order));
console.log(invoices.emailText(order));
Изход
2026-0142;80.00;16.00;96.00
Здравей, Мария! Поръчка 2026-0142: 96.00 €

Маркетингът казва: „В имейла сумата да е 96 €, а не 96.00 €. Изглежда по-чисто.“ Някой сменя помощния метод #money:

#money(amount) {
if (Number.isInteger(amount)) {
return String(amount);
}
return amount.toFixed(2);
}
Изход
2026-0142;80;16;96
Здравей, Мария! Поръчка 2026-0142: 96 €

Имейлът е какъвто го искаха. На сутринта счетоводната програма отказва файла: очаква 80.00, а получава 80. Редът за счетоводството никой не е пипал. Пипнат е помощният метод, който двата реда делят. Тестът за имейла минава, а тест за файла няма, защото „него никой не е сменял“.

Идеята

Една причина за промяна

Класът служи на двама, които не се познават. Счетоводителката иска точен формат, който нейната програма чете. Маркетингът иска приятен текст за клиента. Двамата искат промени по различно време и по различни причини. Щом кодът им е в един клас, промяната за единия може да счупи другия.

Това е първият от петте принципа SOLID (събрани от Робърт Мартин; името е от първите им букви на английски). Принципът за единствената отговорност (Single Responsibility Principle, SRP): класът има една причина за промяна.

„Причина“ значи кой иска промяната: човек, отдел, закон. Не значи „един метод“ и не значи „малък клас“. Въпросът, който си задаваш: „Кой ще дойде да поиска промяна тук?“ Ако отговорът е „счетоводството или маркетингът“, класовете са два.

Виж го

Всеки със своя клас

ДДС-то и сумата са правило на данъчния закон и трябват и на двамата. Те отиват при поръчката, на едно място:

class Order {
constructor(number, customer, net) {
this.number = number;
this.customer = customer;
this.net = net;
}
vat() {
return this.net * 0.2;
}
total() {
return this.net + this.vat();
}
}

Редът за счетоводството и текстът на имейла стават отделни класове. Всеки форматира сумите по своя начин:

class AccountingExport {
line(order) {
return [order.number, order.net.toFixed(2), order.vat().toFixed(2), order.total().toFixed(2)].join(";");
}
}
class InvoiceEmail {
text(order) {
return `Здравей, ${order.customer}! Поръчка ${order.number}: ${this.#money(order.total())} €`;
}
#money(amount) {
if (Number.isInteger(amount)) {
return String(amount);
}
return amount.toFixed(2);
}
}
const order = new Order("2026-0142", "Мария", 80);
console.log(new AccountingExport().line(order));
console.log(new InvoiceEmail().text(order));
Изход
2026-0142;80.00;16.00;96.00
Здравей, Мария! Поръчка 2026-0142: 96 €

Имейлът е с цели суми, а файлът пак е с две цифри. Утре счетоводството ще смени формата, и ще пипаш само AccountingExport. Кодът на имейла дори не е пред теб.

Двата начина да напишеш сумата вече не са общи. Това не е дублиране: днес си приличат, но са две правила, които се сменят по различни причини.

Кога пречи. Другата крайност е клас за всяко действие: VatCalculator, TotalCalculator, NumberFormatter, GreetingBuilder, по три реда всеки. Сменят се винаги заедно, защото ги иска един и същ човек, а ти ги отваряш в четири файла, за да прочетеш едно правило. Клас от 200 реда, който служи на един човек, е по-добре от пет класа, които винаги се отварят заедно. Каквото се сменя заедно, стои заедно.

По-точно, за любопитните
  • Робърт Мартин сам пише, че „една отговорност“ често се чете като „прави едно нещо“. Неговото по-късно уточнение е, че модулът отговаря пред един актьор: група хора, които искат промени. „Прави едно нещо“ е правило за функциите, а не за класовете.
  • „Не се повтаряй“ (DRY) е за знание, не за текст. Два реда код, които изглеждат еднакво, но се сменят по различни причини, не са дублиране. Ако ги слееш в един помощен метод, връзваш двама души с един възел. Точно това стана в проблема.

Пробвай

Кой иска промяната

1. Кой въпрос ти казва дали класът има една причина за промяна?
2. ShippingLabel смята цената на доставката (иска я търговският отдел), рисува етикета (форматът е на куриерската фирма) и пише реда за склада (иска го складът). Как го подреждаш?

Виж го

Нова доставка, без да пипаш сметката

Вторият принцип е отворено/затворено (Open/Closed Principle, OCP): кодът е отворен за разширение и затворен за промяна. Нов вариант идва с нов код, а кодът, който вече работи и е проверен, не се пипа.

Видя го в урока Strategy: веригата от if за начина на плащане стана таблица име → стратегия. Доставките са същото. Всяка доставка знае цената си:

function toAddress(amount) {
return amount >= 100 ? 0 : 5.9;
}
function toOffice(amount) {
return 3.9;
}

Сметката не знае какви доставки има:

function orderTotal(amount, delivery) {
return amount + delivery(amount);
}

Магазинът добавя доставка до автомат. Тя е нова функция:

function toLocker(amount) {
return 2.9;
}

orderTotal, доставката до адрес и до офис не се пипат. Остава новата доставка да влезе в таблицата, от която клиентът избира. Това е следващата карта.

Кога пречи. Не можеш да затвориш кода за всичко, и не трябва. Всяка точка за разширение е функция и ред в таблица, които някой трябва да чете. Затваряш там, където промените наистина идват: нови доставки има всеки сезон. Правилото на опита: първия път пиши if. Когато дойде третият вариант, направи таблицата (Replace Conditional with Polymorphism от урока за refactoring).

Пробвай

Нов ред в таблицата

Таблицата от урока Strategy изброява доставките, от които клиентът избира. Добави автомата.

Какво пише на празното място?

const deliveries = new Map([
  ["адрес", toAddress],
  ["офис", toOffice],
  ["автомат", ],
]);

for (const [choice, delivery] of deliveries) {
  console.log(`${choice}: ${orderTotal(80, delivery).toFixed(2)} €`);
}

Проблем

Отказът, който спря по средата

Клиентката плаща поръчка с три неща: 60 € с карта, ваучер за 20 € и 16 € от точки. После я отказва. Всеки вид плащане знае как да върне парите си:

// Всяко плащане има refund(): връща парите на клиента по същия начин, по който е платил.
class Payment {
constructor(amount) {
this.amount = amount;
}
}
class CardPayment extends Payment {
refund() {
console.log(`Карта: върнати ${this.amount.toFixed(2)} €`);
}
}
class VoucherPayment extends Payment {
refund() {
throw new Error("ваучер не се връща");
}
}
class PointsPayment extends Payment {
refund() {
console.log(`Точки: върнати ${this.amount.toFixed(2)} €`);
}
}

Поръчката минава по плащанията и вика refund() на всяко:

class Order {
constructor(payments) {
this.payments = payments;
this.status = "платена";
}
cancel() {
for (const payment of this.payments) {
payment.refund();
}
this.status = "отказана";
}
}
const order = new Order([
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16),
]);
try {
order.cancel();
} catch (e) {
console.log(`Грешка: ${e.message}`);
}
console.log(`Статус: ${order.status}`);
Изход
Карта: върнати 60.00 €
Грешка: ваучер не се връща
Статус: платена

Картата е върната, точките не са, а поръчката е още „платена“. Складът вижда „платена“ и изпраща пратката. Кодът на cancel() е правилен. Грешката е във ваучера: той е Payment, но не прави това, което Payment обещава.

Виж го

Подтипът, който не пази обещанието

cancel() {
for (const payment of this.payments) {
payment.refund();
}
this.status = "отказана";
}
const order = new Order([
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16),
]);
try {
order.cancel();
} catch (e) {
console.log(`Грешка: ${e.message}`);
}
console.log(`Статус: ${order.status}`);
Стъпка по стъпка, с думи
  1. Поръчката е платена с три неща: 60.00 € с карта, ваучер за 20.00 € и 16.00 € от точки. И трите са Payment.
  2. Клиентката отказва. cancel() минава по плащанията и вика refund() на всяко.
  3. Картата връща 60.00 €. Тя прави точно това, което Payment обещава.
  4. Ваучерът също е Payment, но неговият refund() хвърля Error. Обещанието е нарушено.
  5. Изключението излиза от цикъла и от cancel(). Точките (16.00 €) не са върнати, а редът, който прави status „отказана“, не се изпълнява.
  6. catch в програмата го хваща и печата грешката. Програмата продължава.
  7. Статусът е още „платена“. Складът ще изпрати пратката, а 60.00 € вече са върнати на картата.

Това е третият принцип, принципът на заместването на Барбара Лисков (Liskov Substitution Principle, LSP): където кодът работи с типа, може да застане всеки подтип, и кодът да не разбере. Подтипът може да върши работата по свой начин, но спазва обещанието на типа.

Payment обещава „връщам парите на клиента“. Какво значи това за ваучер в истински магазин? Нов ваучер за същата сума:

class VoucherPayment extends Payment {
refund() {
console.log(`Ваучер: нов ваучер за ${this.amount.toFixed(2)} €`);
}
}
const order = new Order([
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16),
]);
try {
order.cancel();
} catch (e) {
console.log(`Грешка: ${e.message}`);
}
console.log(`Статус: ${order.status}`);
Изход
Карта: върнати 60.00 €
Ваучер: нов ваучер за 20.00 €
Точки: върнати 16.00 €
Статус: отказана

cancel() не е сменен. Подтипът вече пасва там, където се иска типът.

Нарушението се познава по три белега:

  • подтипът хвърля „не се поддържа“ (грешка) за метод, който типът обещава;
  • подтипът връща нещо, което типът не обещава (-1, null, празен текст), или иска повече от извикващия;
  • кодът пита instanceof, преди да извика метода. Това е заобиколка около подтип, който не пасва.
Езикът не вижда нито едно от трите, а без интерфейс в JavaScript обещанието е само в името на метода и в коментара. Затова то трябва да е записано и да е проверено с тест.

Кога пречи. Тук догмата е обратна: да натикаш нещо в йерархията, защото „и то е плащане“. Ако нещо наистина не може да върне пари (например подарък от магазина), то не е Payment с refund. Дай му свой тип, вместо да пишеш метод, който хвърля.

По-точно, за любопитните
И езикът има такъв случай. Замразеният масив е пак Array (Array.isArray казва true), но не приема push:
const items = Object.freeze(["кафе", "чаша"]);
try {
items.push("чай");
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
Изход
TypeError: Cannot add property 2, object is not extensible
В модул (а и с "use strict") грешката е TypeError. Без строг режим push хвърля пак, но присвояване на елемент тихо не прави нищо.

Формално, по Лисков: подтипът не иска повече от типа (не засилва условията на входа), не дава по-малко (не отслабва обещанието за резултата) и пази това, което за типа винаги е вярно.

Пробвай

Тихото нарушение

Доставката до автомат връща -1, когато автоматът е пълен. Сметката събира, както винаги. Какво печата програмата?

function toLocker(full) {
return (amount) => {
if (full) {
return -1; // -1 значи „няма място“
}
return 2.9;
};
}
console.log(`${orderTotal(80, toLocker(true)).toFixed(2)} €`);
Отговор

Проблем

SMS-ът, който никога не тръгна

Магазинът праща известия на клиентите. Някой е описал всичко, което може да се праща, на едно място:

// Известията: всеки, който праща известия, има email(to, text) и sms(phone, text).
Доставчикът на имейли праща само имейли. Кодът при изпращане на пратка вика notifier.sms(...), и без метода програмата спира с TypeError. Затова някой го е написал.

Пробвай

Намери грешката

Клиентката получава имейл, че пратката е тръгнала. SMS-ът за куриера не идва:

const notifier = new MailProvider();
notifier.email("maria@example.com", "Поръчка 2026-0142 е изпратена.");
notifier.sms("0888 123 456", "Куриерът идва днес между 14 и 16 ч.");
Изход
Имейл до maria@example.com: Поръчка 2026-0142 е изпратена.

На кой ред е грешката?

Избери реда с грешката.

Виж го

Малки интерфейси

Четвъртият принцип: разделяне на интерфейсите (Interface Segregation Principle, ISP). Никой не трябва да зависи от методи, които не ползва. Договорът се пише от страната на този, който го ползва, и е толкова голям, колкото му трябва.

В JavaScript интерфейси няма. Малкият договор е това, което кодът вика: известието за куриера вика само sms, затова пасва всеки обект с sms. Принципът тук значи: не искай повече, отколкото викаш, и не карай никого да пише празни методи.

Всеки доставчик прави само каквото може, а известието за куриера иска само SMS:

class MailProvider {
email(to, text) {
console.log(`Имейл до ${to}: ${text}`);
}
}
class SmsGateway {
sms(phone, text) {
console.log(`SMS до ${phone}: ${text}`);
}
}
class ShippingNotice {
constructor(sender) {
this.sender = sender;
}
courierToday(phone) {
this.sender.sms(phone, "Куриерът идва днес между 14 и 16 ч.");
}
}
const 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, програмата спира още при първото извикване, вместо да мълчи:

try {
new ShippingNotice(new MailProvider()).courierToday("0888 123 456");
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
Изход
TypeError: this.sender.sms is not a function

Малките интерфейси помагат и на тестовете: двойникът за известието имплементира един метод, а не всичко, което магазинът може да праща.

Кога пречи. Интерфейс за всеки метод „по ISP“ е другата крайност: Readable, Writable, Deletable, Countable пред хранилище, което се ползва на едно място и винаги цялото. Ако всеки, който ползва договора, ползва целия договор, той е един.

Проблем

Тестът, който праща истински имейл

Фактурата тръгва по имейл. Услугата сама си прави пощата:

class SmtpMailer {
send(to, text) {
console.log(`SMTP до ${to}: ${text}`);
}
}
class InvoiceService {
constructor() {
this.mailer = new SmtpMailer();
}
issue(order, email) {
const total = order.total().toFixed(2);
const text = `Фактура ${order.number}: ${total} €`;
this.mailer.send(email, text);
}
}
const 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.

class InvoiceService {
// sender е всеки обект с метод send(to, text).
constructor(sender) {
this.sender = sender;
}
issue(order, email) {
const total = order.total().toFixed(2);
const text = `Фактура ${order.number}: ${total} €`;
this.sender.send(email, text);
}
}

Детайлите са отделно. Пощата и тестовият двойник имат метода, който услугата вика:

class SmtpSender {
send(to, text) {
console.log(`SMTP до ${to}: ${text}`);
}
}
class FakeSender {
constructor() {
this.sent = [];
}
send(to, text) {
this.sent.push(`${to} | ${text}`);
}
}
const order = new Order("2026-0142", "Мария", 80);
new InvoiceService(new SmtpSender())
.issue(order, "maria@example.com");
const fake = new FakeSender();
const service = new InvoiceService(fake);
service.issue(order, "maria@example.com");
console.log(`Тестът видя: ${fake.sent[0]}`);
Изход
SMTP до maria@example.com: Фактура 2026-0142: 96.00 €
Тестът видя: maria@example.com | Фактура 2026-0142: 96.00 €

class InvoiceService {
// sender е всеки обект с метод send(to, text).
constructor(sender) {
this.sender = sender;
}
issue(order, email) {
const total = order.total().toFixed(2);
const text = `Фактура ${order.number}: ${total} €`;
this.sender.send(email, text);
}
}
class SmtpSender {
send(to, text) {
console.log(`SMTP до ${to}: ${text}`);
}
}
const order = new Order("2026-0142", "Мария", 80);
new InvoiceService(new SmtpSender())
.issue(order, "maria@example.com");
const fake = new FakeSender();
const service = new InvoiceService(fake);
service.issue(order, "maria@example.com");
console.log(`Тестът видя: ${fake.sent[0]}`);
Стъпка по стъпка, с думи
  1. Преди фактурите сочеха надолу, към SmtpMailer. Сега услугата записва какво ѝ трябва: обект със send(to, text).
  2. InvoiceService получава пощата в конструктора и знае само send(to, text). За SMTP не знае нищо.
  3. SmtpSender има send(to, text), договора, който услугата описа. Стрелката сочи нагоре: детайлът зависи от правилото. Това е обръщането.
  4. При работа извикването пак върви надолу: от услугата до SMTP. Обърната е зависимостта в кода, не посоката на извикването.
  5. В теста същата услуга получава FakeSender: втори детайл, който сочи към същата абстракция. Имейл не тръгва: двойникът записва в sent какво е получил.
  6. Тестът вижда точно текста на фактурата, без пощенски сървър.

„Обръщането“ е на стрелката в кода. Преди фактурите знаеха за SMTP. Сега SMTP знае за договора send(to, text), който стои до фактурите. Извикването при работа пак върви от фактурите към SMTP.

DIP и DI. В урока Singleton и Dependency Injection видя подаването отвън: обектът получава зависимостта си в конструктора. Това е механизмът. DIP казва от какво да зависиш: от абстракция, която е твоя. Едното може без другото:

  • подаване без DIP: услугата получава пощата отвън, но файлът ѝ пак внася import { SmtpMailer } from "./mail.js" и вика нещо, което само SMTP има;
  • DIP без библиотека за DI: в програмата InvoiceService получава SmtpSender, и това е цялото сглобяване. Мястото, където програмата сглобява обектите, е едно.

Кога пречи. Интерфейс пред всеки клас „заради DIP“ е най-честата догма. Абстракция си заслужава пред детайл, който е бавен, външен или ще се сменя: поща, плащане, база, часовник, мрежа. Пред Order или пред форматирането на сума тя е код за четене без полза.

По-точно, за любопитните
В истински проект invoices.js не внася smtp.js. main.js внася двата и подава new SmtpSender() на услугата. Абстракция като тип няма: тя е методът send(to, text), записан в коментара и проверен от теста с двойника.

Пробвай

Подреди теста

Тестът проверява фактурата без истинска поща. Поръчката order вече я има. Подреди редовете:

  1. console.log(`Тестът видя: ${fake.sent[0]}`);
  2. const fake = new FakeSender();
  3. const service = new InvoiceService(fake);
  4. service.issue(order, "maria@example.com");

Накратко

Кога SOLID пречи

Ето ДДС-то, написано „по всички правила“:

class StandardVat {
vat(net) {
return net * 0.2;
}
}
class TaxPolicyFactory {
static create() {
return new StandardVat();
}
}
console.log(`ДДС: ${TaxPolicyFactory.create().vat(80).toFixed(2)} €`);
Изход
ДДС: 16.00 €

И същото без догма:

const VAT_RATE = 0.2;
console.log(`ДДС: ${(80 * VAT_RATE).toFixed(2)} €`);
Изход
ДДС: 16.00 €

Изходът е един и същ. Първото е клас и фабрика за число, което се сменя веднъж на десет години, и то пак на едно място. Това е миризмата спекулативна общност: код за промени, които никой не е поискал. Когато дойде втора ставка (9% за храни), тогава е моментът за таблица.

Принципите са въпроси, не правила. Задавай ги, когато кодът боли:

Въпросът Догмата, която да избягваш
SRP Кой ще поиска промяна тук? Един или двама? Клас за всеки метод
OCP Кое наистина идва ново всеки месец? Точка за разширение навсякъде
LSP Може ли всеки подтип да застане тук без if? Йерархия заради думата „е“
ISP Ползва ли този клиент всичко, което иска? Интерфейс за всеки метод
DIP Бавен, външен или сменяем ли е детайлът? Интерфейс пред всеки клас

Упражнение

Подаръчна карта, без да пипаш отказа

Реши задачата в редактора си.

Подаръчна карта. Магазинът приема и подаръчни карти. При отказ парите се връщат обратно в картата. Добави GiftCardPayment, който спазва обещанието на Payment, и откажи поръчка, платена с 40 € от карта и 25 € от подаръчна карта. cancel() и другите плащания не се пипат.

Изход
Карта: върнати 40.00 €
Подаръчна карта: 25.00 € обратно в картата
Статус: отказана

Още задачи

  • Отчетът за месеца. Клас смята оборота за месеца, пише CSV за счетоводството и текст за управителя, и сам праща имейла. Раздели го по това кой иска промените. Кое остава общо?
  • Push известие. Добави известие в мобилното приложение, когато пратката е тръгнала. Кой малък договор ти трябва и кои класове не пипаш?
  • Махни една абстракция. Намери в свой проект клас или функция, обвита „за всеки случай“, зад която няма бавен или външен детайл. Махни обвивката и пусни тестовете.

Тест

Провери се

1. Маркетингът иска промяна в текста на имейла, а след нея пада файлът за счетоводството. Кой принцип е нарушен?
2. Магазинът добавя доставка до автомат. Какво значи „затворено за промяна“?
3. VoucherPayment е Payment, но refund хвърля грешка „не се връща“. Какво става с код, който вика refund на всяко плащане?
4. Доставчикът на имейли има празен метод sms, защото кодът вика sms на всеки, който праща известия. Какво ти казва това?
5. InvoiceService прави self.mailer = SmtpMailer() (this.mailer = new SmtpMailer()) в конструктора си. Какво нарушава?
6. Кога интерфейс с една имплементация си заслужава?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·