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

Инверсия зависимостей

Высокоуровневая политика не должна зависеть от низкоуровневой детали; оба зависят от абстракции, которой владеет политика. Переверни стрелку — OrderService → OrderRepository ← MySqlOrderRepository — чтобы бизнес-логика стала тестируемой, а инфраструктура заменяемой.

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

Твой OrderService — код, который держит реальные правила бизнеса, что такое заказ, когда его можно оформить, как он оценивается, — открывается строкой import { MySQLDatabase } from "../db/mysql". Перечитай эту строку. Самый ценный, самый стабильный код в системе тянется вниз и берёт жёсткую зависимость от конкретного драйвера базы данных. Теперь правила нельзя протестировать без работающего MySQL, нельзя переехать на Postgres без операции и нельзя даже осмыслить, не затащив в голову пулы соединений.

Что-то перевёрнуто задом наперёд. Вещь, которая меняется реже всего (твоя доменная политика), прикована к вещи, которая меняется чаще всего (вендор-специфичная деталь ввода-вывода). Инверсия зависимостей — это правило, которое ставит стрелку правильным концом — и самое неожиданное в том, кто владеет абстракцией после этого.

Цель

После этого урока ты можешь точно сформулировать принцип инверсии зависимостей (обе половины), объяснить, почему стрелка зависимости должна указывать на стабильную политику, а не на изменчивую деталь, отрефакторить зависимость Service → ConcreteDriver в Service → Interface ← Driver с интерфейсом, которым владеет политика, и распознать карго-культовый антипаттерн, где интерфейс просто отражает один конкретный класс и ничего не инвертирует.

1

У DIP две половины, и про вторую все забывают. Принцип гласит: (а) высокоуровневые модули не должны зависеть от низкоуровневых — оба должны зависеть от абстракций; и (б) абстракции не должны зависеть от деталей — детали должны зависеть от абстракций. Большинство помнит половину (а) — «программируй на интерфейс» — и на этом останавливается. Половина (б) — вот что даёт DIP зубы: абстракция формируется тем, что нужно политике, а не тем, что случайно предлагает какой-то конкретный класс.

// high-level policy (stable: changes when the business changes)
class OrderService { /* what an order is, pricing, placement rules */ }

// low-level detail (volatile: changes when infra/vendors change)
class MySQLDatabase { /* connection pools, SQL, driver quirks */ }

Вопрос, на который отвечает DIP: в какую сторону идёт import между этими двумя?

2

Наивно стрелка указывает от политики к детали — и это баг. По умолчанию, то, что ты пишешь не задумываясь, — это когда высокоуровневый сервис напрямую делает import низкоуровневого драйвера. Теперь и зависимость времени компиляции, и зависимость времени выполнения текут от стабильного → к изменчивому.

import { MySQLDatabase } from "../infra/mysql";

class OrderService {
  private db = new MySQLDatabase();          // policy nailed to a vendor
  place(order: Order) {
    this.db.query("INSERT INTO orders ...");  // SQL leaking into business code
  }
}

Цена конкретна: ты не можешь юнит-тестировать place() без MySQL; переход на Postgres правит класс, который держит твои правила; а обновление драйвера может сломать бизнес-логику. Самый ценный код в системе — заложник наименее ценного. Именно это направление зависимости мы и хотим развернуть.

3

Инвертируй: определи интерфейс, который нужен политике, и которым владеет политика. Вместо OrderService → MySQLDatabase определи OrderRepository — интерфейс, сформулированный на языке домена (save(order), findById(id)), а не базы данных (query, exec). Сервис зависит от этого интерфейса. Конкретный MySqlOrderRepository реализует его. Критически важно: интерфейс живёт вместе с высокоуровневым модулем, в его пакете, как часть его контракта.

// owned by the policy layer — phrased in domain terms
export interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
}

class OrderService {
  constructor(private orders: OrderRepository) {}   // depends on the abstraction
  async place(order: Order) {
    /* business rules */ await this.orders.save(order);
  }
}

Теперь и сервис, и драйвер зависят от OrderRepository. Стрелка от детали указывает вверх, на абстракцию политики. Именно это владение делает это «инверсией», а не просто «интерфейсом где-то».

4

Теперь деталь зависит от политики — стрелка действительно перевёрнута. MySqlOrderRepository импортирует OrderRepository (который принадлежит домену) и подстраивается под словарь домена. Зависимость времени компиляции идёт от инфраструктуры к домену. Изменчивая вещь зависит от стабильной вещи — а это то, что нужно, потому что стабильные вещи — хорошие цели для зависимостей.

import type { OrderRepository } from "../domain/OrderRepository";

export class MySqlOrderRepository implements OrderRepository {
  constructor(private db: MySQLDatabase) {}
  async save(order: Order) {
    await this.db.query("INSERT INTO orders ...", toRow(order));
  }
  async findById(id: OrderId) { /* SQL → Order */ }
}

// composition root wires the volatile detail into the stable policy
const service = new OrderService(new MySqlOrderRepository(mysql));

