Головна / Статті

SOLID як питання змін: рефакторинг сервісу Spring Boot

Навчіться застосовувати кожен принцип SOLID у бекенді Spring Boot, ставлячи запитання про те, що, ймовірно, зміниться, та як уникнути надмірної складності проектування, до якої часто призводить SOLID.

Видавець: REACTAPP.TOP12 хв читання2660 слів

Джерела: 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() у бізнес-логіці — „Чи потрібно цьому класу про це знати?“.
    • Вводьте абстракції там, де зміни є ймовірними, та утримуйтеся від них там, де це неможливо; інтерфейс із однією постійною реалізацією або дюжина класів для однієї функції збільшують витрати без жодного захисту.
    • Порушення принципів Ліскова зазвичай свідчать про неправильну абстракцію, тому краще виправити саму модель, ніж латкувати код тих, хто нею користується.
    • Пишіть код, який працює сьогодні, та структуруйте його так, щоб зміни завтра були не складнішими, ніж сьогоднішні.