SOLID: A Practical Refactoring Example

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) гласит: подклассы должны быть взаимозаменяемы с базовым классом, не нарушая его контракт — не только формально, но и с точки зрения поведения.

В нашем примере нарушений два:

  1. EmailNotifier усиливает предусловия — он выбрасывает IllegalArgumentException, если строка не соответствует внутренним требованиям (например, слишком длинная). При этом интерфейс INotifier никак не указывает на такие ограничения, даже на уровне документации. В результате клиент, использующий INotifier, не может быть уверен, что передача валидной строки не вызовет исключение и сбоя в программе. Чтобы обойти это, разработчик будет вынужден либо использовать instanceof, либо оборачивать вызовы в try/catch — то есть раскрывать абстракцию, которую этот интерфейс должен скрывать.
  2. 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 — не изолированная идея, а часть системы. Нарушение одного ведёт к падению остальных. Но и наоборот — исправление одного часто тянет за собой улучшения по всей цепочке.

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

Report Page