Инверсия зависимостей
Высокоуровневая политика не должна зависеть от низкоуровневой детали; оба зависят от абстракции, которой владеет политика. Переверни стрелку — OrderService → OrderRepository ← MySqlOrderRepository — чтобы бизнес-логика стала тестируемой, а инфраструктура заменяемой.
Твой OrderService — код, который держит реальные правила бизнеса, что такое заказ, когда его можно оформить, как он оценивается, — открывается строкой import { MySQLDatabase } from "../db/mysql". Перечитай эту строку. Самый ценный, самый стабильный код в системе тянется вниз и берёт жёсткую зависимость от конкретного драйвера базы данных. Теперь правила нельзя протестировать без работающего MySQL, нельзя переехать на Postgres без операции и нельзя даже осмыслить, не затащив в голову пулы соединений.
Что-то перевёрнуто задом наперёд. Вещь, которая меняется реже всего (твоя доменная политика), прикована к вещи, которая меняется чаще всего (вендор-специфичная деталь ввода-вывода). Инверсия зависимостей — это правило, которое ставит стрелку правильным концом — и самое неожиданное в том, кто владеет абстракцией после этого.
После этого урока ты можешь точно сформулировать принцип инверсии зависимостей (обе половины), объяснить, почему стрелка зависимости должна указывать на стабильную политику, а не на изменчивую деталь, отрефакторить зависимость Service → ConcreteDriver в Service → Interface ← Driver с интерфейсом, которым владеет политика, и распознать карго-культовый антипаттерн, где интерфейс просто отражает один конкретный класс и ничего не инвертирует.
У 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 между этими двумя?
Наивно стрелка указывает от политики к детали — и это баг. По умолчанию, то, что ты пишешь не задумываясь, — это когда высокоуровневый сервис напрямую делает 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 правит класс, который держит твои правила; а обновление драйвера может сломать бизнес-логику. Самый ценный код в системе — заложник наименее ценного. Именно это направление зависимости мы и хотим развернуть.
Инвертируй: определи интерфейс, который нужен политике, и которым владеет политика. Вместо 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. Стрелка от детали указывает вверх, на абстракцию политики. Именно это владение делает это «инверсией», а не просто «интерфейсом где-то».
Теперь деталь зависит от политики — стрелка действительно перевёрнута. 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.