I - Interface Segregation: маленькие интерфейсы лучше

ISP: лучше много маленьких интерфейсов, чем один огромный.

Если интерфейс заставляет тебя реализовать лишние методы - он плохой.

Плохой пример

type Storage interface {
  Get(key string) (string, error)
  Set(key, val string) error
  Delete(key string) error
  Connect() error
  Close() error
}
interface Storage {
  public function get(string $key): string;
  public function set(string $key, string $val): void;
  public function delete(string $key): void;
  public function connect(): void;
  public function close(): void;
}
/**
 * @typedef {object} Storage
 * @property {(key: string) => Promise<string>} get
 * @property {(key: string, val: string) => Promise<void>} set
 * @property {(key: string) => Promise<void>} delete
 * @property {() => Promise<void>} connect
 * @property {() => Promise<void>} close
 */

А ты хотел просто in-memory map... и внезапно должен писать connect()/close().

Лучше: разделить

type Reader interface { Get(key string) (string, error) }
type Writer interface { Set(key, val string) error }
type Deleter interface { Delete(key string) error }
interface Reader { public function get(string $key): string; }
interface Writer { public function set(string $key, string $val): void; }
interface Deleter { public function delete(string $key): void; }
/** @typedef {{ get: (key: string) => Promise<string> }} Reader */
/** @typedef {{ set: (key: string, val: string) => Promise<void> }} Writer */
/** @typedef {{ delete: (key: string) => Promise<void> }} Deleter */
Тесты проще: мокать маленький интерфейс легче. И зависимости становятся честными.

Эталон ISP в stdlib: io.Reader / io.Writer / io.Closer

В стандартной библиотеке Go ISP доведён до совершенства: ключевые операции с потоками - это три однометодных интерфейса:

type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }
<?php
declare(strict_types=1);

// PSR-7 разрезает HTTP-stream именно по ISP - каждая роль отдельно.
// Symfony HttpFoundation использует тот же приём.

interface Reader
{
    public function read(int $length): string;
}

interface Writer
{
    public function write(string $data): int;
}

interface Closer
{
    public function close(): void;
}
// В JS интерфейсов нет, но JSDoc-typedef работает как контракт.
/** @typedef {{ read: (length: number) => Promise<Buffer> }} Reader */
/** @typedef {{ write: (data: Buffer) => Promise<number> }} Writer */
/** @typedef {{ close: () => Promise<void> }} Closer */

Из этих кирпичиков собираются составные интерфейсы - но только когда это нужно:

type ReadWriter   interface { Reader; Writer }            // умеет и читать, и писать
type ReadCloser   interface { Reader; Closer }            // поток с владением
type WriteCloser  interface { Writer; Closer }
<?php
declare(strict_types=1);

// PHP поддерживает множественное наследование интерфейсов через extends -
// аналог Go embedding в интерфейсе.

interface ReadWriter  extends Reader, Writer {}   // умеет и читать, и писать
interface ReadCloser  extends Reader, Closer {}   // поток с владением
interface WriteCloser extends Writer, Closer {}
// Составные типы через intersection в JSDoc.
/** @typedef {Reader & Writer} ReadWriter   */ // умеет и читать, и писать
/** @typedef {Reader & Closer} ReadCloser   */ // поток с владением
/** @typedef {Writer & Closer} WriteCloser  */

Функция io.Copy(dst Writer, src Reader) принимает минимально-достаточные интерфейсы: ей не нужен Close, поэтому она его и не требует. Это значит, что в io.Copy можно подставить файл, сетевое соединение, gzip-поток, in-memory bytes.Buffer - что угодно, что умеет читать и писать. Сила ISP в том, что клиент не зависит от методов, которыми не пользуется.

Go-идиома: «объявляй интерфейс на стороне потребителя»

В Go интерфейсы реализуются неявно. Это даёт мощную идиому: интерфейс описывается там, где его потребляют, а не там, где реализуют. По умолчанию интерфейсы получаются маленькими - потребитель просит ровно ту операцию, которая ему нужна:

