SOLID как вопрос о изменениях: рефакторинг сервиса Spring Boot
Научитесь применять каждый принцип SOLID в бэкенде Spring Boot, задаваясь вопросом о том, что может измениться, и как избежать чрезмерной сложности проектирования, к которой так часто приводит SOLID.
Источники: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me
Большинство разработчиков могут вспомнить, что означают пять букв SOLID, но лишь немногие могут объяснить, как эти принципы должны влиять на создание реального сервиса Spring Boot с репозиториями, поставщиками платежей и каналами уведомлений. Одних лишь определений недостаточно, когда менеджер по продукту просит уведомления через WhatsApp в пятницу днем. В этом руководстве рассматриваются каждый принцип на примере типичного кода бэкенда, показаны рефакторинги, которые он предлагает, и, что не менее важно, показано, когда механическое применение принципа ухудшает структуру кода.
Общим элементом всех пяти принципов является не «больше интерфейсов» и не «миниатюрные классы». Речь идет о том, чтобы сделать код простым в изменении, чтобы модификация одного аспекта поведения не влияла на всё остальное. Приведенные ниже примеры написаны на Java, но эти принципы применимы и к сервисам на TypeScript в Node.js; если вы предпочитаете эту среду, существует соответствующая статья на SOLID через практические примеры на JavaScript.
Проблема, которую пытается решить SOLID
Начнем с сервиса обработки заказов, который выполняет всё необходимое для оформления покупки в одном методе: он проверяет данные, сохраняет их, отправляет письма, формирует счета-фактуры, уведомляет пользователей и ведет аналитику.
public class OrderService {
public void createOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendEmail(order);
generateInvoice(order);
sendNotification(order);
updateAnalytics(order);
}
}
Здесь всё в порядке. Логика работает сверху вниз, такой код легко пройдёт проверку и может работать в производстве месяцами. Проблемы начинаются с последующих требований: один из заинтересованных сторон хочет, чтобы уведомления отправлялись через WhatsApp вместо электронной почты; другой — чтобы генерация счётов происходила в фоновом режиме. Команда по работе с данными объявляет о миграции базы данных, а отдел аналитики требует публикации данных в Kafka.
Каждое из этих запросов попадает в один и тот же класс. Код по-прежнему работает, но каждая мелкая изменение заставляет перечитывать, редактировать и повторно тестировать модуль, связанный с пятью другими аспектами. Именно такая взаимосвязь, при которой одна изменение влечёт за собой множество других, и предназначены принципы SOLID для контроля.
Единая ответственность: считайте причины для изменений
Принцип единственной ответственности гласит, что класс должен иметь одну ответственность, что на практике означает одну причину для изменений. Его часто неправильно интерпретируют как «один метод на класс». Такое понимание ошибочно: класс с двадцатью методами всё равно может иметь единственную ответственность, если все они служат одной цели.
Полезный вопрос заключается не в том, насколько большой класс, а в том, что может заставить вас его изменить. Если вы можете перечислить несколько нерелевантных причин, скорее всего, класс несёт несколько ответственностей.
Выявление смешанных ответственностей
Вот пример сервиса регистрации пользователей в стиле, который часто встречается в многих кодовых базах.
public class UserService {
public void registerUser(User user) {
validate(user);
saveToDatabase(user);
sendEmail(user);
generateReport(user);
}
}
Он выполняет четыре разных задачи: проверку пользователя, сохранение его данных, отправку электронных писем и формирование отчета. Это дает четыре независимых причины для изменений. Изменение поставщика электронной почты требует редактирования UserService. Переход на другую базу данных также требует редактирования UserService. Изменение формата отчетов снова влечет за собой необходимость редактирования UserService. Когда не связанные между собой команды постоянно изменяют один и тот же файл, это является признаком проблем в проектировании.
Делегирование задач сотрудникам
В процессе рефакторинга координация действий остается в UserService, а специализированная работа передается специальным сотрудникам, включаемым через конструктор.
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public void registerUser(User user) {
validate(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
Структура зависимостей, которая получается, легко представить:
UserService
|
+---- UserRepository
|
+---- EmailService
UserService теперь определяет, что происходит во время регистрации, в то время как UserRepository отвечает за сохранение данных, а EmailService — за их доставку. Валидация остается в сервисе, поскольку является частью правил регистрации, а отчетность полностью исключается из процесса регистрации.
Хорошей практикой является отказ от подсчета методов и вместо этого рассмотрение того, сколько разных факторов могут побудить вас изменить класс. Этот вопрос помогает выявить настоящую связность, а не лишь поверхностный размер кода.
Принцип «открытости/закрытости»: добавление функционала без изменения уже стабильного кода
Формальное формулирование о том, что программные объекты должны быть открыты для расширения, но закрыты для изменений, звучит абстрактно. На понятном языке: вы должны иметь возможность внедрять новый функционал, не изменяя постоянно работающий и протестированный код.
Расширяющиеся условия
Классическим примером является обработка платежей. Первая версия часто работает с строкой.
public class PaymentService {
public void pay(String type) {
if (type.equals("CARD")) {
// card payment
}
if (type.equals("UPI")) {
// UPI payment
}
if (type.equals("NET_BANKING")) {
// net banking
}
}
}
Это подходит для трех методов. Затем бизнес начинает требовать использования кошельков, PayPal, Apple Pay и банковских переводов. Каждое новое требование означает необходимость повторного создания класса PaymentService, расширения условий и повторной проверки всех существующих вариантов. Постепенно класс превращается в длинную цепочку операторов if/else.
Замена веток на полиморфизм
Подход «открыто/закрыто» предусматривает использование абстракции, описывающей, что должен делать каждый способ оплаты.
public interface PaymentProcessor {
void process(Payment payment);
}
Каждый способ оплаты становится отдельной реализацией. Обработчик карт:
public class CardPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// card payment
}
}
А обработчик UPI:
public class UpiPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// UPI payment
}
}
Добавление нового поставщика теперь означает создание нового класса вместо редактирования существующих. Процессоры, уже используемые в производстве, остаются нетронутыми, что сокращает зону влияния каждого изменения. В приложении Spring обычно регистрируют каждый процессор как bean и выбирают подходящий через словарь, ключом к которому является тип оплаты; таким образом даже логика выбора не требует создания новой ветви.
Когда интерфейс ничего не дает
Все это не означает, что каждому классу нужен интерфейс. Очень распространённым паттерном в проектах Spring является один-к-одному соответствие интерфейса и его единственной реализации:
public interface UserService {
void createUser();
}
с единственным классом, реализующим его:
public class UserServiceImpl implements UserService {
}
Если нет реальных шансов на вторую реализацию и замена одной ничего не дает, то такой интерфейс представляет собой лишь дополнительный файл для навигации. Современные библиотеки для мокинга позволяют имитировать конкретные классы, поэтому один только критерий тестируемости редко является убедительным аргументом. Принципы SOLID направлены на проектирование с учётом ожидаемых изменений, а не на автоматическое оборачивание каждого класса в абстракцию.
Принцип замены Лискова: соблюдайте контракт, а не только сигнатуру
Принцип замены Лискова гласит, что любой подтип должен можно использовать там, где ожидается его родительский тип, без нарушения корректности программы. Из пяти принципов он кажется наиболее академическим, поэтому полезно снова воспользоваться примером оплаты.
Абстракция остаётся прежней:
public interface PaymentProcessor {
void process(Payment payment);
}
Реализация карты выполняет то, что обещает интерфейс:
public class CardPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
// process payment
}
}
Пока всё в порядке. Но затем кто-то добавляет процессор для оплаты наличными, который на самом деле не может обрабатывать ничего онлайн:
public class CashPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
throw new UnsupportedOperationException();
}
}
Компилятор доволен, поскольку сигнатура метода совпадает. Однако поведение нарушает данное обещание. Любой вызывающий код, имеющий объект типа PaymentProcessor, разумно предполагает, что вызов метода process приведёт к обработке платежа. При такой реализации цикл, кажущийся безвредным из-за обработки всех процессоров, может внезапно вызвать ошибку во время выполнения, и вызывающие коды вынуждены добавлять проверки типа instanceof или блоки try/catch для защиты, что является явным признаком утечки абстракции.
Вопрос, который следует задать при написании реализации, заключается в том, может ли она действительно вести себя так, как обещает абстракция. Если честный ответ — нет, то обычно решение заключается в изменении абстракции, например путем отдельной моделирования офлайн-оплат или разделения функционала на более узкий интерфейс. Принцип Лискова касается корректности поведения, а не того, компилируется ли ключевое слово implements.
Разделение интерфейсов: предотвращаем зависимость клиентов от того, что им не нужно
Принцип разделения интерфейсов гласит, что ни один клиент не должен быть вынужден зависеть от методов, которые он не использует. Именно в больших, универсальных интерфейсах обычно проявляется это правило.
Интерфейс, подходящий всем
Рассмотрим интерфейс, объединяющий управление сотрудниками, расчёт заработной платы, утверждение отпусков и формирование отчётов:
public interface EmployeeOperations {
void createEmployee();
void updateEmployee();
void deleteEmployee();
void generateSalary();
void approveLeave();
void generateReport();
}
Компонент отчётности требует от него всего лишь одной операции:
generateReport();
Однако из-за зависимости от EmployeeOperations он связан с логикой создания, удаления, расчёта зарплаты и отпусков, которую он никогда не будет использовать.
Разделение в соответствии с потребностями потребителей
Лучшее решение — предоставление отдельных, сфокусированных интерфейсов. Один из них предназначен для управления записями сотрудников:
public interface EmployeeManagement {
void createEmployee();
void updateEmployee();
void deleteEmployee();
}
А другой — для формирования отчётов:
public interface EmployeeReporting {
void generateReport();
}
Теперь каждый потребитель зависит только от тех функций, которые он использует. Один класс может всё ещё реализовывать оба интерфейса; разделение касается того, что видят клиенты.
Почему это важно при росте системы
В небольшом приложении чрезмерно большой интерфейс представляет собой незначительное неудобство. В крупном приложении он превращается в сеть случайных связей, при которой изменение в одном компоненте влияет на множество не связанных между собой компонентов. Меньшие интерфейсы делают границы зависимостей более очевидными и понятными для анализа.
Ограничение здесь такое же, как и при принципе «Открыто/Закрыто». Не разделяйте интерфейсы на фрагменты просто для формального соблюдения правил. Делайте это только тогда, когда разным клиентам действительно нужны разные наборы функций.
Инверсия зависимостей: пусть бизнес-логика зависит от абстракций
Принцип инверсии зависимостей предусматривает, что как код бизнес-уровня, так и код инфраструктуры должны опираться на общие абстракции, вместо того чтобы слой политик ссылался на конкретные классы низкого уровня. В приложениях Spring Boot этот принцип, пожалуй, является самым важным из пяти.
Инфраструктура с жестко заданными связями
Здесь сервис обработки заказов создает собственный репозиторий:
public class OrderService {
private final MySqlOrderRepository repository;
public OrderService() {
this.repository = new MySqlOrderRepository();
}
}
Теперь бизнес-логика точно знает, какая технология базы данных хранит её данные. Чтобы заменить MySQL или использовать фиктивный репозиторий в тестах на единицы, необходимо изменять сам класс OrderService. Высокоуровневая политика связана с низкоуровневыми деталями.
Инверсия зависимостей
Сначала определите, что на самом деле нужно бизнес-логике, независимо от технологии хранения:
public interface OrderRepository {
void save(Order order);
}
Затем сделайте класс, специфичный для MySQL, одной из реализаций этого интерфейса:
@Repository
public class MySqlOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
// save order
}
}
Наконец, пусть сервис получает абстракцию через свой конструктор:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService больше не зависит от того, где будут храниться заказы. Реализацией может служить MySQL, PostgreSQL, MongoDB, вызов другого сервиса или виртуальный объект в памяти, используемый в тестах. Порядок взаимодействия изменился: инфраструктура соответствует интерфейсу, определенному бизнес-слоем, вместо того чтобы бизнес-слой приспосабливался к инфраструктуре.
Как внедрение зависимостей в Spring поддерживает эти принципы
Spring делает концепцию обратной инверсии зависимостей естественной, поскольку его контейнер сам обеспечивает связывание компонентов. Когда сервис указывает свои вспомогательные компоненты в конструкторе, как показано ниже, Spring находит объект типа OrderRepository, соответствующий этим требованиям, и передает его сервису.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
Этот класс никогда не создает собственные зависимости. Для сравнения рассмотрим прямое создание объектов внутри кода бизнес-логики:
OrderRepository repository =
new MySqlOrderRepository();
Эта единственная строка связывает вызывающий код с конкретным классом, что делает замену невозможной без редактирования кода. Инъекция конструкторов позволяет сохранять связи на уровне абстракций и делает зависимости видимыми в сигнатуре класса, именно поэтому её обычно предпочитают инъекции полей. Имейте в виду, что инъекция зависимостей — это механизм, а инверсия зависимостей — принцип проектирования: контейнер помогает вас связывать абстракции, но вы всё равно должны сами выбирать, какие абстракции определять.
SOLID не означает больше классов
Команды, только что ознакомившиеся с принципами SOLID, иногда заходят слишком далеко. Одна функция для пользователя превращается в десяток различных типов:
UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor
Если применить это ко всему кодовому базису, получится сорок классов, в то время как восемь из них позволили бы более четко выразить ту же самую структуру. Это перегрузка проекта, а не хорошая архитектура. Принципы существуют для уменьшения сложности; если их применение затрудняет понимание кода, скорее всего, оно происходит механически, а не в ответ на реальные изменения.
Начните с того, что скорее всего изменится
Вместо того чтобы спрашивать, где можно применить принципы SOLID, опытные инженеры бэкенда обычно начинают с другого вопроса: что в этом компоненте скорее всего изменится? Ответ напрямую соотносится с принципами:
- База данных может измениться: введите абстракцию репозитория.
- Поставщики платежей могут измениться: разместите их за абстракцией платежей.
Такой подход помогает сохранять принципы в их первоначальном виде. Добавляйте абстракции там, где существует реальная нестабильность, а стабильный код оставляйте простым.
Применение того же подхода к границам сервисов
Этот метод рассуждения применим не только к отдельным классам. Представьте себе один сервис, готовый к развертыванию и отвечающий за все эти области:
User
Payment
Notification
Reporting
Inventory
Shipping
Всё может работать без проблем, но пользователи, платежи, уведомления, отчёты, запасы и доставка меняются по разным причинам и часто в разное время, находясь в ведении разных команд. Логика принципа единственной ответственности поможет определить, где проходят естественные границы.
Это не означает, что каждая область функциональности требует собственного микросервиса. SOLID — это не требование к увеличению количества сервисов; это руководство по правильному разделению ответственностей и зависимостей, независимо от того, будут ли эти границы представлены в виде модулей, пакетов или сервисов.
Чек-лист для проверки компонентов бэкенда
Прежде чем считать крупный компонент бэкенда готовым, проверьте по одному вопросу к каждому принципу:
- Единственная ответственность: имеет ли этот класс несколько независимых причин для изменений?
Честные ответы на эти вопросы ценнее, чем умение декламировать их определения.
Основные выводы
- Цель всех пяти принципов одна и та же: сделать последующие изменения простыми и контролируемыми, чтобы замена базы данных, добавление платежного провайдера или изменение канала уведомлений казались обычной процедурой, а не рискованным действием.
- Читайте каждый принцип как вопрос. Большая служба заставляет задаться вопросом «Что здесь меняется?», интерфейс с десятью методами — «Кому нужны все они?», а вызов
new SomeImplementation()в бизнес-логике — «Должен ли этот класс об этом знать?». - Вносите абстракции там, где изменения вероятны, и избегайте их там, где это невозможно; интерфейс с одной постоянной реализацией или дюжина классов для одной функции увеличивают затраты без какой-либо защиты.
- Нарушения принципа Лискова обычно указывают на неправильную абстракцию, поэтому следует исправлять модель, а не пытаться решить проблему у тех, кто ею пользуется.
- Пишите код, который работает сегодня, и структурируйте его так, чтобы изменения завтра не были сложнее, чем сегодняшние.