Принципы разработки SOLID: пять принципов с честным разбором и критикой
0%

SOLID: пять принципов с честным разбором и критикой

SOLID: пять принципов с честным разбором и критикой

SOLID — самый цитируемый и самый неправильно понятый набор правил в объектно-ориентированном проектировании. Его пересказывают на собеседованиях одной строчкой на принцип, после чего пишут код, в котором каждый класс обёрнут интерфейсом с единственной реализацией, а простая функция размазана по шести файлам. Это карго-культ, о котором мы говорили в обзоре трека.

Цель этой статьи — вернуть принципам их исходный смысл. Каждый из пяти был сформулирован как ответ на конкретную боль: «изменение в одном месте ломает другое», «чтобы добавить фичу, приходится править работающий код», «наследник взорвал вызывающий код», «клиент зависит от того, чем не пользуется», «бизнес-логику нельзя собрать без драйвера базы». Если боли нет — принцип не нужен. Если боль есть — принцип показывает, куда провести шов.


Откуда взялся акроним

Пять принципов Роберт Мартин собрал в статье Design Principles and Design Patterns (2000). Порядок SRP–OCP–LSP–ISP–DIP и сам акроним SOLID предложил примерно в 2004 году Майкл Фэзерс — исходный список был длиннее и включал, например, принципы связности пакетов (REP, CCP, CRP) и принципы их зацепления (ADP, SDP, SAP), которые сегодня почти не вспоминают, хотя для модульных монолитов они полезнее половины SOLID.

Важный исторический факт: принципы формулировались в мире, где типичное приложение — большой ОО-монолит на C++ или Java, релиз раз в квартал, а «изменение» означало перекомпиляцию и физическую поставку библиотеки. Многое из того, что тогда решалось наследованием и абстрактными классами, сегодня решается функциями высшего порядка, конфигурацией, feature-флагами и деплоем раз в час. Держите это в голове, читая формулировки.


SRP — Single Responsibility Principle

Интуиция

Формулировка «у класса должна быть одна ответственность» бесполезна, потому что «ответственность» можно нарезать как угодно. Мартин позже уточнил принцип так:

Модуль должен отвечать перед одним и только одним актором.

Актор — это группа людей, которая заказывает изменения: бухгалтерия, отдел кадров, DBA, продуктовая команда. SRP — не про эстетику кода, а про организацию: если один класс правят два разных отдела по разным причинам, они будут ломать друг друга и конфликтовать в мержах. Это принцип о минимизации «радиуса взрыва» изменения.

Классический пример нарушения

class Employee:
    """Три актора в одном классе: финансы, HR и DBA."""

    def calculate_pay(self) -> Money:
        # правила считает бухгалтерия
        return self.base * self._overtime_factor()

    def report_hours(self) -> Hours:
        # отчёт нужен HR, но использует тот же _overtime_factor
        return self.hours * self._overtime_factor()

    def save(self) -> None:
        # схему хранения меняют DBA
        db.execute("UPDATE employees SET ...")

    def _overtime_factor(self) -> float:
        return 1.5 if self.hours > 160 else 1.0

Ловушка не в том, что «методов много». Ловушка в общем приватном _overtime_factor: бухгалтерия просит поменять правило сверхурочных → меняется и отчёт для HR, о чём никто не узнает до скандала. Это реальный сценарий из книги Clean Architecture, и он объясняет, почему SRP — принцип про связность по причинам изменения, а не про размер файла.

Разделение:

class PayCalculator:          # актор: финансы
    def calculate(self, e: EmployeeData) -> Money: ...

class HourReporter:           # актор: HR
    def report(self, e: EmployeeData) -> Hours: ...

class EmployeeRepository:     # актор: DBA
    def save(self, e: EmployeeData) -> None: ...

@dataclass(frozen=True)
class EmployeeData:           # общие данные без поведения
    id: int
    base: Money
    hours: Hours