// Плохо: пакет storage экспортирует «толстый» Storage,
// и каждый его потребитель тянет лишнее
package storage
type Storage interface { Get(...); Set(...); Delete(...); Connect(...); Close(...) }

// Хорошо: каждый потребитель объявляет свой узкий интерфейс
package billing

type orderReader interface {
    Get(ctx context.Context, id string) (Order, error)
}

func ChargeOrder(r orderReader, id string) error { /* ... */ }
<?php
declare(strict_types=1);

// В PHP интерфейсы реализуются явно (implements), но идиома «объявляй
// интерфейс на стороне потребителя» работает: ролевой интерфейс
// объявляется в namespace потребителя, а сторонний класс (Symfony service)
// заявляет реализацию через implements или DI-конфигурацию.

// Плохо: пакет Storage экспортирует «толстый» Storage,
// и каждый потребитель тянет лишнее
namespace App\Storage;

interface Storage
{
    public function get(string $key): mixed;
    public function set(string $key, mixed $value): void;
    public function delete(string $key): void;
    public function connect(): void;
    public function close(): void;
}

// Хорошо: потребитель объявляет свой узкий интерфейс рядом с use case
namespace App\Billing;

interface OrderReader
{
    public function get(string $id): Order;
}

final class ChargeOrder
{
    public function __construct(private readonly OrderReader $orders) {}

    public function execute(string $id): void { /* ... */ }
}
// В JS то же самое через JSDoc - потребитель описывает форму, которая ему нужна.

// Плохо: модуль storage экспортирует «толстый» Storage
/** @typedef {{ get, set, delete, connect, close }} Storage */

// Хорошо: потребитель объявляет узкий контракт рядом с use case
/** @typedef {{ get: (id: string) => Promise<Order> }} OrderReader */

class ChargeOrder {
  #orders;
  /** @param {OrderReader} orders */
  constructor(orders) { this.#orders = orders; }

  async execute(id) { /* ... */ }
}

ChargeOrder теперь зависит не от «всего хранилища», а только от способности достать заказ. Реализация подставится через DI. Это ISP по построению.

Fat interface vs role interface

Dave Cheney противопоставляет два стиля:

  • Fat interface - «тип со всеми возможными способностями» (Storage с 10 методами). Описывает реализацию.
  • Role interface - «способность, нужная конкретному коду» (orderReader с одним методом). Описывает требование.

Хорошая ISP-идиома: role interface объявляет потребитель, fat interface не нужен почти никогда. Если ты пишешь интерфейс с 7+ методами - почти наверняка ты описываешь реализацию, а не роль.

Антипаттерн: интерфейс на 20 методов в service.go

В монолитах часто встречается такая схема:

// internal/service/service.go - единый «контракт сервиса»
type Service interface {
    GetUser(...) (*User, error)
    CreateUser(...) error
    UpdateUser(...) error
    DeleteUser(...) error
    ListOrders(...) ([]Order, error)
    PlaceOrder(...) error
    RefundOrder(...) error
    GetReport(...) (*Report, error)
    SendNewsletter(...) error
    // ... и так ещё 12 методов
}
<?php
declare(strict_types=1);

// src/Service/ServiceInterface.php - единый «контракт сервиса»
namespace App\Service;

interface ServiceInterface
{
    public function getUser(int $id): User;
    public function createUser(array $data): void;
    public function updateUser(int $id, array $patch): void;
    public function deleteUser(int $id): void;

    /** @return Order[] */
    public function listOrders(int $userId): array;
    public function placeOrder(int $userId, array $items): void;
    public function refundOrder(int $orderId): void;