Тесты инжектят фейк; прод инжектит MySQL; миграция на Postgres — это один новый класс, реализующий тот же интерфейс, а OrderService не тронут. Тестируемость и заменяемость выпадают из перевёрнутой стрелки.

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

Сервис отчётности, до и после. До — политика тянется за драйвером:

import { Redis } from "../infra/redis";
import { Postgres } from "../infra/postgres";

class RevenueReport {
  private cache = new Redis();
  private db = new Postgres();
  async monthly(month: string) {
    const hit = await this.cache.get(`rev:${month}`);
    if (hit) return JSON.parse(hit);
    const rows = await this.db.query("SELECT sum(total) ...", [month]); // policy knows SQL
    const total = rows[0].sum;
    await this.cache.set(`rev:${month}`, JSON.stringify(total));
    return total;
  }
}

RevenueReport держит правило («месячная выручка — это сумма итогов заказов, кэшируется») и механику Redis и SQL. Ты не можешь протестировать правило без обоих серверов, и отчёт меняется всякий раз, когда меняется вендор кэша.

После — инвертируем, с абстракциями, которыми владеет отчёт:

// domain-owned ports, phrased in the report's language
export interface RevenueStore { sumForMonth(month: string): Promise<number>; }
export interface ReportCache {
  get(key: string): Promise<number | null>;
  set(key: string, value: number): Promise<void>;
}

class RevenueReport {
  constructor(private store: RevenueStore, private cache: ReportCache) {}
  async monthly(month: string) {
    const key = `rev:${month}`;
    const hit = await this.cache.get(key);
    if (hit !== null) return hit;        // pure policy: cache-aside, no vendor in sight
    const total = await this.store.sumForMonth(month);
    await this.cache.set(key, total);
    return total;
  }
}

Правило cache-aside теперь тестируется двумя in-memory фейками и нулём серверов. PostgresRevenueStore и RedisReportCache живут в инфраструктуре и реализуют эти порты. Заметь, что порты говорят на диалекте отчёта (sumForMonth, а не query) — это политика владеет абстракцией, в чём и весь смысл.

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

Почему настаивать, что абстракцией владеет высокоуровневый модуль и она сформулирована на его языке? Потому что в этом разница между инверсией и косвенностью вбок. Если интерфейс отражает базу данных (query, exec, beginTransaction), политика всё ещё фактически зависит от «SQL-базы» — ты сменил тип, но не направление; домен по-прежнему подстраивается под форму инфраструктуры. Когда интерфейс — это OrderRepository.save(order) — определённый тем, что нужно политике, живущий в пакете политики, — инфраструктура обязана подстроиться под форму домена. Стабильный модуль диктует контракт; изменчивый модуль подчиняется. Это владение и есть инверсия; одно лишь ключевое слово interface — нет.

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

Карго-культовый антипаттерн: интерфейс, существующий лишь чтобы отразить один конкретный класс — UserService + IUserService с идентичными членами, MySqlThing + IMySqlThing, авто-извлечённые IDE. Реализация одна, интерфейс меняется в ногу с ней, и владеет им (и назван по нему) деталь, а не политика. Это покупает тебе косвенность — лишний файл, лишний прыжок в «перейти к определению» — и ноль развязки: ты всё так же не можешь осмысленно варьировать реализацию, абстракция протекает словарём конкретного класса, а тесты не получают ничего, чего не дал бы обычный фейк. DIP — это не «интерфейс на каждый класс». Инвертируй только там, где есть настоящий шов: граница, которую ты пересечёшь со второй реализацией (тестовый фейк считается) или где политика должна оставаться в неведении об изменчивой детали. Везде ещё интерфейс — это просто церемония, которая повышает стоимость чтения, не снижая стоимости изменения.

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

Команда извлекает интерфейс IOrderRepository, чьи методы — query(sql), exec(sql) и beginTransaction(), — копию один-в-один публичного API их обёртки над MySQL, и заставляет OrderService зависеть от него. Применили ли они инверсию зависимостей?

Итог

Инверсия зависимостей гласит, что высокоуровневая политика и низкоуровневая деталь должны обе зависеть от абстракции, и этой абстракцией владеет политика, и сформулирована она на её языке. Стрелка по умолчанию — OrderService → MySQLDatabase — указывает не туда: стабильный, ценный код прикован к изменчивому, заменяемому коду. Ты переворачиваешь её, определив OrderRepository (доменный порт), от которого зависит OrderService и который реализует MySqlOrderRepository, так что стрелка зависимости детали теперь указывает вверх, на политику. Выгода прямая: бизнес-логика становится тестируемой фейками, а инфраструктура — заменяемой за стабильным швом. Антипаттерн, которого надо избегать, — карго-культ «интерфейс на каждый класс»: заголовок, отражающий один конкретный класс, которым владеет деталь, не инвертирующий ничего и покупающий лишь косвенность. Инвертируй там, где есть настоящий шов; везде ещё пропусти церемонию.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.