Общая логика, если она действительно общая, выносится в явно названную политику (OvertimePolicy), которую каждый актор подключает осознанно.

Честная критика

  • Нефальсифицируемость. «Одна причина для изменения» невозможно проверить объективно: для одного ревьюера UserService — одна ответственность, для другого — пять. Принцип полезен как вопрос («кто закажет следующее изменение этого файла?»), но плох как правило прохода в CI.
  • Переизмельчение. Механическое SRP порождает классы-однометодники: UserNameValidator, UserEmailValidator, UserNameValidatorFactory. Связность падает, потому что чтобы понять один сценарий, нужно открыть восемь файлов. Это уже нарушение принципа связности — того самого, ради которого SRP и вводился.
  • Практический тест. Посмотрите git log --format="%an" -- path/to/file.py | sort -u и историю причин коммитов. Если файл годами правит одна команда по одному типу задач — SRP соблюдён, сколько бы методов там ни было.

OCP — Open/Closed Principle

Две разные формулировки

Принцип старше SOLID: его ввёл Бертран Мейер в Object-Oriented Software Construction (1988). У Мейера «закрыт» означало буквально: модуль опубликован и скомпилирован, менять его нельзя, расширять — только наследованием. У Мартина смысл сместился к полиморфному OCP: расширять через реализацию абстракции, не трогая клиента абстракции. Формулировка:

Программные сущности должны быть открыты для расширения и закрыты для модификации.

Практическая цель — сделать так, чтобы добавление новой разновидности поведения было операцией «добавить файл», а не «найти все switch по типу и не забыть ни одного».

Пример

Было — каждое новое правило требует правки функции, которая уже покрыта тестами и работает:

def discount(order: Order) -> Money:
    if order.customer.is_vip:
        return order.total * Decimal("0.10")
    elif order.coupon == "BLACKFRIDAY":
        return order.total * Decimal("0.30")
    elif order.total > 10_000:
        return order.total * Decimal("0.05")
    return Money(0)

Стало — правила замкнуты на общий контракт, движок закрыт для модификации:

from typing import Protocol, Iterable

class DiscountRule(Protocol):
    def applies_to(self, order: "Order") -> bool: ...
    def amount(self, order: "Order") -> Money: ...

class VipDiscount:
    def applies_to(self, order): return order.customer.is_vip
    def amount(self, order):     return order.total * Decimal("0.10")

class CouponDiscount:
    def __init__(self, code: str, rate: Decimal):
        self.code, self.rate = code, rate
    def applies_to(self, order): return order.coupon == self.code
    def amount(self, order):     return order.total * self.rate

class DiscountEngine:
    """Закрыт для модификации: новые правила приходят снаружи."""
    def __init__(self, rules: Iterable[DiscountRule]):
        self._rules = list(rules)

    def best(self, order: Order) -> Money:
        applicable = [r.amount(order) for r in self._rules if r.applies_to(order)]
        return max(applicable, default=Money(0))

Сложность bestO(k) по числу правил и O(k) по памяти на список applicable (легко свести к O(1) памяти через max(генератор)). Цена абстракции — один виртуальный вызов на правило и потеря возможности увидеть всю логику скидок в одном экране кода.

Когда OCP окупается, а когда вредит

OCP — это ставка на то, что вы угадали ось изменчивости. Ось угадана верно, если новые требования приходят как «ещё одно правило того же вида». Если же они приходят как «а теперь скидка зависит от истории заказов и должна применяться каскадом», ваша абстракция DiscountRule не спасает — её всё равно придётся ломать, но теперь ещё и во всех реализациях.

Практическое правило, ближе к реальности, чем догма: первое появление варианта — просто if. Второе — терпим и смотрим. Третье — вводим абстракцию, потому что ось изменчивости подтверждена данными, а не фантазией (это «правило трёх» из рефакторинга и прямое следствие YAGNI).

