📐 Розділ 18 · Питання #7

Що таке принцип Interface Segregation

Пульт керування побутовою технікою:


🟢 Junior Level

Принцип розділення інтерфейсів (Interface Segregation Principle, ISP) — це четвертий принцип SOLID, сформульований Робертом Мартіном:

«Клієнти не повинні залежати від методів, які вони не використовують».

Що це означає простими словами

  • Клієнт — це викликаючий код або клас-реалізація, що використовує певний інтерфейс.
  • Якщо ви створюєте велетенський («жирний») інтерфейс із десятками різнорідних методів, усі класи-реалізації змушені писати порожні заглушки або викидати винятки на методи, які їм абсолютно не потрібні.
  • Принцип ISP вимагає розбивати монолітні інтерфейси на невеликі, вузькоспеціалізовані контракти, щоб класи реалізовували тільки ті методи, які їм дійсно необхідні.

Життєва аналогія

Пульт керування побутовою технікою:

  • Уявіть універсальний пульт, на якому розміщено 200 кнопок: перемикання телеканалів, регулювання температури кондиціонера, запуск пральної машини та зліт квадрокоптера.
  • Щоб просто увімкнути світло, вам доводиться тримати в руках важкий пульт із сотнею зайвих кнопок.
  • Правильний підхід: мати окремий вимикач світла (Switchable), окремий пульт для телевізора (ChannelSwitchable) та термостат (TemperatureRegulated).

Приклад порушення ISP («Жирний» інтерфейс)

// Погано: інтерфейс змушує вміти все підряд
public interface SmartDevice {
    void printDocument(byte[] doc);
    void scanDocument();
    void sendFax(String number);
    void brewCoffee();
}

// Звичайний офісний принтер не вміє сканувати, відправляти факси та варити каву!
public class BasicOfficePrinter implements SmartDevice {
    @Override
    public void printDocument(byte[] doc) {
        System.out.println("Друкуємо документ...");
    }

    @Override
    public void scanDocument() {
        // Порушення ISP: метод-заглушка!
        throw new UnsupportedOperationException("Сканер відсутній!");
    }

    @Override
    public void sendFax(String number) {
        throw new UnsupportedOperationException("Факс не підтримується!");
    }

    @Override
    public void brewCoffee() {
        throw new UnsupportedOperationException("Принтер не варить каву!");
    }
}

Виправлення за ISP: Інтерфейси ролей (Role Interfaces)

Розбиваємо монолітний інтерфейс на сфокусовані ролі:

// Вузькі спеціалізовані інтерфейси
public interface Printer {
    void printDocument(byte[] doc);
}

public interface Scanner {
    void scanDocument();
}

public interface Fax {
    void sendFax(String number);
}

// Простий принтер реалізує тільки те, що дійсно вміє:
public class BasicOfficePrinter implements Printer {
    @Override
    public void printDocument(byte[] doc) {
        System.out.println("Друкуємо документ...");
    }
}

// Багатофункціональний пристрій (БФП) комбінує потрібні інтерфейси:
public class MultifunctionCopier implements Printer, Scanner, Fax {
    @Override
    public void printDocument(byte[] doc) { /* друк */ }
    @Override
    public void scanDocument() { /* сканування */ }
    @Override
    public void sendFax(String number) { /* факс */ }
}

Клієнтський код, якому потрібен лише друк, приймає параметр типу Printer і навіть не підозрює про існування факсів чи сканерів.


🟡 Middle Level

Role Interfaces проти Header Interfaces

Роберт Мартін та Мартін Фаулер виділяють дві діаметрально протилежні концепції проектування інтерфейсів:

Характеристика Header Interface (Антипатерн) Role Interface (Best Practice)
Ідея Інтерфейс механічно дублює всі публічні методи конкретного сервісу (UserService $\rightarrow$ IUserService). Інтерфейс моделює конкретну роль у сценарії взаємодії (UserLookup, PasswordResetter, Auditable).
Кому належить Належить пакету реалізації (постачальнику). Належить клієнту (споживачу контракту).
Кількість методів 10–50 методів (розростається разом із розвитком сервісу). 1–3 методи (сфокусований на єдиному завданні).
Еволюція Будь-яка зміна методу ламає всіх клієнтів і тестові моки. Зміни суворо ізольовані всередині вузьких контекстів.

