Принципы SOLID

SOLID

Что это такое

SOLID — это пять правил объектного проектирования, которые Роберт Мартин собрал под одним акронимом. Законами они не являются и всего проектирования не исчерпывают: это набор эвристик, и все они указывают на одну цель — удерживать изменение локальным, чтобы правка одной части системы не расходилась по частям, которые к ней отношения не имеют. Каждая буква акронима называет свой способ, которым структура кода пропускает изменение через границы.

Почему это важно

Большая часть кода гниёт из-за связанности, а не из-за ошибок. Возьмём класс с двумя причинами для изменения: каждый релиз рискует задеть вторую заботу. Базовый класс с подклассом, который не может его соблюсти, заставляет вызывающих проверять типы, о которых они не должны знать. А модуль, который сам создаёт свои зависимости, нельзя ни протестировать, ни заменить. Однако сами эти проблемы о себе не заявляют: они накапливаются правками, которые становятся всё крупнее, пока небольшой запрос функции не начинает занимать неделю. Поэтому SOLID называет симптомы достаточно рано, чтобы исправить их дёшево.

Ключевые идеи

Единственная ответственность

У модуля должна быть одна причина для изменения — обычно это одна группа людей, которым он небезразличен. Класс, который форматирует счёт и ещё и отправляет его, меняется и когда меняется вёрстка, и когда меняются правила доставки: две причины, две команды, один файл. Проверка идёт не по числу методов, а по числу аудиторий, чьи запросы вызвали бы правку.

class Invoice { total() { /* ... */ } }
class InvoiceEmailer { send(invoice) { /* ... */ } }

Открыт для расширения, закрыт для изменения

Проект должен позволять добавить новый случай добавлением кода, а не правкой растущей цепочки условий. Значит, зависеть нужно от интерфейса в той точке, где происходит варьирование, а новые реализации подключать на периферии. Это не «никогда не правь файл», а требование держать правку для нового случая в одном месте, а не разбрасывать её по многим.

interface ShippingRate { price(parcel): number }
class CourierA implements ShippingRate { /* ... */ }
class CourierB implements ShippingRate { /* ... */ }

Подстановка Лисков

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

class ReadOnlyList extends List {
  add(item) { throw new Error("только чтение"); }   // ломает всех вызывающих, которые используют List
}

Разделение интерфейсов

Ни один вызывающий не должен зависеть от методов, которыми не пользуется. Широкий интерфейс заставляет всех, кто его реализует, писать заглушки, а вызывающих — знать о посторонних операциях, поэтому изменение любого метода может сломать реализацию, которая его вовсе не трогала. Предпочитайте несколько маленьких интерфейсов, которые класс реализует вместе, и определяйте их с точки зрения вызывающего.

interface Reader { read(id): Record }
interface Writer { write(record): void }

Инверсия зависимостей

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

class OrderService {
  constructor(private payments: PaymentGateway) {}
}

Практика

Найдите вторую причину для изменения

Выберите в своём коде класс с более чем десятком методов. Выпишите отдельные причины, по которым кто-то мог бы попросить его изменить. Если аудиторий набралось больше одной, разделите класс по этой линии.

Готово, когда: у каждого получившегося класса есть одно предложение, описывающее, почему он изменится, и тесты по-прежнему проходят.

Подставьте зависимость в тесте

Возьмите сервис, который сам создаёт своего помощника, и измените его так, чтобы помощник приходил извне. Напишите тест, который подставляет подделку.

Готово, когда: тест выполняется без настоящего помощника, и ни одно место вызова в продакшене не изменило поведения.

Частые ошибки

  • Применение каждого правила к каждому классу — небольшие программы получают церемонии без пользы; исправить: применяйте правила там, где граница уже создаёт трение.
  • Чтение «открыт/закрыт» как «никогда не правь этот файл» — в результате появляется слой плагинов ради одной реализации; исправить: стремитесь к одному месту изменения на каждый новый случай.
  • Сокрытие сломанной иерархии за проверками типов — instanceof у вызывающих сигналит, что подстановка провалилась; исправить: переработайте абстракцию или смоделируйте варьирование как данные.
  • Разделение интерфейсов до трёх импортов в каждом месте вызова — доведённое до крайности разделение само становится связанностью; исправить: определяйте интерфейсы из нужд вызывающего, а не по алфавиту методов.
  • Внедрение зависимостей, которые никогда не будут меняться — параметр для чистой утилиты добавляет косвенность ни за что; исправить: инвертируйте только там, где правдоподобен тестовый двойник или вторая реализация.

Что читать дальше

  • Паттерны проектирования (01-cs-fundamentals/18-design-patterns) — именованные решения, которые применяют эти принципы к повторяющимся задачам, вместе с их ценой.
  • Чистый код (01-cs-fundamentals/08-clean-code) — привычки именования и размера, которые нужны проекту по SOLID, чтобы оставаться читаемым.
  • Архитектура программного обеспечения (01-cs-fundamentals/25-software-architecture) — те же принципы в масштабе модулей и сервисов, а не классов.