Аббревиатура SOLID — пять принципов проектирования классов, которые сформулировал Роберт Мартин. Их часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории. На деле принципы — это ответы на конкретные боли: «почему так тяжело менять этот код?», «почему один класс правят сразу несколько команд?», «почему замена библиотеки ломает половину системы?».
Разберём каждый принцип с нуля: в чём проблема, как её видно в коде и как исправить.
SRP — одна ответственность
У класса должна быть одна причина для изменения.
Представьте сервис, который со временем вырос в «свалку»:
class OrderService:
def create_order(self, request: CreateOrderRequest) -> OrderDto: ... # 120 строк
def cancel_order(self, order_id: OrderId) -> OrderDto: ... # 80 строк
def search_orders(self, filter: OrderFilter) -> Page[OrderDto]: ... # 60 строк
def export_orders(self, filter: OrderFilter) -> bytes: ... # 90 строк
def recalculate_statistics(self) -> None: ... # 70 строк
Когда меняется правило создания заказа — правим этот класс. Когда меняется формат экспорта — снова этот класс. Когда меняется алгоритм статистики — опять он. У класса несколько несвязанных причин для изменения, и любая правка рискует задеть остальное.
SRP говорит: у класса должна быть одна причина для изменения. На практике это значит — один класс решает одну задачу.
class CreateOrderHandler:
def __init__(self, orders: OrderRepository, pricing: PricingPolicy) -> None:
self._orders = orders
self._pricing = pricing
def handle(self, cmd: CreateOrderCommand) -> OrderDto:
order = Order.create(cmd.customer_id, cmd.lines, self._pricing)
self._orders.save(order)
return OrderDto.from_order(order)
Этот класс меняется только когда меняется правило создания заказа. Отправка письма покупателю — другая ответственность, она живёт в отдельном обработчике события.
Хороший сигнал нарушения SRP — когда класс содержит слово «and» в устном описании: «он создаёт заказ и отправляет письмо и пересчитывает статистику».
OCP — открыт для расширения, закрыт для изменения
Поведение системы расширяется добавлением кода, а не правкой существующего.
Типичная картина нарушения — match или цепочка if, в которую нужно дописывать ветку каждый раз, когда появляется новый вариант:
def discount(order: Order) -> Decimal:
match order.customer.type:
case CustomerType.VIP:
return order.total * Decimal("0.10")
case CustomerType.EMPLOYEE:
return order.total * Decimal("0.20")
case CustomerType.REGULAR:
return Decimal("0")
Новый тип клиента — правка этого метода. И всех остальных match по тому же признаку в других местах кода.
Решение — объявить точку расширения (в Python — Protocol), и добавлять новое поведение новым классом, не трогая существующий:
class DiscountPolicy(Protocol):
def supports(self, customer: Customer) -> bool: ...
def discount(self, order: Order) -> Decimal: ...
class DiscountCalculator:
def __init__(self, policies: list[DiscountPolicy]) -> None:
self._policies = policies
def discount(self, order: Order) -> Decimal:
for policy in self._policies:
if policy.supports(order.customer):
return policy.discount(order)
return Decimal("0")
Новый тип скидки — новый класс, реализующий DiscountPolicy. Существующий код не трогается.
Оговорка: OCP не значит «везде плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — match честнее. Принцип применяется там, где расширение реально ожидается.
LSP — принцип подстановки Лисков
Реализацию можно заменить на любую другую реализацию того же контракта — и вызывающий код не заметит разницы.
Нарушение обычно выглядит как наследование ради повторного использования кода, когда подкласс нарушает ожидания родителя:
class CachedProductRepository(SqlAlchemyProductRepository):
def __init__(self) -> None:
super().__init__()
self._cache: dict[ProductId, Product] = {}
def find_by_id(self, product_id: ProductId) -> Product | None:
if product_id not in self._cache:
product = super().find_by_id(product_id)
if product is not None:
self._cache[product_id] = product
return self._cache.get(product_id)
def delete(self, product_id: ProductId) -> None:
raise NotImplementedError("кеш не поддерживает удаление")
Класс называет себя репозиторием, но delete бросает исключение. Код, который работал с базовым классом, со «специализированным» ломается — это и есть нарушение LSP: подкласс сузил контракт.
Правильная форма — не наследование, а композиция. Новый класс реализует тот же контракт и честно выполняет его целиком:
class CachingProductRepository:
def __init__(self, delegate: ProductRepository, cache: Cache) -> None:
self._delegate = delegate
self._cache = cache
def find_by_id(self, product_id: ProductId) -> Product | None:
return self._cache.get_or_load(product_id, lambda: self._delegate.find_by_id(product_id))
def delete(self, product_id: ProductId) -> None:
self._delegate.delete(product_id) # удаление выполняется
self._cache.evict(product_id) # и кеш инвалидируется
Теперь CachingProductRepository подставляется вместо любого другого ProductRepository без сюрпризов.
Правило-подсказка: если видите NotImplementedError в переопределённом методе — почти наверняка нарушен LSP.
ISP — разделение интерфейсов
Клиент не должен зависеть от методов, которые он не использует.
Интерфейсы имеют свойство разрастаться: сначала был save и find_by_id, потом добавили find_for_listing, потом find_for_export, потом archive_older_than:
class OrderStorage(Protocol):
def save(self, order: Order) -> None: ...
def find_by_id(self, order_id: OrderId) -> Order | None: ...
def find_for_listing(self, filter: OrderFilter, page: Pagination) -> Page[OrderListRow]: ...
def find_for_export(self, date_from: date, date_to: date) -> list[OrderExportRow]: ...
def archive_older_than(self, cutoff: date) -> None: ...
Обработчик команды использует два метода из пяти, но зависит от всех. Изменение сигнатуры метода экспорта вынуждает перепроверять и его. В тестах — приходится заглушать все пять методов, хотя нужны только два.
Решение — разрезать по потребителям, не по таблице:
# для команд — только то, что нужно
class OrderRepository(Protocol):
def save(self, order: Order) -> None: ...
def find_by_id(self, order_id: OrderId) -> Order | None: ...
# для чтения и отчётов — отдельно
class OrderViewRepository(Protocol):
def find_for_listing(self, filter: OrderFilter, page: Pagination) -> Page[OrderListRow]: ...
def find_for_export(self, date_from: date, date_to: date) -> list[OrderExportRow]: ...
Одна реализация может удовлетворять обоим протоколам — это нормально. Важно, что каждый потребитель зависит только от тех методов, которые реально использует.
DIP — инверсия зависимостей
Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.
Простыми словами: бизнес-логика не должна напрямую знать про конкретные инструменты (базу данных, почтовый сервер, внешний API). Иначе при смене инструмента придётся трогать бизнес-логику.
Типичное нарушение — доменная модель знает про конкретный транспорт уведомлений:
class Order:
def cancel(self, mail_sender: SmtpMailSender) -> None:
self.status = Status.CANCELLED
mail_sender.send(self.customer.email, "Заказ отменён")
Доменная модель зависит от SmtpMailSender — конкретной инфраструктурной детали. Захотите перейти на push-уведомления — придётся менять Order. Захотите протестировать отмену без реального SMTP — сложно.
Инверсия: домен объявляет что ему нужно (Protocol), а инфраструктура решает как это реализовать:
# протокол объявлен рядом с доменом, говорит на языке домена
class NotificationPort(Protocol):
def order_cancelled(self, order: Order) -> None: ...
# реализация — в инфраструктурном слое
class SmtpNotificationAdapter:
def __init__(self, mail_client: SmtpClient) -> None:
self._mail_client = mail_client
def order_cancelled(self, order: Order) -> None:
self._mail_client.send(build_message(order))
Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.
Обратите внимание: зависимость идёт от SmtpNotificationAdapter к NotificationPort (который живёт рядом с доменом), а не наоборот. Направление зависимостей перевёрнуто относительно направления вызова — отсюда название «инверсия».
Коротко
- SRP: у класса одна причина для изменения. Если класс делает «и то, и это» — его пора разделить.
- OCP: новое поведение добавляется новым классом, не правкой существующего.
Protocolкак точка расширения. - LSP: подкласс или реализация заменяют оригинал без сюрпризов.
NotImplementedErrorв переопределённом методе — красный флаг. - ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
- DIP: бизнес-логика зависит от интерфейсов, а не от конкретных классов. Направление зависимостей — к домену, не от него.
Принципы работают вместе: класс с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми классами (OCP).
Что почитать дальше
- Паттерны GoF — конкретные приёмы, которые реализуют идеи SOLID на практике.
- GRASP на примерах — принципы распределения ответственности между классами.
- Гексагональная архитектура — DIP и ISP, доведённые до структуры модулей.