    public function getReport(string $period): Report;
    public function sendNewsletter(string $subject, string $body): void;
    // ... и так ещё 12 методов
}
// src/service/Service.js - единый «контракт сервиса»
/**
 * @typedef {object} Service
 * @property {(id: number) => Promise<User>} getUser
 * @property {(data: object) => Promise<void>} createUser
 * @property {(id: number, patch: object) => Promise<void>} updateUser
 * @property {(id: number) => Promise<void>} deleteUser
 * @property {(userId: number) => Promise<Order[]>} listOrders
 * @property {(userId: number, items: object[]) => Promise<void>} placeOrder
 * @property {(orderId: number) => Promise<void>} refundOrder
 * @property {(period: string) => Promise<Report>} getReport
 * @property {(subject: string, body: string) => Promise<void>} sendNewsletter
 *  ... и так ещё 12 методов
 */

Любому клиенту, которому нужна одна операция, придётся писать мок на все 20. Любому HTTP-хендлеру - принимать на вход «всё хранилище». Любая правка одной из веток методов меняет один большой контракт, на который завязано всё.

ISP-рефакторинг: разнести по предметным областям и оставить интерфейсы рядом с потребителями. Получится 4-5 ролевых интерфейсов на 1-3 метода каждый, и вся кодовая база сразу станет легче для чтения и тестирования.

Связь с SRP

ISP - это SRP, применённый к интерфейсам. SRP говорит: «один модуль - одна причина меняться». ISP уточняет: «один интерфейс - один тип потребителя». Если одни клиенты интерфейса хотят правок, а другие - нет, значит интерфейс совмещает роли и должен быть разрезан.

Когда ISP вредит

Не каждое разделение полезно. Признаки over-engineering:

  • Интерфейс с одним методом, который никогда не используется отдельно (UserGetter, UserSaver, UserDeleter - а в проекте всегда нужны все три)
  • Интерфейс, у которого только одна реализация и не предвидится тестового мока
  • Дробление ради дробления, без пары связанных операций

ISP - это инструмент устранения реальной избыточной зависимости, а не способ доказать аккуратность через мелкие файлы.

Композиция интерфейсов через embedding

После того, как ты разрезал интерфейс на роли, иногда нужен «составной» контракт. Go даёт это через embedding в интерфейсе:

// Базовые роли
type Reader  interface { Read(p []byte) (int, error) }
type Writer  interface { Write(p []byte) (int, error) }
type Closer  interface { Close() error }

// Составные - собираются из базовых, не дублируют методы
type ReadWriter   interface { Reader; Writer }
type ReadCloser   interface { Reader; Closer }
type WriteCloser  interface { Writer; Closer }
type ReadWriteCloser interface { Reader; Writer; Closer }
<?php
declare(strict_types=1);

// PHP даёт ту же сборку через interface extends (множественное наследование интерфейсов).

// Базовые роли
interface Reader { public function read(int $length): string; }
interface Writer { public function write(string $data): int; }
interface Closer { public function close(): void; }

// Составные - собираются из базовых, методы наследуются автоматически
interface ReadWriter      extends Reader, Writer {}
interface ReadCloser      extends Reader, Closer {}
interface WriteCloser     extends Writer, Closer {}
interface ReadWriteCloser extends Reader, Writer, Closer {}
// JSDoc intersection делает то же самое - сложение маленьких контрактов.

// Базовые роли
/** @typedef {{ read: (length: number) => Promise<Buffer> }} Reader */
/** @typedef {{ write: (data: Buffer) => Promise<number> }} Writer */
/** @typedef {{ close: () => Promise<void> }} Closer */

// Составные - собираются из базовых через intersection (&)
/** @typedef {Reader & Writer}          ReadWriter      */
/** @typedef {Reader & Closer}          ReadCloser      */
/** @typedef {Writer & Closer}          WriteCloser     */
/** @typedef {Reader & Writer & Closer} ReadWriteCloser */

Это правильный путь к большим интерфейсам: они получаются через сложение маленьких, а не через декларацию «толстого» с самого начала. Любой клиент может зависеть от минимально нужной комбинации, и любой тип, удовлетворяющий частям, автоматически удовлетворяет целому.

Мини‑задание

  • Найди «толстый» интерфейс и разрежь на 2-3 маленьких
  • В одном из своих сервисов перенеси объявление интерфейса на сторону потребителя - стало ли яснее, что именно ему нужно?
  • Прогуляйся по io/net/bufio в stdlib и посмотри, как маленькие интерфейсы складываются в большие через embedding

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.