open atlas
↑ К треку
Паттерны и качество кода CP · 06 · 01

Разделение интерфейса

Толстый интерфейс — магнит связанности: правка одного клиента вынуждает перекомпиляцию и перетест у всех остальных. Разделяй на ролевые интерфейсы, названные по тому, как клиент использует тип, а не по методам — иначе лекарство станет роем односложного шума.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

У тебя есть IUserRepository с восемнадцатью методами — CRUD, поиск, массовый импорт, экспорт аудит-трейла, удаление по GDPR, прогрев кэша. Экрану отчётов нужны ровно два из них: findById и search. Но тестовый дубль отчёта обязан застабить все восемнадцать, модуль отчёта перекомпилируется всякий раз, когда кто-то добавляет метод массового импорта, который он никогда не вызовет, а ревьюер, читающий отчёт, не может понять, от каких возможностей тот реально зависит. Отчёт связан с семнадцатью методами, которых он никогда не касается.

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

Цель

После этого урока ты сможешь распознавать толстый интерфейс как магнит связанности; объяснить, почему «зависит от метода» значит зависит, даже если ты никогда его не вызываешь; разбивать толстый контракт на ролевые интерфейсы, названные по тому, как клиент использует тип; и избегать обратной ошибки — измельчения контракта в рой односложных интерфейсов, которые добавляют шум, не покупая развязки.

1

Принцип разделения интерфейса: ни один клиент не должен быть вынужден зависеть от методов, которые он не использует. «Зависеть от» — несущая формулировка. Клиент зависит от каждого метода в интерфейсе, который он именует, — не только от тех, что вызывает, — потому что компилятор, проверка типов, мок-фреймворк и следующий читатель видят всю поверхность. Модуль отчётов, типизированный против восемнадцатиметодного IUserRepository, для всех практических целей привязан ко всем восемнадцати.