Отдельно: в мире, где вы владеете всем кодом и деплоите его целиком, «закрытость для модификации» стоит дёшево нарушить — вы просто правите if и катите релиз. OCP критичен там, где у модуля есть чужие потребители: публичная библиотека, плагинный API, SDK. Там модификация — ломающее изменение для тех, кого вы не контролируете.


LSP — Liskov Substitution Principle

Формулировка и происхождение

Барбара Лисков сформулировала интуицию в докладе Data Abstraction and Hierarchy (1987), а строгую версию дала вместе с Дженнет Уинг в статье A Behavioral Notion of Subtyping (TOPLAS, 1994):

Пусть q(x) — свойство, доказуемое для объектов x типа T. Тогда q(y) должно быть истинно для объектов y типа S, где S — подтип T.

Ключевое слово — behavioral: компилятор проверяет только сигнатуры, а LSP говорит о поведении, которое в типах не выражено. Правила, вытекающие из design by contract Мейера:

Что Правило для наследника Вариантность
Предусловия нельзя усиливать (принимай не меньше) контравариантно
Постусловия нельзя ослаблять (обещай не меньше) ковариантно
Инварианты должны сохраняться
История наследник не может делать изменяемым то, что база гарантировала неизменяемым
Исключения нельзя бросать типы, не предусмотренные базой

Вариантность контрактов при наследовании: расширение предусловий и сужение постусловий

Квадрат и прямоугольник — и почему пример важен

class Rectangle:
    def set_width(self, w): self._w = w
    def set_height(self, h): self._h = h
    def area(self): return self._w * self._h

class Square(Rectangle):          # математически — да, поведенчески — нет
    def set_width(self, w):  self._w = self._h = w
    def set_height(self, h): self._w = self._h = h

def stretch(r: Rectangle):
    r.set_width(5)
    r.set_height(4)
    assert r.area() == 20         # для Square получим 16

Мораль не в геометрии. Мораль в том, что отношение подтипа определяется контрактом клиента, а не таксономией предметной области. Клиент stretch неявно рассчитывал на инвариант «ширина и высота независимы». Square его нарушает — значит, Square не подтип Rectangle в этой системе, хотя в школьной геометрии квадрат — прямоугольник.

Нарушения LSP, которые вы точно видели в проде

List<String> ro = Collections.unmodifiableList(list);
ro.add("x");   // UnsupportedOperationException в рантайме

Стандартная библиотека Java сама нарушает LSP: unmodifiableList возвращает объект типа List, который не выполняет постусловие add. Это сознательный компромисс ради удобства API, и он регулярно взрывается в рантайме — ровно та цена, о которой предупреждает принцип. Джошуа Блох в Effective Java (Item 18) прямо советует «предпочитать композицию наследованию» именно из-за хрупкости поведенческих контрактов.

Другие типичные нарушения:

  • наследник бросает NotImplementedError на части методов (нарушение постусловия);
  • наследник требует вызвать init() перед использованием (усиление предусловия);
  • «read-only» декоратор поверх изменяемого интерфейса;
  • NullObject, который на самом деле не выполняет контракт, а тихо ничего не делает — иногда это ровно то, что нужно, но чаще прячет баг.

Как проверять LSP механически

Есть простой и очень эффективный приём: общий набор тестов на контракт, который прогоняется для каждой реализации.

import pytest

class RepositoryContract:
    """Наследуйте в тестах каждой реализации — контракт проверяется одинаково."""

    def make_repo(self) -> "OrderRepository":
        raise NotImplementedError

    def test_saved_order_is_readable(self):
        repo = self.make_repo()
        repo.save(Order(id=1, total=Money(100)))
        assert repo.get(1).total == Money(100)

    def test_get_missing_raises_not_found(self):
        repo = self.make_repo()
        with pytest.raises(OrderNotFound):     # постусловие для ВСЕХ реализаций
            repo.get(999)

class TestInMemoryRepo(RepositoryContract):
    def make_repo(self): return InMemoryOrderRepository()

class TestPostgresRepo(RepositoryContract):
    def make_repo(self): return PostgresOrderRepository(test_dsn())

