SOLID: A Practical Refactoring Example
Makar M.Введение
В завершение серии статей о принципах SOLID разберём практический кейс. На основе проекта solid-refactoring-example я покажу, как в реальном коде нарушаются все пять принципов — и что с этим делать.
Приложение из примера отправляет уведомления через email и webhook, но сделано так, что страдает не только от нарушений SOLID, но и от общей архитектурной запущенности. Отличная база для наглядного рефакторинга. Для упрощения восприятия, было принято решение все классы разместить в одном файле Main.java.
Разбор полётов
Для начала разберёмся, как именно нарушаются принципы SOLID в этом проекте.
Принцип единственной ответственности (SRP) гласит: у каждого модуля должна быть только одна причина для изменения. В нашем случае это правило нарушается уже на уровне класса NotificationSender. Он отвечает не только за отправку уведомлений, но и обладает слишком большими знаниями о имплементациях INotifier, таких как EmailNotifier и WebhookNotifier. Из-за этого, через него проходит несколько осей изменений, что на несколько шагов приближает его к такому антипаттерну как God Object.
Из-за этого любое изменение в логике конкретного канала доставки — например, в WebhookNotifier — может потребовать изменения и в NotificationSender. Хотя, казалось бы, какое отношение WebhookNotifier имеет к NotificationSender?
В итоге получается так называемая "операция дробовиком": чтобы внести одну правку в одном месте, нам приходится вносить много мелких изменений по всей кодовой базе. Это увеличивает риски и усложняет сопровождение.
Принцип открытости/закрытости (OCP) требует, чтобы модули были открыты для расширения, но закрыты для модификации. В NotificationSender это правило нарушено с головой.
Вместо того чтобы использовать полиморфизм, код вручную проверяет тип объекта через instanceof и выбирает поведение при помощи цепочек if / else if. Такой подход напрямую противоречит OCP: каждый раз, когда будет появляеться новая реализация INotifier, мы будем вынуждены лезть в NotificationSender и добавлять новый блок условий. Это делает модуль уязвимым к изменениям и мешает масштабируемости.
Проблема всё та же — NotificationSender слишком сильно знает о деталях реализаций. Он не просто использует интерфейс INotifier, а завязан на конкретные классы, что делает его негибким и хрупким.
Принцип подстановки Барбары Лисков (LSP) гласит: подклассы должны быть взаимозаменяемы с базовым классом, не нарушая его контракт — не только формально, но и с точки зрения поведения.
В нашем примере нарушений два:
EmailNotifierусиливает предусловия — он выбрасываетIllegalArgumentException, если строка не соответствует внутренним требованиям (например, слишком длинная). При этом интерфейсINotifierникак не указывает на такие ограничения, даже на уровне документации. В результате клиент, использующийINotifier, не может быть уверен, что передача валидной строки не вызовет исключение и сбоя в программе. Чтобы обойти это, разработчик будет вынужден либо использоватьinstanceof, либо оборачивать вызовы вtry/catch— то есть раскрывать абстракцию, которую этот интерфейс должен скрывать.WebhookNotifierнарушает контракт, бросаяUnsupportedOperationExceptionвconnect()иdisconnect(). ИнтерфейсINotifierподразумевает, что эти методы можно безопасно вызывать, но конкретная реализация заявляет, что такие операции не поддерживаются. Это создаёт ложные ожидания: клиент думает, что может безопасно вызвать метод, а вместо этого получает исключение. Опять-таки, это ведёт к множественнымif-проверкам и обработке ошибок там, где их быть не должно.
Обе ситуации — классические примеры нарушения LSP. Абстракция перестаёт быть надёжной, и использование полиморфизма становится опасным.
Принцип разделения интерфейса (ISP) гласит: интерфейсы не должны заставлять клиентов зависеть от того, что им не нужно. В нашем случае нарушение этого принципа хорошо видно на примере связки INotifier и WebhookNotifier.
INotifier — это толстый интерфейс по отношению к возможностям WebhookNotifier. Он включает методы connect() и disconnect(), которые актуальны, например, для EmailNotifier, но абсолютно избыточны для WebhookNotifier. Вместо того чтобы представлять общую абстракцию, INotifier содержит детали конкретных реализаций — и именно это делает его загрязнённым.
Такое проектирование возникает, когда интерфейс собирают под конкретные кейсы, а не под общее поведение. В результате WebhookNotifier вынужден реализовывать методы, которые ему не нужны. Типичная реакция — создать заглушки и выбрасывать UnsupportedOperationException, что автоматически нарушает принцип подстановки Лисков (LSP) и сбивает с толку клиентов интерфейса.
Итог — ненадёжная архитектура, которую приходится компенсировать try/catch-ами и проверками типов. Всё из-за того, что один интерфейс пытается быть универсальным, но вместо этого становится неудобным и небезопасным для большинства реализаций.
Принцип инверсии зависимостей (DIP) гласит: модули высокого уровня не должны зависеть от деталей низкоуровневой реализации — оба должны зависеть от абстракций.
В нашем случае NotificationSender напрямую нарушает этот принцип. Хотя формально он работает с интерфейсом INotifier, на практике происходит утечка абстракции: через instanceof он вынужден проверять конкретные типы реализаций и принимать решения на основе их внутренней логики. То есть вместо использования полиморфизма, мы вручную управляем поведением — и тем самым жестко связываем высокоуровневый модуль с низкоуровневыми деталями.
Это не только нарушает DIP, но и делает NotificationSender нестабильным: любое добавление нового канала доставки требует правки в его коде. Мы уже видели, как это связано с нарушением OCP — DIP, по сути, обеспечивает правильное направление зависимостей, позволяя OCP реализоваться на практике.
Как итог: вместо того чтобы строить систему на абстракциях, мы жёстко связываем модули, что делает архитектуру хрупкой и плохо масштабируемой. Количество исходящих зависимостей растёт, стабильность падает.
Рефакторинг
Я бы начал рефакторинг с устранения нарушений принципа подстановки Лисков (LSP). Первый шаг — вынести проверку длины строки из EmailNotifier в более низкоуровневый класс (т.к. в моём примере это выдуманное протоколозависимое ограничение), типизированно обозначив возможные исключения в контракте интерфейсов. Это устранит неявное усиление предусловий и приведёт поведение класса в соответствие с контрактом INotifier.
Следом — инкапсуляция логики подключения к email-серверу в отдельный компонент, например, SMTPConnection. Этот шаг позволяет не только очистить EmailNotifier от лишних деталей, но и сузить сам интерфейс INotifier, избавив его от методов, не универсальных для всех реализаций.
Упрощённый и сфокусированный INotifier автоматически решает проблему с WebhookNotifier, который теперь не обязан реализовывать неподдерживаемые методы — нарушение ISP исчезает.
Параллельно, NotificationSender больше не нуждается в проверках instanceof — поведение делегируется реализациям интерфейса. Это обеспечивает чистый полиморфизм, снимает зависимость от конкретных классов и восстанавливает соблюдение DIP и OCP: NotificationSender теперь работает с абстракцией, не зная ничего о деталях реализаций, и не требует изменений при добавлении новых каналов доставки.
Вдобавок, NotificationSender теперь отвечает только за отправку уведомлений — одна причина для изменения, одно направление ответственности. То есть мы также исправляем нарушение SRP.
В результате получаем архитектуру, где каждый класс делает только то, что должен, и не знает больше, чем нужно. Ниже — пример обновлённого кода:
package com.makar;
class SendingException extends Exception {
public SendingException(String message) {
super(message);
}
}
interface INotifier {
void send(String message) throws SendingException;
}
interface IEmailConnection {
void connect();
void send(String message) throws IllegalArgumentException;
void disconnect();
}
class SMTPConnection implements IEmailConnection {
public void connect() {
System.out.println("Connecting to email server...");
}
public void send(String message) throws IllegalArgumentException {
if (message.length() > 160) {
throw new IllegalArgumentException("Message too large");
}
System.out.println("Sending EMAIL: " + message);
}
public void disconnect() {
System.out.println("Disconnecting from email server...");
}
}
class EmailNotifier implements INotifier {
private final IEmailConnection connection;
public EmailNotifier(IEmailConnection connection) {
this.connection = connection;
}
public void send(String message) throws SendingException {
this.connection.connect();
try {
this.connection.send(message);
} catch (IllegalArgumentException e) {
throw new SendingException("Message not sent, because: " + e.getMessage());
} finally {
this.connection.disconnect();
}
}
}
class WebhookNotifier implements INotifier {
public void send(String message) {
System.out.println("Sending webhook with message: " + message);
}
}
class NotificationSender {
public void sendNotification(INotifier notifier, String message) throws SendingException {
notifier.send(message);
}
}
public class Main {
public static void main(String[] args) {
NotificationSender sender = new NotificationSender();
try {
sender.sendNotification(new EmailNotifier(new SMTPConnection()), "Welcome!");
sender.sendNotification(new WebhookNotifier(), "Webhook message here.");
sender.sendNotification(new EmailNotifier(new SMTPConnection()), "A".repeat(1001));
} catch (SendingException e) {
System.out.println(e.getMessage());
}
}
}
Output:
Connecting to email server... Sending EMAIL: Welcome! Disconnecting from email server... Sending webhook with message: Webhook message here. Connecting to email server... Disconnecting from email server... Message not sent, because: Message too large
Заключение
Рефакторинг под принципы SOLID — это не про «красивый код ради красоты». Это про устойчивую архитектуру, которую легко расширять, сопровождать и тестировать. На примере простого приложения для отправки уведомлений видно, как быстро небольшие нарушения могут превратиться в цепочку архитектурных проблем.
Каждый принцип SOLID — не изолированная идея, а часть системы. Нарушение одного ведёт к падению остальных. Но и наоборот — исправление одного часто тянет за собой улучшения по всей цепочке.
Правильные абстракции, минимальные зависимости, чистый полиморфизм и чёткое разделение ответственности — это не теория. Это основа, на которой держатся живые проекты. Надёжные, гибкие и предсказуемые.