ISP у стандартних бібліотеках Java та Spring Data

  1. Java Core API:
    • Інтерфейси стандартної бібліотеки — зразки ISP: Runnable (1 метод: run()), Callable (1 метод: call()), Comparable (1 метод: compareTo()), AutoCloseable (1 метод: close()), Readable, Appendable.
    • Потоки введення-виведення InputStream розділені за можливостями: DataInput, ObjectInput.
  2. Еволюція Spring Data Repositories:
    • У ранніх версіях фреймворків часто застосовувалися велетенські загальні інтерфейси.
    • Spring Data декомпозував роботу з даними на вузькі грані:
      • Repository<T, ID> — маркерний корінь.
      • CrudRepository<T, ID> — тільки базовий CRUD.
      • PagingAndSortingRepository<T, ID> — тільки пагінація та сортування.
      • QueryByExampleExecutor<T> — динамічний пошук за зразком.
    • Клієнтський сервіс інжектує саме ту грань репозиторію, яка йому потрібна.

Симптоми порушення ISP у проєкті

  1. UnsupportedOperationException / порожні методи (// do nothing): явна ознака того, що інтерфейс змусив клас узяти невластиву відповідальність.
  2. Роздуті моки в Unit-тестах: щоб протестувати метод, який викликає лише 1 функцію залежності, доводиться писати 15 рядків when(service.unrelatedMethod()).thenReturn(...).
  3. Часті merge-конфлікти в інтерфейсах: кілька незалежних команд одночасно редагують один і той самий інтерфейс загального призначення.

🔴 Senior Level

Історичний контекст Xerox та фізичний дизайн архітектури

Принцип ISP народився під час роботи Роберта Мартіна консультантом у компанії Xerox на початку 1990-х:

  • Нове ПЗ для багатофункціонального копіювального апарата складалося з сотень класів, зав’язаних на єдиний гігантський клас/інтерфейс Job.
  • Будь-яка зміна в коді степлювання чи підрахунку сторінок призводила до зміни заголовного файлу Job.h у C++.
  • Це викликало каскадну перекомпільованість сотень вихідних файлів, тривалу лінковку та багатогодинне тестування всієї системи, паралізуючи релізи.
  • Мартін застосував поділ інтерфейсів за клієнтами: клієнт друку залежить лише від PrintJob, клієнт мережевої передачі — від NetworkJob, а клас фізичного принтера реалізує обидва ці вузькі інтерфейси.
[PrintClient] ──> [PrintJob Interface]   \
                                          ===> [Job Implementation Class]
[BillingClient] ─> [BillingJob Interface] /

Вплив на бінарну сумісність JVM та завантаження класів

У віртуальній машині Java монолітні інтерфейси створюють приховані системні витрати:

  1. Binary Compatibility та Constant Pool:
    • Кожен метод, оголошений в інтерфейсі, додає записи CONSTANT_Methodref_info / CONSTANT_NameAndType_info в Constant Pool .class-файлів усіх його споживачів.
    • Якщо дві бібліотеки в classpath використовують різні версії широкого інтерфейсу, компіляція може пройти успішно, але в рантаймі виклик відсутнього методу призведе до фатального java.lang.NoSuchMethodError. Дроблення інтерфейсів кардинально знижує ризик конфлікту транзитивних залежностей (Jar Hell / Dependency Hell).
  2. Поліморфний диспатчинг (itable):
    • Виклики методів інтерфейсів транслюються в байткод invokeinterface.
    • Для кожного класу JVM будує таблицю інтерфейсних методів (itable).
    • Якщо клас реалізує кілька гігантських інтерфейсів, itable розростається, збільшуючи обсяг пам’яті в Metaspace та погіршуючи локальність даних у кеші процесора (L1/L2 data cache misses) під час пошуку по таблиці інтерфейсів.

Вирішення конфліктів ISP зі сторонніми SDK: Патерн Adapter

Якщо ви використовуєте сторонню бібліотеку (AWS SDK, Kafka Client, Spring Cloud) з монолітним інтерфейсом, який неможливо змінити:

// Сторонній "жирний" інтерфейс із зовнішньої бібліотеки
public interface HeavyCloudClient {
    void putObject(String bucket, String key, byte[] bytes);
    byte[] getObject(String bucket, String key);
    void deleteBucket(String bucket);
    void setBucketLifecyclePolicy(String bucket, Policy policy);
    void rotateAccessKeys();
}

// Наш доменний вузький контракт (Client-driven ISP)
public interface DocumentStorage {
    void store(String documentId, byte[] content);
}

// Адаптер ізолює ядро домену від роздутого SDK
@Component
public class S3DocumentStorageAdapter implements DocumentStorage {
    private final HeavyCloudClient s3Client;
    private final String bucketName;

    public S3DocumentStorageAdapter(HeavyCloudClient s3Client, @Value("${app.s3.bucket}") String bucketName) {
        this.s3Client = s3Client;
        this.bucketName = bucketName;
    }

    @Override
    public void store(String documentId, byte[] content) {
        s3Client.putObject(bucketName, documentId, content);
    }
}

4 Tricky Questions

1. Чи вирішують Default Methods (Java 8+) проблему порушення ISP?

Відповідь: Ні, default-методи розв’язують проблему зворотної сумісності під час еволюції бібліотечних інтерфейсів, але не усувають порушення ISP. Додавання default-методу звільняє класи-реалізації від необхідності писати порожні тіла, проте інтерфейс для клієнтського коду все одно залишається перевантаженим («жирним»). Клієнт бачить в автодоповненні IDE та публічному API десятки методів, які не стосуються його завдання, що провокує помилкові виклики та збільшує зв’язність системи.

2. Чи може розділення інтерфейсів призвести до антипатерну Interface Explosion (Вибух інтерфейсів)?

Відповідь: Так. Якщо дотримуватися ISP догматично і створювати інтерфейс під кожен окремий метод (SAM-інтерфейси для всього підряд), навігація по кодовій базі ускладнюється, кількість файлів подвоюється, а зв’язність абстракцій падає. Критерій балансу: інтерфейс має дробитися лише тоді, коли існують різні клієнти, які використовують неперетинні групи методів, або різні класи-реалізації, що не можуть надати валідну семантику для частини методів. Якщо методи семантично нерозривні (наприклад, Iterator.hasNext() та Iterator.next()), розділяти їх заборонено.

3. Як ISP співвідноситься з мікросервісною архітектурою та REST / gRPC API?

Відповідь: Принцип ISP безпосередньо проєктується на розподілені системи:

  • Монолітний REST API із сотнею ендпоінтів на всі випадки життя порушує ISP на рівні мережі.
  • Архітектурний патерн BFF (Backend for Frontend) — це реалізація ISP у мікросервісах: мобільний застосунок отримує свій вузький легковажний API (/mobile/orders), а Web-портал адміністратора — розширений (/backoffice/orders).
  • У gRPC та Protobuf поділ сервісів (service OrderReaderService, service OrderWriterService) дозволяє клієнтам підписуватися лише на потрібні RPC-контракти без завантаження важких сторонніх схем.

4. У чому різниця між ISP та SRP (Single Responsibility Principle)?

Відповідь:

  • SRP орієнтований на реалізацію та причини для зміни: «У класу має бути одна причина для зміни (один актор/бізнес-роль)». SRP відповідає на питання: «Що робить цей клас усередині?»
  • ISP орієнтований на споживача (клієнта): «Клієнт не повинен залежати від методів, які йому не потрібні». Клас може мати єдину відповідальність (наприклад, DatabaseManager), але якщо він виставляє назовні єдиний широкий інтерфейс для адміністратора, аналітика та звичайного користувача, він порушує ISP.

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

Головні тези:

  • Суть: Клієнти не повинні залежати від методів, які вони не використовують. Багато вузьких інтерфейсів краще за один «жирний».
  • Інтерфейс належить клієнту: Контракти визначаються потребами викликаючого коду, а не зручністю автора реалізації.
  • Головний симптом порушення: UnsupportedOperationException або порожні методи в класах-нащадках; надлишкові моки в юніт-тестах.
  • Патерн вирішення: Adapter для ізоляції сторонніх роздутих API; виділення Role Interfaces замість механічних Header Interfaces.

Чого уникати (Червоні прапорці):

  • ❌ Створювати інтерфейс-копію IClass для кожного класу з 30 методами й називати це якісною абстракцією.
  • ❌ Вважати, що default methods у Java 8+ виправляють порушення ISP.
  • ❌ Розділяти семантично нерозривні методи (наприклад, розривати hasNext() та next()).

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