← назад к разделу

Аббревиатура 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, доведённые до структуры модулей.