Если in-memory-фейк проходит контракт, а Postgres-реализация — нет, у вас нарушение LSP, и все тесты, использующие фейк, лгут. Подробнее об этом — в статье о принципах тестирования.


ISP — Interface Segregation Principle

Суть

Клиенты не должны зависеть от методов, которые они не используют.

Принцип родился из реального проекта Xerox: огромный класс Job для копировального аппарата, от которого зависели все подсистемы. Любая правка Job требовала перекомпиляции и переустановки всей прошивки. Решение — узкие интерфейсы под каждого клиента (PrintJob, StapleJob), которые Job реализует.

Обратите внимание: нарушение ISP почти всегда порождает нарушение LSP. «Толстый» интерфейс заставляет реализацию затыкать лишние методы заглушками — и подстановка ломается. Это два взгляда на одну и ту же болезнь.

ISP в статически типизированных языках без наследования

Каноничная реализация ISP — интерфейсы Go: они неявные, поэтому клиент объявляет ровно тот минимум, который ему нужен, а реализация о нём даже не знает.

// Плохо: функция требует весь Storage, хотя читает один блоб.
func Render(s *Storage, key string) ([]byte, error) { ... }

// Хорошо: требуем ровно ту роль, которая нужна.
type BlobReader interface {
    ReadBlob(ctx context.Context, key string) ([]byte, error)
}

func Render(ctx context.Context, r BlobReader, key string) ([]byte, error) {
    raw, err := r.ReadBlob(ctx, key)
    if err != nil {
        return nil, fmt.Errorf("render %s: %w", key, err)
    }
    return transform(raw), nil
}

Отсюда два известных правила Go: «чем больше интерфейс, тем слабее абстракция» и «принимай интерфейсы, возвращай структуры» (см. Go Code Review Comments и трек Go). Мартин Фаулер описывает ту же идею под именем Role Interface — интерфейс определяется ролью, которую играет объект для конкретного клиента, а не полным списком его умений.

Критика

ISP — самый безобидный принцип из пяти: его почти невозможно применить во вред, если не считать культа «интерфейс на каждый метод». Основная претензия — он частный случай более общего правила минимизации связанности, и в языках со структурной типизацией (Go, TypeScript) соблюдается почти автоматически.

// TypeScript: структурная типизация даёт ISP бесплатно —
// функция объявляет ровно то, что читает.
function greet(user: { name: string }) {
  return `Привет, ${user.name}`;
}

// Подойдёт любой объект с полем name, включая огромную сущность из БД.
greet({ name: "Аня", email: "a@b.c", passwordHash: "...", roles: [] });

DIP — Dependency Inversion Principle

Формулировка

  1. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Ключевое и чаще всего упускаемое: абстракция принадлежит вызывающему, а не реализации. Интерфейс OrderRepository живёт в пакете бизнес-логики и написан на её языке; пакет с Postgres импортирует бизнес-логику, а не наоборот. Именно это и есть «инверсия»: зависимость на уровне исходного кода направлена против потока управления.

Инверсия зависимостей: интерфейс принадлежит высокоуровневому модулю

# --- domain/ports.py: абстракция принадлежит домену -------------------
from typing import Protocol

class OrderRepository(Protocol):
    def get(self, order_id: int) -> Order: ...
    def save(self, order: Order) -> None: ...

class Clock(Protocol):
    def now(self) -> datetime: ...

# --- domain/service.py: политика ничего не знает о Postgres -----------
class OrderService:
    def __init__(self, repo: OrderRepository, clock: Clock):
        self._repo, self._clock = repo, clock

    def cancel(self, order_id: int) -> None:
        order = self._repo.get(order_id)
        if self._clock.now() - order.created_at > timedelta(hours=1):
            raise TooLateToCancel(order_id)
        order.status = OrderStatus.CANCELLED
        self._repo.save(order)

