SOLID без догма
Накратко: SOLID са пет принципа за код, който понася промени: една причина за промяна, разширение без промяна, заменими подтипове, малки интерфейси и зависимост от абстракции.
Проблем
Промяната за имейла, която счупи счетоводството
Онлайн магазинът праща на клиента фактура по имейл. Всяка вечер дава и на счетоводството файл с ред за всяка поръчка. Двете неща прави един клас:
from dataclasses import dataclass
@dataclassclass Order: number: str customer: str net: floatclass InvoiceService: def vat(self, order): return order.net * 0.20
# Счетоводството: ред за файла, който внася в програмата си. def export_line(self, order): total = order.net + self.vat(order) return f"{order.number};{self._money(order.net)};{self._money(self.vat(order))};{self._money(total)}"
# Клиентът: текстът на имейла с фактурата. def email_text(self, order): total = order.net + self.vat(order) return f"Здравей, {order.customer}! Поръчка {order.number}: {self._money(total)} €"
def _money(self, amount): return f"{amount:.2f}"invoices = InvoiceService()order = Order("2026-0142", "Мария", 80)print(invoices.export_line(order))print(invoices.email_text(order))2026-0142;80.00;16.00;96.00Здравей, Мария! Поръчка 2026-0142: 96.00 €Маркетингът казва: „В имейла сумата да е 96 €, а не 96.00 €. Изглежда по-чисто.“ Някой сменя помощния метод _money:
def _money(self, amount): if amount == int(amount): return str(int(amount)) return f"{amount:.2f}"2026-0142;80;16;96Здравей, Мария! Поръчка 2026-0142: 96 €Имейлът е какъвто го искаха. На сутринта счетоводната програма отказва файла: очаква 80.00, а получава 80. Редът за счетоводството никой не е пипал. Пипнат е помощният метод, който двата реда делят. Тестът за имейла минава, а тест за файла няма, защото „него никой не е сменял“.
Идеята
Една причина за промяна
Класът служи на двама, които не се познават. Счетоводителката иска точен формат, който нейната програма чете. Маркетингът иска приятен текст за клиента. Двамата искат промени по различно време и по различни причини. Щом кодът им е в един клас, промяната за единия може да счупи другия.
Това е първият от петте принципа SOLID (събрани от Робърт Мартин; името е от първите им букви на английски). Принципът за единствената отговорност (Single Responsibility Principle, SRP): класът има една причина за промяна.
„Причина“ значи кой иска промяната: човек, отдел, закон. Не значи „един метод“ и не значи „малък клас“. Въпросът, който си задаваш: „Кой ще дойде да поиска промяна тук?“ Ако отговорът е „счетоводството или маркетингът“, класовете са два.
Виж го
Всеки със своя клас
ДДС-то и сумата са правило на данъчния закон и трябват и на двамата. Те отиват при поръчката, на едно място:
from dataclasses import dataclass
@dataclassclass Order: number: str customer: str net: float
def vat(self): return self.net * 0.20
def total(self): return self.net + self.vat()Редът за счетоводството и текстът на имейла стават отделни класове. Всеки форматира сумите по своя начин:
class AccountingExport: def line(self, order): return f"{order.number};{order.net:.2f};{order.vat():.2f};{order.total():.2f}"
class InvoiceEmail: def text(self, order): return f"Здравей, {order.customer}! Поръчка {order.number}: {self._money(order.total())} €"
def _money(self, amount): if amount == int(amount): return str(int(amount)) return f"{amount:.2f}"order = Order("2026-0142", "Мария", 80)print(AccountingExport().line(order))print(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 за начина на плащане стана таблица име → стратегия. Доставките са същото. Всяка доставка знае цената си:
def to_address(amount): return 0 if amount >= 100 else 5.90
def to_office(amount): return 3.90Сметката не знае какви доставки има:
def order_total(amount, delivery): return amount + delivery(amount)Магазинът добавя доставка до автомат. Тя е нова функция:
def to_locker(amount): return 2.90order_total, доставката до адрес и до офис не се пипат. Остава новата доставка да влезе в таблицата, от която клиентът избира. Това е следващата карта.
Кога пречи. Не можеш да затвориш кода за всичко, и не трябва. Всяка точка за разширение е функция и ред в таблица, които някой трябва да чете. Затваряш там, където промените наистина идват: нови доставки има всеки сезон. Правилото на опита: първия път пиши if. Когато дойде третият вариант, направи таблицата (Replace Conditional with Polymorphism от урока за refactoring).
Пробвай
Нов ред в таблицата
Таблицата от урока Strategy изброява доставките, от които клиентът избира. Добави автомата.
Какво пише на празното място?
DELIVERIES = {
"адрес": to_address,
"офис": to_office,
"автомат": ,
}
for choice, delivery in DELIVERIES.items():
print(f"{choice}: {order_total(80, delivery):.2f} €")to_locker, без скоби. В таблицата стои самата функция. Със скоби там би стоял резултатът ѝ (2.9), а сметката не би имала какво да извика.адрес: 85.90 €офис: 83.90 €автомат: 82.90 €Сменена е само таблицата, а тя само изброява. OCP не значи „нито един сменен ред“. Значи, че правилата, които вече работят и са проверени, не се пипат при всеки нов вариант.
Проблем
Отказът, който спря по средата
Клиентката плаща поръчка с три неща: 60 € с карта, ваучер за 20 € и 16 € от точки. После я отказва. Всеки вид плащане знае как да върне парите си:
from abc import ABC, abstractmethodfrom dataclasses import dataclass
class Payment(ABC): @abstractmethod def refund(self): """Връща парите на клиента по същия начин, по който е платил."""
@dataclassclass CardPayment(Payment): amount: float
def refund(self): print(f"Карта: върнати {self.amount:.2f} €")
@dataclassclass VoucherPayment(Payment): amount: float
def refund(self): raise NotImplementedError("ваучер не се връща")
@dataclassclass PointsPayment(Payment): amount: float
def refund(self): print(f"Точки: върнати {self.amount:.2f} €")Поръчката минава по плащанията и вика refund() на всяко:
class Order: def __init__(self, payments): self.payments = payments self.status = "платена"
def cancel(self): for payment in self.payments: payment.refund() self.status = "отказана"order = Order([ CardPayment(60), VoucherPayment(20), PointsPayment(16),])try: order.cancel()except NotImplementedError as e: print(f"Грешка: {e}")print(f"Статус: {order.status}")Карта: върнати 60.00 €Грешка: ваучер не се връщаСтатус: платенаКартата е върната, точките не са, а поръчката е още „платена“. Складът вижда „платена“ и изпраща пратката. Кодът на cancel() е правилен. Грешката е във ваучера: той е Payment, но не прави това, което Payment обещава.
Виж го
Подтипът, който не пази обещанието
def cancel(self): for payment in self.payments: payment.refund() self.status = "отказана"
order = Order([ CardPayment(60), VoucherPayment(20), PointsPayment(16),])try: order.cancel()except NotImplementedError as e: print(f"Грешка: {e}")print(f"Статус: {order.status}")Стъпка по стъпка, с думи
- Поръчката е платена с три неща: 60.00 € с карта, ваучер за 20.00 € и 16.00 € от точки. И трите са Payment.
- Клиентката отказва. cancel() минава по плащанията и вика refund() на всяко.
- Картата връща 60.00 €. Тя прави точно това, което Payment обещава.
- Ваучерът също е Payment, но неговият refund() хвърля NotImplementedError. Обещанието е нарушено.
- Изключението излиза от цикъла и от cancel(). Точките (16.00 €) не са върнати, а редът, който прави status „отказана“, не се изпълнява.
- except в програмата го хваща и печата грешката. Програмата продължава.
- Статусът е още „платена“. Складът ще изпрати пратката, а 60.00 € вече са върнати на картата.
Това е третият принцип, принципът на заместването на Барбара Лисков (Liskov Substitution Principle, LSP): където кодът работи с типа, може да застане всеки подтип, и кодът да не разбере. Подтипът може да върши работата по свой начин, но спазва обещанието на типа.
Payment обещава „връщам парите на клиента“. Какво значи това за ваучер в истински магазин? Нов ваучер за същата сума:
@dataclassclass VoucherPayment(Payment): amount: float
def refund(self): print(f"Ваучер: нов ваучер за {self.amount:.2f} €")order = Order([ CardPayment(60), VoucherPayment(20), PointsPayment(16),])try: order.cancel()except NotImplementedError as e: print(f"Грешка: {e}")print(f"Статус: {order.status}")Карта: върнати 60.00 €Ваучер: нов ваучер за 20.00 €Точки: върнати 16.00 €Статус: отказанаcancel() не е сменен. Подтипът вече пасва там, където се иска типът.
Нарушението се познава по три белега:
- подтипът хвърля „не се поддържа“ (NotImplementedError) за метод, който типът обещава;
- подтипът връща нещо, което типът не обещава (
-1,None, празен текст), или иска повече от извикващия; - кодът пита isinstance, преди да извика метода. Това е заобиколка около подтип, който не пасва.
Кога пречи. Тук догмата е обратна: да натикаш нещо в йерархията, защото „и то е плащане“. Ако нещо наистина не може да върне пари (например подарък от магазина), то не е Payment с refund. Дай му свой тип, вместо да пишеш метод, който хвърля.
По-точно, за любопитните
collections.abc дели договора на две: Sequence (чете) и MutableSequence (и сменя). Кортежът е само първото:from collections.abc import MutableSequence, Sequence
items = ("кафе", "чаша")print(isinstance(items, Sequence), isinstance(items, MutableSequence))True FalseSequence, и кортежът пасва без лъжа. Това е и следващият принцип: малки договори.Формално, по Лисков: подтипът не иска повече от типа (не засилва условията на входа), не дава по-малко (не отслабва обещанието за резултата) и пази това, което за типа винаги е вярно.
Пробвай
Тихото нарушение
Доставката до автомат връща -1, когато автоматът е пълен. Сметката събира, както винаги. Какво печата програмата?
def to_locker(full): def cost(amount): if full: return -1 # -1 значи „няма място“ return 2.90 return cost
print(f"{order_total(80, to_locker(True)):.2f} €")79.00 €. Сметката е писана за обещанието „цената на доставката, 0 или повече“. Тя събира 80 и -1, и клиентът плаща 1 € по-малко за доставка, която не може да стане. Никой не хвърля нищо, затова това нарушение е по-опасно от изключението. Поправката: пълният автомат не е цена. Пълният автомат не влиза в таблицата с доставките, които клиентът може да избере.
Проблем
SMS-ът, който никога не тръгна
Магазинът праща известия на клиентите. Някой е описал всичко, което може да се праща, на едно място:
from abc import ABC, abstractmethod
class Notifier(ABC): @abstractmethod def email(self, to, text): pass
@abstractmethod def sms(self, phone, text): passsms. Без него MailProvider() спира с TypeError, затова някой го е написал.Пробвай
Намери грешката
Клиентката получава имейл, че пратката е тръгнала. SMS-ът за куриера не идва:
notifier = 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). Никой не трябва да зависи от методи, които не ползва. Договорът се пише от страната на този, който го ползва, и е толкова голям, колкото му трябва.
В Python малкият договор се пише с typing.Protocol: „нещо с метод sms“. Доставчиците не наследяват нищо. Пасва всеки обект, който има метода:
from typing import Protocol
class EmailSender(Protocol): def email(self, to: str, text: str) -> None: ...
class SmsSender(Protocol): def sms(self, phone: str, text: str) -> None: ...Всеки доставчик прави само каквото може, а известието за куриера иска само SMS:
class MailProvider: def email(self, to, text): print(f"Имейл до {to}: {text}")
class SmsGateway: def sms(self, phone, text): print(f"SMS до {phone}: {text}")
class ShippingNotice: def __init__(self, sender: SmsSender): self.sender = sender
def courier_today(self, phone): self.sender.sms(phone, "Куриерът идва днес между 14 и 16 ч.")email = MailProvider()email.email("maria@example.com", "Поръчка 2026-0142 е изпратена.")ShippingNotice(SmsGateway()).courier_today("0888 123 456")Имейл до maria@example.com: Поръчка 2026-0142 е изпратена.SMS до 0888 123 456: Куриерът идва днес между 14 и 16 ч.Ако някой подаде доставчика на имейли там, където трябва SMS, програмата спира още при първото извикване, вместо да мълчи:
try: ShippingNotice(MailProvider()).courier_today("0888 123 456")except AttributeError as e: print(f"AttributeError: {e}")AttributeError: 'MailProvider' object has no attribute 'sms'Protocol при изпълнение. Проверка на типовете като mypy вижда грешката преди пускане: MailProvider няма sms, а параметърът иска SmsSender.Малките интерфейси помагат и на тестовете: двойникът за известието имплементира един метод, а не всичко, което магазинът може да праща.
Кога пречи. Интерфейс за всеки метод „по ISP“ е другата крайност: Readable, Writable, Deletable, Countable пред хранилище, което се ползва на едно място и винаги цялото. Ако всеки, който ползва договора, ползва целия договор, той е един.
Проблем
Тестът, който праща истински имейл
Фактурата тръгва по имейл. Услугата сама си прави пощата:
class SmtpMailer: def send(self, to, text): print(f"SMTP до {to}: {text}")
class InvoiceService: def __init__(self): self.mailer = SmtpMailer()
def issue(self, order, email): total = order.total() text = f"Фактура {order.number}: {total:.2f} €" self.mailer.send(email, text)order = Order("2026-0142", "Мария", 80)InvoiceService().issue(order, "maria@example.com")SMTP до maria@example.com: Фактура 2026-0142: 96.00 €Работи, но:
- тестът за фактурата праща истински имейл, а на машина без SMTP сървър пада;
- магазинът минава от SMTP на услуга с API, и пипаш класа с правилата за фактурите, за да смениш пощата.
Правилото (какво пише във фактурата) зависи от детайла (как тръгва имейлът).
Виж го
Абстракцията е на правилото
Петият принцип: обръщане на зависимостите (Dependency Inversion Principle, DIP). Кодът с правилата не зависи от детайлите. И двете зависят от абстракция, и тя е на правилата: казва какво им трябва („прати този текст на този адрес“), не как го прави SMTP.
from abc import ABC, abstractmethod
class InvoiceSender(ABC): @abstractmethod def send(self, to, text): pass
class InvoiceService: def __init__(self, sender: InvoiceSender): self.sender = sender
def issue(self, order, email): total = order.total() text = f"Фактура {order.number}: {total:.2f} €" self.sender.send(email, text)Детайлите са отделно. Пощата и тестовият двойник наследяват абстракцията на услугата:
class SmtpSender(InvoiceSender): def send(self, to, text): print(f"SMTP до {to}: {text}")
class FakeSender(InvoiceSender): def __init__(self): self.sent = []
def send(self, to, text): self.sent.append(f"{to} | {text}")order = Order("2026-0142", "Мария", 80)smtp = InvoiceService(SmtpSender())smtp.issue(order, "maria@example.com")
fake = FakeSender()service = InvoiceService(fake)service.issue(order, "maria@example.com")print(f"Тестът видя: {fake.sent[0]}")SMTP до maria@example.com: Фактура 2026-0142: 96.00 €Тестът видя: maria@example.com | Фактура 2026-0142: 96.00 €class InvoiceSender(ABC): @abstractmethod def send(self, to, text): pass
class InvoiceService: def __init__(self, sender: InvoiceSender): self.sender = sender
def issue(self, order, email): total = order.total() text = f"Фактура {order.number}: {total:.2f} €" self.sender.send(email, text)
class SmtpSender(InvoiceSender): def send(self, to, text): print(f"SMTP до {to}: {text}")
order = Order("2026-0142", "Мария", 80)smtp = InvoiceService(SmtpSender())smtp.issue(order, "maria@example.com")
fake = FakeSender()service = InvoiceService(fake)service.issue(order, "maria@example.com")print(f"Тестът видя: {fake.sent[0]}")Стъпка по стъпка, с думи
- Преди фактурите сочеха надолу, към SmtpMailer. Сега услугата записва какво ѝ трябва: InvoiceSender, до себе си.
- InvoiceService получава пощата в конструктора и знае само InvoiceSender. За SMTP не знае нищо.
- SmtpSender имплементира InvoiceSender на услугата. Стрелката в кода сочи нагоре: детайлът зависи от правилото. Това е обръщането.
- При работа извикването пак върви надолу: от услугата до SMTP. Обърната е зависимостта в кода, не посоката на извикването.
- В теста същата услуга получава FakeSender: втори детайл, който сочи към същата абстракция. Имейл не тръгва: двойникът записва в sent какво е получил.
- Тестът вижда точно текста на фактурата, без пощенски сървър.
„Обръщането“ е на стрелката в кода. Преди фактурите знаеха за SMTP. Сега SMTP знае за InvoiceSender, който стои до фактурите. Извикването при работа пак върви от фактурите към SMTP.
DIP и DI. В урока Singleton и Dependency Injection видя подаването отвън: обектът получава зависимостта си в конструктора. Това е механизмът. DIP казва от какво да зависиш: от абстракция, която е твоя. Едното може без другото:
-
подаване без DIP: услугата получава пощата отвън, но файлът ѝ пак внася
from mail import SmtpMailerи вика нещо, което само SMTP има; - DIP без библиотека за DI: в програмата
InvoiceServiceполучаваSmtpSender, и това е цялото сглобяване. Мястото, където програмата сглобява обектите, е едно.
Кога пречи. Интерфейс пред всеки клас „заради DIP“ е най-честата догма. Абстракция си заслужава пред детайл, който е бавен, външен или ще се сменя: поща, плащане, база, часовник, мрежа. Пред Order или пред форматирането на сума тя е код за четене без полза.
По-точно, за любопитните
ABC пощата наследява InvoiceSender и го внася: стрелката към абстракцията се вижда. С typing.Protocol SmtpSender изобщо не знае за договора, и зависимостта от детайла към абстракцията изчезва. Проверката (mypy) сверява формата преди пускане.Пробвай
Подреди теста
Тестът проверява фактурата без истинска поща. Поръчката order вече я има. Подреди редовете:
service = InvoiceService(fake)fake = FakeSender()service.issue(order, "maria@example.com")print(f"Тестът видя: {fake.sent[0]}")
Двойникът се прави първи, защото услугата го иска в конструктора. После фактурата се издава, и чак тогава има какво да провериш. Изходът: Тестът видя: maria@example.com | Фактура 2026-0142: 96.00 €
Накратко
Кога SOLID пречи
Ето ДДС-то, написано „по всички правила“:
from abc import ABC, abstractmethod
class TaxPolicy(ABC): @abstractmethod def vat(self, net): pass
class StandardVat(TaxPolicy): def vat(self, net): return net * 0.20
def create_tax_policy(): return StandardVat()print(f"ДДС: {create_tax_policy().vat(80):.2f} €")ДДС: 16.00 €И същото без догма:
VAT_RATE = 0.20print(f"ДДС: {80 * VAT_RATE:.2f} €")ДДС: 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