// One fat contract every consumer is forced to name in full.
interface IUserRepository {
  findById(id: string): Promise<User | null>;
  search(q: Query): Promise<User[]>;
  create(u: NewUser): Promise<User>;
  update(id: string, patch: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
  bulkImport(rows: CsvRow[]): Promise<ImportReport>;
  exportAuditTrail(id: string): Promise<AuditEvent[]>;
  eraseForGdpr(id: string): Promise<void>;
  warmCache(): Promise<void>;
  // …nine more
}

Отчёт использует два из них. ISP говорит: он не должен даже знать, что остальные шестнадцать существуют.

2

Толстый интерфейс связывает несвязанных клиентов через тип, а не через их поведение. Экран отчётов и конвейер импорта не делят ни логики, ни потока данных, ни требования. Но раз они именуют один интерфейс, правка, которую требует один, прилетает к другому. Добавь bulkImportStreaming(stream) для импортёра — и мок отчёта больше не удовлетворяет интерфейс; его файл перекомпилируется; CI перезапускает его тесты; ревьюер обязан заново его одобрить. Ничто из того, что отчёт делает, не изменилось — изменилась лишь форма, от которой его заставили зависеть.

// The importer's new requirement…
interface IUserRepository {
  // …
  bulkImportStreaming(stream: ReadableStream): Promise<ImportReport>; // NEW
}

// …breaks this, which never imports anything:
class FakeRepoForReportTests implements IUserRepository {
  // ❌ now missing bulkImportStreaming — report tests fail to compile
}

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

3

Разбивай на ролевые интерфейсы, названные по тому, как клиент использует тип. Не называй интерфейсы по реализации (IUserRepository); называй их по роли, которая нужна клиенту, — по срезу возможностей, который данный потребитель разыгрывает против типа. Отчёту нужно искать пользователей, поэтому он зависит от UserLookup. Импортёру нужно записывать их массово, поэтому он зависит от UserImporter. Конкретный репозиторий по-прежнему реализует всё; клиенты просто перестают видеть дальше собственной роли.

interface UserLookup {
  findById(id: string): Promise<User | null>;
  search(q: Query): Promise<User[]>;
}

interface UserWriter {
  create(u: NewUser): Promise<User>;
  update(id: string, patch: Partial<User>): Promise<User>;
  delete(id: string): Promise<void>;
}

interface UserImporter {
  bulkImport(rows: CsvRow[]): Promise<ImportReport>;
}

// One class can satisfy several roles at once.
class UserRepository implements UserLookup, UserWriter, UserImporter { /* … */ }

Теперь отчёт зависит от UserLookup — два метода, ровно тот срез, который он использует. Импортёр может растить UserImporter сколько угодно, а отчёт ни перекомпилируется, ни перетестируется.

4

Типизируй клиент против узкой роли — и развязка станет настоящей. Разделение интерфейса окупается, только если потребители действительно именуют узкий тип. Отчёт, который всё ещё принимает IUserRepository «для удобства», снова зависит от всего. Дисциплина такова: сигнатура каждого потребителя объявляет ровно ту роль, которую он разыгрывает.

// Before: bound to the whole surface, coupled to 16 unused methods.
function buildReport(repo: IUserRepository) { /* uses 2 */ }

// After: the signature is the dependency declaration.
function buildReport(repo: UserLookup) {
  return repo.search({ active: true });
}

Отдача конкретна и senior-уровня: тестовый дубль для buildReport стабит два метода, а не восемнадцать; сигнатура честно документирует зависимость; а изменение ролей записи/импорта/GDPR не может дотянуться до этой функции, потому что система типов больше их не соединяет. Разделение позволяет каждому клиенту зависеть только от нужного ему среза.

Разбор примера

Посмотри, как один клиент сбрасывает шестнадцать зависимостей. Сервис уведомлений шлёт письмо «твой аккаунт готов». Ему нужно прочитать одного пользователя. Типизированный против толстого репозитория:

// before
class WelcomeMailer {
  constructor(private repo: IUserRepository) {}
  async send(id: string) {
    const user = await this.repo.findById(id);
    if (user) await this.email(user);
  }
}

// the test double pays the fat-interface tax:
const fake: IUserRepository = {
  findById: async () => sampleUser,
  // ❌ TS forces you to stub search, create, update, delete, bulkImport,
  //    exportAuditTrail, eraseForGdpr, warmCache, …nine more — all unused
} as IUserRepository; // ← the `as` cast is the smell: lying to the compiler

Каст as IUserRepository — это улика: тест притворяется, что удовлетворяет контракт, которого не удовлетворяет, потому что честно удовлетворить его значит застабить шестнадцать нерелевантных методов. Теперь раздели по ролям:

// after — depend on the role this client plays
interface UserLookup {
  findById(id: string): Promise<User | null>;
}

class WelcomeMailer {
  constructor(private users: UserLookup) {}
  async send(id: string) {
    const user = await this.users.findById(id);
    if (user) await this.email(user);
  }
}

// the test double now tells the truth, no cast:
const fake: UserLookup = { findById: async () => sampleUser };

В поведении мейлера ничего не изменилось. Но он больше не перекомпилируется, когда добавляют метод аудита или импорта, его тест объявляет реальную зависимость одной строкой, а ревьюер с одного взгляда видит, что мейлер только читает пользователей. Толстый интерфейс был магнитом связанности; именование роли, которую он разыгрывает, перерезало кабель.

Почему это работает

Почему «зависит от метода, который никогда не вызывает» вообще чего-то стоит? Три конкретных счёта. Компиляция/CI: во многих тулчейнах модуль, именующий тип, инвалидируется, когда поверхность типа меняется, поэтому несвязанные потребители перекомпилируются, а их тесты перезапускаются. Тестовые дубли: фейк обязан реализовать весь интерфейс (или соврать кастом), поэтому толстая поверхность раздувает каждый тест, который её касается. Понимание и ревью: широкая зависимость прячет настоящую — читатель и ревьюер не отличают два метода от восемнадцати, поэтому радиус поражения любого изменения типа — «все, кто его именует». Разделение сжимает все три до реально используемого среза.

Частая ошибка

Режим отказа ISP — это перекоррекция: измельчение одного толстого интерфейса в рой односложных интерфейсов — IFindById, ISearch, ICreate, IUpdate… — так что каждый клиент теперь жонглирует пятью микротипами, а кодовая база превращается в шум. Это своя проблема связанности-и-захламления: ничто не читается как цельная роль, и ты обменял «слишком широко» на «слишком дробно». Принцип: разделяй по роли клиента, а не по методу. Роль — это связный набор операций, которые один вид клиента использует вместе: UserLookup (чтение), UserWriter (мутация), UserImporter (массово). Если два метода всегда нужны одному клиенту вместе, держать их в одном ролевом интерфейсе — правильно, а не нарушение. В сомнении дай клиентам провести границы: одна роль на каждый отдельный способ, которым используют тип.

Проверь себя
Викторина

Read-only отчёт типизирован против толстого IUserRepository, но вызывает только findById и search. Команда импорта добавляет bulkImportStreaming в интерфейс. В чём ISP-корректная причина, почему это проблема, и в чём фикс?

Итог

Принцип разделения интерфейса гласит: ни один клиент не должен быть вынужден зависеть от методов, которые он не использует — и «зависеть от» включает методы, которые ты никогда не вызываешь, потому что компилятор, мок-фреймворк и ревьюер все привязывают тебя ко всей поверхности. Толстый интерфейс — это магнит связанности: он стягивает несвязанных клиентов вместе, так что правка, которую требует один, вынуждает перекомпиляцию, перетест и повторное ревью у всех остальных. Раздели его на ролевые интерфейсы, названные по тому, как клиент использует типUserLookup, UserWriter, UserImporter — и типизируй каждого потребителя против нужного ему среза; один класс может удовлетворять несколько ролей. Режим отказа — избыточное дробление в рой односложных интерфейсов, что меняет ширину на дробный шум. Правило: разделяй по роли клиента, а не по методу — пусть границы проводят клиенты.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.