# --- infra/pg.py: деталь зависит от абстракции ------------------------
class PostgresOrderRepository:              # структурно реализует Protocol
    def __init__(self, pool): self._pool = pool
    def get(self, order_id: int) -> Order: ...
    def save(self, order: Order) -> None: ...

# --- main.py: композиционный корень, единственное место со всеми знаниями
service = OrderService(PostgresOrderRepository(pool), SystemClock())

Что мы выиграли конкретно:

  • cancel тестируется без базы и без sleepClock подменяется на фиксированный;
  • правило «час на отмену» читается целиком, без SQL-шума;
  • замена Postgres на другой источник — изменение в одном файле и в композиционном корне;
  • пакет domain не имеет внешних зависимостей вообще, что видно статически (import-linter, go list, ArchUnit могут это проверять в CI).

DIP ≠ DI ≠ IoC-контейнер

Три вещи, которые постоянно путают:

  • DIP — принцип направления зависимостей на уровне исходного кода;
  • Dependency Injection — техника передачи зависимостей извне (через конструктор/параметр);
  • IoC-контейнер — библиотека, автоматизирующая DI (Spring, Dagger, dependency-injector).

DIP можно соблюдать вообще без ООП: функция, принимающая другую функцию, — уже инверсия зависимости. И наоборот, можно завести контейнер, а зависимости оставить направленными на детали.

# DIP через функцию высшего порядка — интерфейс не нужен
def cancel_order(order_id: int, *, get_order, save_order, now):
    order = get_order(order_id)
    if now() - order.created_at > timedelta(hours=1):
        raise TooLateToCancel(order_id)
    ...

Критика

  • Интерфейс на каждый класс — антипаттерн. Пары UserService / IUserService с единственной реализацией не дают ни развязки, ни тестируемости (современные моки прекрасно работают с классами), зато добавляют навигационный налог: «перейти к определению» приводит в интерфейс. Абстракция оправдана, когда есть вторая реализация или настоящая граница (сеть, БД, время, случайность, внешний API).
  • Стоимость исполнения. Полиморфизм — это косвенный вызов, потеря инлайнинга и предсказания ветвлений. В обсуждении Clean Code, Horrible Performance Кейси Муратори и Роберт Мартин публично обсуждают, что дисциплина «всё через полиморфизм» стоит на горячих путях в разы производительности. Для CRUD-сервиса это неважно; для рендера кадра, парсера или трейдингового движка — решающе.
  • Ложное чувство переносимости. Интерфейс Repository не сделает ваш код независимым от БД, если наружу торчат транзакции, ленивая загрузка и семантика изоляции. Абстракция протекает (см. закон дырявых абстракций Джоэла Спольски).

Общая картина: цена против пользы

Закономерность видна: выигрышны применения на настоящих границах (внешний мир, вторая реализация, чужие потребители) и проигрышны — «на всякий случай», внутри одного модуля, который целиком принадлежит вам.


Честная критика SOLID как набора

  1. Нет эмпирических подтверждений. Крупных воспроизводимых исследований, связывающих соблюдение SOLID с меньшей дефектностью или стоимостью поддержки, практически нет. Это инженерный фольклор высокого качества, а не научный результат. Относитесь соответственно: как к эвристикам, а не к законам.
  2. Принципы неоднородны. ISP почти тавтологичен, LSP — строгая математика, SRP — социология, OCP — ставка на прогноз, DIP — топология зависимостей. Объединяет их только акроним.
  3. Они ОО-центричны. В функциональной парадигме половина решается иначе: OCP — паттерн-матчингом и функциями высшего порядка, DIP — параметризацией эффектов, LSP — алгебраическими типами, где «наследников» просто нет. Об этом — в треке о парадигмах программирования.
  4. Альтернативы существуют. Дэн Норт, критикуя SOLID как труднопроверяемый, предложил CUPID: Composable, Unix philosophy, Predictable, Idiomatic, Domain-based — свойства, а не правила. Полезно как противовес: вместо «соблюдён ли принцип» спрашивать «приятно ли с этим кодом работать».
  5. SOLID молчит о главном. Он ничего не говорит о данных, конкурентности, отказах, границах транзакций, наблюдаемости и производительности. Идеально SOLID-ный сервис может лежать под нагрузкой и терять деньги на ретраях — об этом статья об отказоустойчивости.

