Инверсия зависимостей
DIP: высокоуровневые модули не зависят от низкоуровневых — оба зависят от абстракций. Интерфейс принадлежит потребителю, а не поставщику. Этот механизм лежит в основе hexagonal и clean architecture.
Команда billing на B2B-платформе спроектировала чистый интерфейс для репозитория заказов. Разместили его в пакете модуля order: order/interfaces/IOrderRepository.ts. Billing импортировал его оттуда. Выглядело аккуратно — интерфейс прямо рядом с реализацией. Но через шесть месяцев команда order решила реструктурировать пакет. Они переместили интерфейс, разбили его на части и поменяли сигнатуры некоторых методов. Billing сломался. Payment gateway сломался. Audit-сервис сломался. Команда order была в недоумении: они опубликовали интерфейс, значит потребители должны быть защищены. Не были — потому что интерфейс жил в пакете модуля order. Модуль order всё ещё им владел. Каждый раз, когда команда order трогала свой пакет, они могли сломать каждого потребителя — интерфейс есть, а защиты нет. У команды была верная идея (интерфейс как граница), но неверная модель владения. DIP — это не просто «заведи интерфейс». Это про то, кому он принадлежит.
Две половины DIP
Принцип инверсии зависимостей Роберта Мартина состоит из двух утверждений, и оба должны выполняться:
- Высокоуровневые модули не должны зависеть от низкоуровневых. Оба должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Первое утверждение привычно — это правило зависимостей из предыдущего урока в форме модулей. Высокоуровневые модули (billing на B2B-платформе) содержат политику: бизнес-логику, определяющую, что должно произойти. Низкоуровневые модули (репозиторий заказов, платёжный шлюз) содержат механизм: техническую сантехнику, которая делает это возможным. Политика не должна быть связана с механизмом.
Второе утверждение тоньше — и именно его нарушила вводная история. Размещение интерфейса в пакете низкоуровневого модуля делает абстракцию деталью низкоуровневого модуля. Высокоуровневый потребитель импортирует интерфейс из низкоуровневого модуля. Низкоуровневый модуль по-прежнему может сломать потребителя, реструктурировав пакет, переименовав интерфейс или разбив его на несколько. Абстракция технически присутствует, но владение не инвертировано.
DIP требует, чтобы высокоуровневый модуль владел абстракцией, от которой зависит.
Кому принадлежит интерфейс?
Владение интерфейсом — это суть DIP. Интерфейс принадлежит тому, кто его определяет и контролирует его эволюцию. Когда спрашиваешь «должен ли этот интерфейс измениться?», ответ должен исходить из потребностей потребителя, а не из удобства реализации поставщика.
На B2B-платформе модуль billing нуждается в данных заказов. Интерфейс, который ему нужен — IOrderProvider или IOrderPort — должен жить в пакете billing (или в выделенном пакете границы, который billing контролирует). Он объявляет ровно то, что нужно billing: getConfirmedOrder(id: string): Promise<ConfirmedOrderDTO>. Не больше. Модуль order затем реализует этот интерфейс, написав класс, удовлетворяющий контракту billing.
Стрелки зависимостей теперь:
BillingService ──imports──→ IOrderPort (принадлежит billing)
↑
implements
│
PostgresOrderRepository (в модуле order)Репозиторий модуля order зависит от интерфейса billing — а не наоборот. Если нужды billing изменятся, интерфейс меняется и репозиторий должен обновиться. Если команда order меняет внутренний способ получения данных заказов, она обновляет реализацию репозитория; интерфейс billing не трогается.
Compile-time граница vs runtime граница
DIP по сути — о проведении границы, которую может соблюдать компилятор. На одной стороне: высокоуровневый модуль и принадлежащий ему интерфейс. На другой: низкоуровневый модуль, реализующий его. Ни одной стороне не нужно знать о конкретных деталях другой.
Различие между тем, что известно на этапе компиляции, и тем, что разрешается в runtime, — ключ к модульности:
На этапе компиляции BillingService знает только об IOrderPort. Он знает сигнатуры методов, типы параметров, возвращаемые типы. Он знает контракт. Он не знает, что PostgresOrderRepository существует. Компилятор не видит PostgresOrderRepository из контекста billing.
В runtime что-то должно связать IOrderPort с PostgresOrderRepository. Это работа dependency injection: composition root или контейнер разрешает, какая конкретная реализация удовлетворяет какому интерфейсу, и передаёт её в billing. В этот момент управление течёт от billing через интерфейс к реализации репозитория.
Это разделение — суть того, что делает hexagonal architecture (unit 04) и clean architecture (unit 05) структурно соблюдаемыми. Гексагон в hexagonal architecture — высокоуровневый домен, окружённый принадлежащими ему определениями интерфейсов (портами). Адаптеры — конкретные реализации — живут вне гексагона и зависят от портов внутрь. Концентрические круги в clean architecture — слои, где каждый внутренний круг владеет интерфейсами, которые реализуют внешние. DIP — механизм, делающий эти паттерны структурно соблюдаемыми.
▸Почему это работает
Почему владение интерфейсом так важно на практике? Потому что без него абстракция — фикция. Интерфейс может существовать как TypeScript interface или Java abstract class, но если он живёт в пакете низкоуровневого модуля, низкоуровневая команда может его изменить. Они могут добавить обязательные методы, изменить сигнатуры, устаревшие удалить — и высокоуровневый потребитель ломается. Слово «инверсия» в DIP означает именно это: в наивном дизайне высокоуровневый модуль зависит от низкоуровневого. После DIP низкоуровневый модуль зависит от интерфейса высокоуровневого. Стрелка зависимости буквально инвертирована. Это происходит только когда интерфейс живёт в домене потребителя.
DIP на B2B-платформе: до и после
До DIP платформа имела такую структуру:
billing/импортируетorder/infra/postgres-order-repo.ts→ конкретное к конкретномуbilling/импортируетpayment/stripe-gateway.ts→ конкретное к конкретномуorder/импортируетdb/postgres-driver.ts→ конкретное к конкретному
Каждый модуль мог сломать каждый другой через изменения реализации. Добавление нового платёжного провайдера требовало изменений в billing. Замена БД требовала затронуть всё.
После DIP:
billing/ports/IOrderPort.ts— принадлежит billing; определяетgetConfirmedOrderbilling/ports/IPaymentPort.ts— принадлежит billing; определяетchargeCustomerorder/adapters/BillingOrderAdapter.ts— реализуетIOrderPort, живёт в модуле order, но зависит от интерфейса billingpayment/adapters/StripePaymentAdapter.ts— реализуетIPaymentPort, живёт в модуле payment, но зависит от интерфейса billing
Billing теперь не имеет compile-time знания о внутренностях модуля order или модуля payment. Модули order и payment зависят от определений границы billing. Добавление нового платёжного провайдера означает написание нового адаптера, реализующего IPaymentPort — сам billing не меняется.
▸lesson.inset.note
Распространённое заблуждение: DIP не означает «всегда используй интерфейс для всего». Он означает, что на значимых архитектурных границах — где высокоуровневый модуль политики должен пересекать в территорию низкоуровневого механизма — интерфейс должен принадлежать высокоуровневой стороне. Для внутренних деталей внутри модуля или для стабильных сторонних инфраструктурных библиотек накладные расходы на инверсию интерфейса могут не окупаться. DIP — инструмент для архитектурных границ, а не универсальное стилевое правило.
Модуль order публикует интерфейс `IOrderRepository` в собственном пакете: `order/interfaces/IOrderRepository.ts`. Модуль billing импортирует и использует его. Команда order позже проводит рефакторинг, разбивая интерфейс на `IOrderReader` и `IOrderWriter`. Billing ломается. Что пошло не так с точки зрения DIP?
Модуль billing определяет IOrderPort в собственном пакете. На этапе компиляции BillingService.ts импортирует только IOrderPort. В runtime контейнер dependency injection связывает IOrderPort с PostgresOrderRepository. Новый разработчик говорит: «BillingService всё равно вызывает PostgreSQL в runtime — DIP не устранил зависимость». Как ты ответишь?
В hexagonal architecture (unit 04) домен определяет «порты» — интерфейсы для всех исходящих зависимостей. Модуль домена владеет этими портами. Инфраструктурные адаптеры реализуют их. Какое утверждение объясняет, почему hexagonal architecture структурно является DIP, применённым на архитектурном уровне?
- 01Каковы два утверждения DIP и почему второе (абстракции не должны зависеть от деталей) так же важно, как первое?
- 02На B2B-платформе где именно должен жить IOrderPort и почему расположение пакета определяет, кто может его изменить?
- 03Что такое 'compile-time граница', которую проводит DIP, и как DI-контейнер соотносится с ней?
Принцип инверсии зависимостей состоит из двух половин. Первая: высокоуровневые модули (бизнес-политика) не должны зависеть от низкоуровневых (технический механизм) — оба должны зависеть от абстракций. Вторая: эти абстракции должны принадлежать высокоуровневой стороне, а не низкоуровневой.
Владение интерфейсом — операциональное следствие. Интерфейс, определённый в пакете низкоуровневого модуля, всё ещё принадлежит низкоуровневому модулю — поставщик может изменить его, разбить, переместить и сломать каждого потребителя. Интерфейс, определённый в пакете высокоуровневого модуля, принадлежит потребителю — он меняется только когда меняются требования потребителя, и поставщик должен соответствовать контракту потребителя.
Структурный результат: после DIP стрелка зависимости в исходном коде от низкоуровневого модуля указывает в сторону интерфейса высокоуровневого, а не от него. Поставщик зависит от определения границы потребителя. Это и есть настоящая инверсия.
Этот механизм делает hexagonal architecture (unit 04) и clean architecture (unit 05) структурно соблюдаемыми. В hexagonal architecture домен владеет своими портами — каждый исходящий интерфейс определён доменом. Инфраструктурные адаптеры реализуют эти порты и поэтому зависят внутрь. В clean architecture правило зависимостей ведёт к центру — внутренние круги определяют интерфейсы, внешние реализуют их. DIP — не архитектурный паттерн сам по себе; это базовый механизм, на который опираются все эти паттерны для соблюдения границ зависимостей на этапе компиляции.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.