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