Типичные ошибки

Ошибка Как выглядит Что делать
Интерфейс ради интерфейса IFoo с единственным Foo Удалить интерфейс до появления второй реализации
SRP как «один метод — один класс» 40 классов на один сценарий Мерить по акторам и причинам изменения
OCP «про запас» Плагинная архитектура для двух вариантов Правило трёх; if — нормальный код
Наследование ради переиспользования class Stack(ArrayList) Композиция; наследование только для подстановки
Фейк, не соблюдающий контракт in-memory-репозиторий без уникальных ключей Единый контракт-тест на все реализации
Абстракция принадлежит инфраструктуре domain импортирует repositories.postgres Перенести интерфейс в домен, проверять в CI
«SOLID» вместо измерения рефакторинг без метрик Смотреть на реальную частоту изменений файлов

Как это применяют в проде

  • Гексагональная архитектура / порты и адаптеры — это, по сути, DIP, доведённый до уровня модулей: домен объявляет порты, инфраструктура их реализует. Проверяется статически (import-linter в Python, ArchUnit в Java, depguard в Go) прямо в CI.
  • Контракт-тесты между сервисами (Pact, consumer-driven contracts) — это LSP на уровне сетевых API: поставщик обязан не сужать то, на что рассчитывает потребитель.
  • Совместимость схем в Kafka/Protobuf (backward/forward compatibility) — те же правила вариантности: не усиливать требования к входу, не ослаблять гарантии по выходу.
  • Стандартные библиотеки как эталон ISP: io.Reader/io.Writer в Go, Iterable в Python, IEnumerable в C#. Один метод — и вся экосистема совместима.
  • Feature-флаги и конфигурация сегодня закрывают часть задач OCP дешевле, чем полиморфизм: новое поведение включается без правки и без нового класса.

Чеклист для код-ревью

[ ] Кто закажет следующее изменение этого файла? Их больше одного?     (SRP)
[ ] Добавление N+1 варианта = новый файл или правка switch?            (OCP)
[ ] Любую ли реализацию можно подставить, не читая её код?             (LSP)
[ ] Есть общий контракт-тест для всех реализаций интерфейса?           (LSP)
[ ] Клиент видит методы, которые никогда не вызовет?                   (ISP)
[ ] Домен импортирует инфраструктуру? Кому принадлежит интерфейс?      (DIP)
[ ] У интерфейса есть вторая реализация или настоящая граница?         (анти-DIP-культ)

Подробнее о том, как встроить такой чеклист в процесс, — в статье о код-ревью и стандартах.


Мини-итог

  • SRP — про людей и радиус взрыва изменения, а не про размер класса.
  • OCP — ставка на угаданную ось изменчивости; окупается на публичных границах и при трёх+ однотипных вариантах.
  • LSP — единственный формально строгий принцип; проверяется контракт-тестами, нарушается наследованием ради переиспользования.
  • ISP — узкие ролевые интерфейсы; в структурно типизированных языках почти бесплатен.
  • DIP — абстракция принадлежит политике; основа тестируемости и гексагональной архитектуры, но интерфейс на каждый класс — чистый вред.

Общий закон: все пять покупают гибкость за косвенность. Косвенность всегда стоит понимания, навигации и производительности. Платите её там, где гибкость действительно понадобится, — и не платите там, где нет.


Источники


Что дальше

SOLID отвечает на вопрос «как разрезать систему». Следующая статья — о том, как не разрезать её слишком рано и слишком мелко: три самых цитируемых правила отрасли и цена преждевременной абстракции.

DRY, KISS, YAGNI и цена преждевременной абстракции

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов