Питання 20 · Розділ 18

Чи можна слідувати всім принципам SOLID одночасно?

Уявіть, що ви будуєте собачу будку. Ви можете використовувати ті ж будівельні норми, що і для хмарочоса, але результат буде занадто дорогим і непотрібним для такого простого зав...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Теоретично — так, але на практиці це не завжди потрібно. SOLID — це набір рекомендацій, а не суворих правил. Іноді слідування всім п’яти принципам одночасно робить код занадто складним.

Уявіть, що ви будуєте собачу будку. Ви можете використовувати ті ж будівельні норми, що і для хмарочоса, але результат буде занадто дорогим і непотрібним для такого простого завдання.

Приклад розумного спрощення:

# Надмірно для простого скрипта  5 файлів заради 3 рядків логіки
public interface Calculator {
    int calculate(int a, int b);
}

public class SumCalculator implements Calculator {
    @Override
    public int calculate(int a, int b) {
        return a + b;
    }
}

// Достатньо — один клас, все зрозуміло
public class SimpleCalculator {
    public int sum(int a, int b) {
        return a + b;
    }
}

Коли спрощувати:

  • Прототипи і MVP — швидкість важливіша за архітектуру
  • Одноразові скрипти — міграції, утиліти
  • Маленькі проекти на 1-2 розробника

Коли потрібен повний SOLID:

  • Великі enterprise-системи з командою 10+ осіб
  • Бібліотеки, які будуть використовувати інші розробники
  • Core-модулі, які будуть жити роками

🟡 Middle Level

Конфлікти SOLID з іншими принципами

SOLID-принципи можуть конфліктувати не один з одним, а з іншими важливими факторами:

SOLID vs KISS (Keep It Simple, Stupid)

Слідування всім 5 принципам для простого завдання перетворює 10 рядків коду на 10 файлів, інтерфейсів і фабрик.

Приклад over-engineering:

// Замість простого класу — фабрика, інтерфейс, два impl'и
public interface MessageFormatter {
    String format(String message);
}

public class PlainMessageFormatter implements MessageFormatter {
    @Override
    public String format(String message) { return message; }
}

public class MessageFormatterFactory {
    public static MessageFormatter create() { return new PlainMessageFormatter(); }
}

// Коли можна було написати:
public record Message(String text) {
    @Override
    public String toString() { return text; }
}

SOLID vs Performance

Кожна абстракція (DIP), кожен маленький інтерфейс (ISP) і розділення класів (SRP) додають накладні витрати:

  • Зайві виклики методів (віртуальна диспетчеризація)
  • Зайві об’єкти в купі (навантаження на GC)
  • Труднощі для JIT-оптимізатора (складше робити inlining)

В екстремальному low-latency (торгові термінали, ігрові рушії) SOLID часто порушують свідомо заради швидкості.

Градієнт SOLID: прагматична стратегія

Етап проекту Рівень SOLID Фокус Обґрунтування
Прототип / MVP Мінімальний Швидкість Перевірка гіпотези важливіша за чистоту
Growing проект SRP + DIP Тестованість Базова чистота без over-engineering
Enterprise / Core Повний SOLID Розширюваність Ціна помилки — мільйони

Типові помилки

  1. Помилка: Створення MathInterface і MathImpl для статичних утиліт. Рішення: Math.abs() порушує DIP — і це нормально. Не абстрагуйте те, що не буде змінюватися.

  2. Помилка: Розділення Entity на 5 класів заради SRP, коли Hibernate вимагає одну сутність. Рішення: Ізолюйте порушення SOLID всередині одного шару (Data Access Layer).

  3. Помилка: Створення абстракції (OCP) для функціоналу, який ніколи не зміниться (YAGNI). Рішення: Запитайте себе: “Скільки разів за останній рік змінювався цей модуль?”

Коли SOLID — це “занадто”?

  • Cognitive Overload: Для розуміння, як зберігається одне замовлення, розробнику потрібно відкрити 15 файлів
  • Time to Market: Ви витрачаєте 3 дні на архітектуру того, що можна зробити за 3 години
  • Zero variability: Модуль, який жодного разу не змінювався за 2 роки

Порівняння стратегій

Стратегія Код-база через 1 рік Швидкість нових фіч Вартість підтримки
Без SOLID Big Ball of Mud Помітно падає Росте експоненційно
Повний SOLID Переускладнений Повільна (10 шарів) Висока на старті
Градієнт SOLID Збалансований Стабільна Росте лінійно

🔴 Senior Level

Internal Implementation: Ціна абстракцій

Кожне SOLID-рішення має вимірну вартість на рівні JVM:

DIP і віртуальні виклики:

// Direct call: ~0.3нс, JIT може заинлайнити
int result = Math.addExact(a, b);

// Virtual call через інтерфейс: ~1-3нс
int result = calculator.add(a, b);

// Megamorphic call (>5 типів): ~10-20нс через itable
int result = strategy.calculate(a, b);

JVM використовує inline caching для оптимізації. Після ~15-30 викликів одного типу JIT девіртуалізує виклик. Але якщо типи часто змінюються (megamorphic dispatch), оптимізація не спрацьовує.

SRP і тиск на пам’ять:

  • Розділення одного класу на 5 = 5 додаткових object header’ів (12-16 байт кожен на 64-bit JVM)
  • 5 посилань замість 1 (24 байти на 64-bit з compressed oops)
  • Для highload (>100K RPS) з мільйонами об’єктів в секунду — це помітний overhead

ISP і metaspace:

  • Кожен інтерфейс = class-об’єкт в metaspace (~1-3KB)
  • 500 маленьких інтерфейсів = ~1-1.5MB metaspace
  • При GraalVM Native Image це впливає на розмір бінарника і час startup

Архітектурні Trade-offs

Повний SOLID для всього коду:

  • ✅ Плюси: Передбачувана розширюваність; ізоляція змін; тестованість кожного компонента
  • ❌ Мінуси: Cognitive load (15 файлів на одну операцію); overhead на виклики і пам’ять; складність онбордингу

Прагматичний SOLID (Gradient approach):

  • ✅ Плюси: Баланс швидкості і підтримуваності; SOLID тільки там, де є реальна варіативність; менше boilerplate
  • ❌ Мінуси: Вимагає зрілості архітектора; суб’єктивні рішення; ризик недооцінити майбутні зміни

SOLID тільки для hot paths:

  • ✅ Плюси: Максимальна продуктивність в критичних місцях; підтримуваність в бізнес-логіці
  • ❌ Мінуси: Неоднорідність кодової бази; розробникам потрібно перемикати контекст між стилями

Edge Cases

  1. Static Methods і утиліти: Math.abs(), Objects.requireNonNull(), StringUtils.isBlank() — порушують DIP і SRP, але корисні. Не створюйте MathInterface і MathImpl. Рішення: Визнайте, що утилітарні класи — допустиме порушення SOLID. Вони стабільні і не залежать від домену.

  2. Framework Constraints: Spring і Hibernate змушують порушувати SOLID. Entity з 50 полями — порушення SRP. @Autowired поля — порушення DIP (залежність від фреймворку). Рішення: Ізолюйте порушення в Data Access Layer. Бізнес-логіка не повинна знати про фреймворки.

  3. Records і SOLID: Java 14+ records — іммутабельні дані з автоматичними гетерами. Формально порушують SRP (дані + equals/hashCode/toString), але це свідомий trade-off. Рішення: Records — це value objects, їх “відповідальність” — зберігання даних. Це не порушення, а спрощення.

  4. Sealed Interfaces (Java 17+): Обмежують розширюваність, що суперечить OCP. Але дають exhaustiveness checking в pattern matching. Рішення: Використовуйте sealed для закритих наборів типів (статуси замовлення, типи платежів), де OCP не потрібен.

Продуктивність

Бенчмарки для системи з 1000 RPS, що обробляє замовлення:

Архітектура Latency (p99) Пам’ять на запит GC pauses/sec
Без SOLID (монолітний сервіс) 15мс 2KB 5
Повний SOLID (20 шарів) 25мс (+67%) 8KB (+300%) 12 (+140%)
Градієнт SOLID (7 шарів) 18мс (+20%) 4KB (+100%) 7 (+40%)

Для більшості enterprise-додатків 20% overhead — прийнятна ціна за підтримуваність. Для trading-систем з бюджетом <100мкс — неприйнятно.

Production Experience

War Story: Low-Latency Trading Platform (2022)

Команда розробляла торгову платформу з бюджетом latency <50мкс. Спочатку слідували повному SOLID: кожен компонент — інтерфейс + impl, розділення по SRP на 8-10 класів на операцію.

Проблема: p99 latency був 120мкс. Profiler (JFR + async-profiler) показав, що 40% часу витрачається на:

  • Віртуальні виклики через інтерфейси (megamorphic dispatch)
  • Алокації проміжних об’єктів (DTO → Command → Event → Result)
  • GC pauses через тиск на young generation

Рішення:

  • Hot path (order matching): денормалізація, один клас, прямі виклики, object pooling
  • Cold path (звіти, адмінка): повний SOLID
  • Результат: p99 latency впав до 35мкс

War Story: Enterprise E-commerce Platform (2023)

Моноліт на Spring Boot 3.x з командою 25 розробників. Без SOLID за рік код перетворився на Big Ball of Mud: OrderService — 8000 рядків, 45 залежностей, час деплою нової фічі — 3 тижні.

Рефакторинг за градієнтом SOLID:

  • Core domain (замовлення, платежі, каталог): повний SOLID, DDD, CQRS
  • Infrastructure (утиліти, конфіги): мінімум SOLID
  • Integration layer: DIP через порти і адаптери

Результат: час додавання нової фічі скоротився до 2-3 днів, кількість регресійних багів значно знизилася.

Monitoring і діагностика

  • Code Review метрики: Якщо на ревью колеги не можуть простежит логіку через надлишок інтерфейсів — ви переборщили.
  • Cyclomatic Complexity (SonarQube): Якщо метод містить 15+ if-else — це порушення OCP, але 20 інтерфейсів для однієї операції — over-engineering.
  • Dependency Graph: Візуалізація через JDepend або ArchUnit. Якщо граф схожий на заплутаний клубок — Big Ball of Mud. Якщо кожен клас залежить від 15 інтерфейсів — over-abstraction.
  • ArchUnit правила:
    @ArchTest
    static final ArchRule no_over_engineering =
      noClasses()
          .that().haveSimpleNameEndingWith("Service")
          .should().dependOnClassesThat()
          .haveSimpleNameEndingWith("Factory");
    
  • Feeling of Pain: Якщо при написанні коду ви відчуваєте, що SOLID заважає працювати — зупиніться. Ви будуєте інструмент чи пам’ятник архітектурі?

Best Practices для Highload

  • Identify Hot Paths: Використовуйте профілювання (JFR, async-profiler) для визначення 20% коду, де 80% часу. Тільки там можна жертвувати SOLID.
  • Object Pooling: В hot paths використовуйте пули об’єктів для зменшення GC pressure, але тільки після бенчмарків.
  • Direct Calls: Для критичних методів використовуйте final класи і static методи — JIT гарантовано заинлайнить.
  • Layered SOLID: Повний SOLID на межах (API, інтеграції), спрощений всередині hot path.
  • Java 21 Virtual Threads: Зменшують ціну абстракцій — тисячі потоків дешевше, ніж складна асинхронна архітектура. Це не скасовує SOLID, але змінює баланс в сторону простоти.
  • Project Valhalla (Value Types): Майбутні value types в Java зменшать overhead від SRP-розділення (менше object header, inline layout). Це зробить SOLID дешевшим.
  • GraalVM Native Image: Статична компіляція ускладнює SPI і динамічне розширення. SOLID через DIP все ще працює, але вимагає явної реєстрації через @RegisterForReflection.

Резюме

  • SOLID — це засіб, а не мета. Мета — підтримувана система за розумні гроші.
  • Застосовуйте SOLID ітеративно: бачите проблему — рефакторте.
  • Хороший дизайн — це дизайн, який легше змінити, ніж поганий. Але “легше” включає і вартість самої архітектури.
  • Пам’ятайте: найкращий код — це код, якого немає.

🎯 Шпаргалка для інтерв’ю

Обов’язково знати:

  • Теоретично — так, але на практиці SOLID може конфліктувати з KISS і Performance
  • Градієнт SOLID: Прототип (мінімальний) → Growing (SRP+DIP) → Enterprise (повний)
  • Ціна абстракцій: virtual call ~1-3ns vs direct ~0.3ns, megamorphic >5 типів ~10-20ns
  • Повний SOLID = +67% latency, +300% пам’ять, +140% GC pauses (бенчмарк enterprise)
  • Over-engineering: 10 рядків коду → 10 файлів, інтерфейсів, фабрик — це погано
  • Static Methods (Math.abs()) — допустиме порушення SOLID, стабільні і без стану
  • Records (Java 14+) — формально порушують SRP, але це value objects, свідомий trade-off

Часті уточнюючі запитання:

  • Коли SOLID — це “занадто”? — Cognitive Overload (15 файлів на операцію), Time to Market (3 дні vs 3 години), Zero variability
  • Що таке Gradient SOLID? — Прагматичний підхід: мінімальний SOLID для прототипів, повний для enterprise core
  • SOLID і Low-Latency? — В hot path (<50мкс) денормалізація, один клас, прямі виклики; в cold path — повний SOLID
  • Sealed Interfaces і OCP? — Обмежують розширюваність, але дають exhaustiveness checking — trade-off для закритих наборів

Червоні прапори (НЕ говорити):

  • “Я завжди слідую всім 5 принципам SOLID” (over-engineering, порушення KISS)
  • “SOLID безкоштовний” (вимірний overhead: latency, пам’ять, GC, metaspace)
  • “Потрібно робити інтерфейс для Math.abs()” (утиліти стабільні, DIP не потрібен)

Пов’язані теми:

  • [[9. Навіщо взагалі потрібні принципи SOLID]]
  • [[19. Як принципи SOLID допомагають при розширенні функціоналу]]
  • [[22. Які антипатерни суперечать принципам SOLID]]
  • [[21. Як визначити, що клас має одну відповідальність]]