Всем привет! Я Борис Зырянов, разработчик в команде Платформы. В этой статье хочу рассказать про Dependency Inversion Principle, потому что это, пожалуй, один из самых важных принципов SOLID, понимание которого дает ключи к архитектуре программного обеспечения. 

Задача, стоящая перед настоящим текстом — придать объем DIP, продемонстрировав, как из лаконичных дефиниций следуют строгие правила по организации кода. В статье будут очерчены критерии корректного использования принципа, разобраны его определения и рассмотрен пример применения. Это даст понятную модель организации зависимостей кода, которую вы сможете применить на практике.

Предисловие

Это вторая версия статьи переработанная и улучшенная. Первая версия публиковалась в моем блоге.

Декомпозиция формулировок и классификация кода

Известно два каноничных определения принципа от его автора — Роберта Мартина. К другим определениям DIP мы будем обращаться при необходимости по ходу статьи (здесь и далее все определения будут приведены «as is» без перевода, во-первых, потому что приводятся цитаты, во-вторых, потому что еще один вариант перевода абсолютно излишен). Приведу определения в хронологическом порядке.

Определение первое от 1996 года:

A. High level modules should not depend upon low level modules. Both should depend upon abstractions.

B. Abstractions should not depend upon details. Details should depend upon abstractions.

Martin R. The Dependency Inversion Principle

Определение второе от 2020 года:

Depend in the direction of abstraction. High level modules should not depend upon low level details.

Martin R. Solid Relevance

Сразу оговоримся, что понимать слово «module» следует как некий структурный элемент кода, обособленную часть программы, отвечающую за конкретный функционал. В зависимости от вашего языка программирования это может быть пространство имен, пакет или действительно модуль (привет, Java). На самом деле, независимо от того, какие возможности предлагает ваш язык для объединения кода в модули, DIP сохраняет применимость для организации зависимостей между ними.

И начнем разбор формулировок DIP с той части, где упоминается про модули и детали — «High level modules should not depend upon low level modules (details)». Представим, что у нас уже есть код некоторого приложения, к которому мы и будем применять DIP.

Если мы попытаемся применить эту часть определений DIP к коду, то обнаружим, что нам навязывается «пространственная» лексика — появляется некоторая «вертикальность»: «high level modules» и «low level modules (details)». И, шире, иерархичность («should not depend upon»). Это приводит к тому, что отношения между модулями получают дополнительное измерение — вертикальную ось, относительно которой измеряется «уровень» модуля, определяется «низкоуровневость» деталей и выстраивается иерархия модулей (по нюансам различий между модулями и деталями пройдемся позднее). Далее, чтобы продолжить применять DIP, нам необходимо выделить в коде приложения модули, а затем расположить модули относительно вертикальной оси (далее эту ось мы будем называть ось уровня абстракции), выстроив иерархию между ними.

Выделять модули в коде и строить зависимости между ними начнем с решения более общей задачи, а именно с распределения кода относительно оси уровня абстракции. Для этого нам необходимо классифицировать код, который можно встретить в любом приложении. А так как уровень кода определяется назначением (характером решаемых задач) кода (Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. СПб.: Питер, 2020. С. 187), то и классификацию будем проводить по этому основанию.

По назначению код приложения можно разделить на 3 категории:

  • Бизнес-правила (домен, бизнес-логика, high level policy, etc.). Код, который описывает логику предметной области и обслуживает интересы заказчика. Собственно, это тот код, ради которого вы начали писать приложение.

  • Код приложения (application). Код, появившийся потому, что мы пишем наше приложение. Код, который обслуживает инстанцирование (подготавливает данные для и осуществляет вызовы) бизнес-правил и передает «выхлоп» от работы логики этих правил куда-то еще.

  • Инфраструктурный код (infrastructure). Код, который обеспечивает взаимодействие с различными устройствами ввода/вывода (I/O) и потоками — драйверами различного ПО, устройствами, файловой системой, web-сервером, standard streams, etc.

Наложив выделенные категории на ось уровня абстракции, получим следующее распределение (рис. 1).

 Рисунок 1 — Категории кода на оси уровня абстракции
 Рисунок 1 — Категории кода на оси уровня абстракции

Выше остальных категорий по оси располагаются высокоуровневые бизнес-правила («high level»), а внизу устройства ввода/вывода и код, который обеспечивает с ними взаимодействие («low level»). Распределение на рис. 1 показывает, насколько та или иная категория кода отстоит от решения бизнес-задач и обслуживает задачи ввода и вывода или наоборот — насколько далек код от взаимодействия с вводом/выводом и в какой степени решает бизнес-задачи. Именно удаленностью от ввода/вывода характеризуется уровень абстракции: чем дальше код от I/O, тем выше его уровень абстракции. 

Модули объединяют в более крупные структуры — слои. Задачи одной категории кода могут решать несколько слоев (в зависимости от общей архитектуры приложения). Чтобы не уходить в архитектурные дебри, будем считать, что количество слоев нашего приложения равно количеству категорий кода, а «уровень абстракции» и «слой приложения» — сходные до определенной степени понятия в контексте статьи.

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

Итак, код нашего гипотетического приложения мы сгруппировали в модули, модули у нас специализированные, то есть решают задачи, находящиеся на разных уровнях абстракции: кто-то в базу данных ходит (low level), а кто-то считает скидку на заказ в интернет-магазине у конкретного пользователя (high level). Это значит, что мы разобрались с «вертикальностью» и самое время идти дальше. А дальше нам необходимо выстроить взаимодействие (построить иерархию) между этими модулями. Поможет нам в этом дальнейшее прочтение определений DIP: «depend in the direction of abstraction» (определение №2). Или более подробно в определении №1 : «Both (high and low level modules) should depend upon abstractions». Здесь абстракция это контракт, выделенный в абстрактный класс или интерфейс. Необходимость проводить зависимости через абстракции проистекает из того, что контракт более стабилен, чем его конкретная реализация и не будет меняться со временем (либо будет это делать очень редко) (Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. СПб.: Питер, 2020. С. 101). 

Вообще, «depend in the direction of abstraction» это самая популярная часть определения, в которой зачастую игнорируют «in the direction», получая «depend in the direction of abstraction», чем и ограничивают весь DIP. Это приводит к его наивному пониманию: казалось бы, определяй зависимости через интерфейсы и вот он DIP. Однако, без учета уровня абстракции, на котором расположен код, и того места где расположена сама абстракция, определять зависимости через интерфейсы не то, чтобы было в целом бессмысленно, но такой подход соответствует DIP не в полной мере. Учитывать уровень абстракции (задачи, которые решает код) необходимо для выбора правильного направления зависимостей. 

И здесь самое время обратиться к еще одной формулировке DIP, которая прямо об этом говорит (определение №3):

  • Abstractions should not depend on details

  • Code should depend on things that are at the same or higher level of abstraction

  • High level policy should not depend on low level details

  • Capture low-level dependencies in domain-relevant abstractions

Schuchert B. L. DIP in the Wild

Цитата выше дополняет нашу картину самым важным постулатом, который следует из каноничных формулировок — «Code should depend on things that are at the same or higher level of abstraction»: абстракция, от которой зависит код, должна находиться либо на одном уровне с кодом, либо на более высоком. Таким образом, общее направление зависимостей в приложении должно стремиться к наиболее удаленному от устройств ввода/вывода коду. 

Еще одно следствие из постулата выше: на одном уровне может быть сколько угодно зависимых друг от друга модулей и это не будет нарушением DIP (рис. 2).

 Рисунок 2 — Зависимости модулей на одном уровне
 Рисунок 2 — Зависимости модулей на одном уровне

На зависимости между одноуровневыми модулями могут накладываться явные и неявные ограничения архитектурными парадигмами (hexagonal architecture, clean architecture, etc.) и подходами к декомпозиции (DDD, здравый смысл, конвенции на уровне команды, etc.), которые не являются предметом данной статьи.

Необходимо оговориться, что в полной мере следование DIP невозможно, ввиду наличия конкретных деталей, предоставляемых языком программирования и используемых в коде. Конечно, можно считать условный класс String в условном языке программирования низкоуровневым механизмом и провести взаимодействие между ним и бизнес-логикой через все уровни абстракции, будто это какой-нибудь драйвер для базы данных. Но смысла в этом исчезающе мало, так как классы, которые предоставляет язык программирования, обычно достаточно стабильны и нам не нужно опасаться их изменчивости (Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. СПб.: Питер, 2020. С. 101). Поэтому следует определить область действия DIP: принцип применяется к тому коду, который пишет непосредственно разработчик. Также DIP не определяет взаимоотношения между типами внутри модуля (зависимость ApplicationClass1 от конкретного класса ApplicationClass2 на рис. 2 внутри модуля).

О low level modules и low level details

В первом и втором определении модули и детали упомянуты в равнозначном контексте и думаю, что это требует отдельного пояснения. Обычно деталями называют реализацию абстракции, поэтому модуль и деталь это пересекающиеся до определенного предела понятия.

Так любой код, становится деталью по отношению к уровням выше. Так как является прямой реализацией высокоуровневого контракта, либо выполняет задачи специфичные для его уровня (и в конечном счете также помогая реализовать функционал модулям верхнего уровня), либо является связующим звеном между модулями верхнего уровня и внешним миром. В последнем случае можно говорить о переиспользовании кода модулей верхнего уровня модулями нижнего уровня — модули верхнего уровня дают одинаковые для любых нижних модулей правила («high level policy» определение №3).

Инверсия

А где, собственно, инверсия? Почему и зачем инверсия? Чтобы ответить на эти вопросы рассмотрим отношения между модулями с явным нарушением DIP. Для примера возьмем условный класс ApplicationUseCase, который зависит от классов Repository и Authorization, а вызывается в WebController (рис. 3). 

Рисунок 3 — Зависимость модуля верхнего уровня от деталей
Рисунок 3 — Зависимость модуля верхнего уровня от деталей

Зависимости ApplicationUseCase в модуле верхнего уровня, необходимые ему для решения своих задач, направлены против оси уровня абстракции сверху вниз: ApplicationUseCase напрямую зависит от конкретных классов Authorization и Repository, расположенных в модулях низкого уровня. Изменения, которые произойдут в этих классах (и в их модулях), будут влиять на все, что от них зависит, что сделает ApplicationUseCase потенциально нестабильным. Ну а прямая зависимость WebController от ApplicationUseCase нарушает часть «Details should depend upon abstractions» определения №1.

Попробуем исправить это, применив DIP к текущим модулям (рис. 4).

Рисунок 4 — Зависимость деталей от абстракции
Рисунок 4 — Зависимость деталей от абстракции

Поменяв прямую зависимость от классов Authorization и Repository в классе ApplicationUseCase на зависимость от интерфейсов (расположенных в границах модуля верхнего уровня) AuthorizationInterface и RepositoryInterface, в которых определили требования к необходимому поведению (далее для краткости просто «требования») для модулей нижнего уровня, мы инвертировали общее направление зависимостей (относительно рис. 3). Теперь зависимости направлены в сторону модуля верхнего уровня и более высокого уровня абстракции.

Там, где зависимости были направлены «естестественным» образом в сторону модуля верхнего уровня, взаимодействие с ними теперь происходит через API  модуля — UseCaseInterface. UseCaseInterface может быть вызван там, где необходимо — консольная команда, обработчик сообщений очереди или, как здесь, веб-контроллер. 

Конечно, и без интерфейса можно было осуществлять вызовы методов ApplicationUseCase откуда угодно снизу, однако, контракт здесь создает стабильное API. И теперь связи между модулями на рис. 4 соответствуют DIP —  модули нижнего уровня зависят от модулей верхнего уровня, все модули зависят от абстракций.

Инвертировав направление зависимостей, мы привели код в состояние, в котором: А. модули верхнего уровня «управляют» низкоуровневым кодом посредством определения требований в интерфейсах; B. низкоуровневый код реализует эти требования и обращается к высокоуровневому коду через абстракции, расположенные и реализуемых в модулях верхнего уровня. 

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

Несомненные (для меня, как разработчика, которому часто приходится зарываться в несколько проектов одновременно) плюсы такого подхода заключается в двух моментах. Первый, точно известно «business value» такого кода, то есть понятно как именно он «зарабатывает деньги» — достаточно взглянуть на модули верхнего уровня (хорошо, просто «взглянуть», очевидно, недостаточно, но также, очевидно, что выделенная предметная логика потратит гораздо меньше вашего времени на свое освоение). Второй, как правило, отсутствует или сведена до необходимого «техническая интоксикация» в модулях верхних уровней. Под «технической интоксикацией» я понимаю ситуацию, когда в коде, который как раз и «зарабатывает деньги» присутствует низкоуровневая семантика, например, методы именуются в стиле handleRequest, refreshCache, transformData, etc. Прямо скажем, вряд ли вашему бизнесу приносит деньги непосредственно кэширование, вероятно, без него ваш продукт не сможет обработать большое число запросов, возможно оно необходимо, но оно не главное. Задачи предметной области служат отправной точкой для семантики наименования поведения и контрактов на любом уровне абстракции. Семантика предметной области «проникает» с верхнего уровня на модули уровнями ниже через реализуемые абстракции, модули нижних уровней как бы «говорят» на языке модулей верхнего уровня — «Capture low-level dependencies in domain-relevant abstractions» из определения №3.

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

DIP и Single Level of Abstraction Principle

Здесь я хотел бы заострить внимание на уровнях абстракции и провести некие линии между DIP и Single Level of Abstraction Principle (SLAP), думаю, что это даст отличный ориентир при написании кода. SLAP упоминается в книге «Clean Code: A Handbook of Agile Software Craftsmanship» (Martin R. C. Clean code: a handbook of agile software craftsmanship. – Pearson Education, 2009. С. 36), выделенного определения в книге нет, а сам принцип сформулирован как требование для функций, которое впоследствии стало именоваться SLA принципом. Это требование выглядит так:

In order to make sure our functions are doing “one thing,” we need to make sure that the statements within our function are all at the same level of abstraction.

Martin R. Clean Code: A Handbook of Agile Software Craftsmanship

Приведу пример функции на golang, которая нарушает этот принцип:

package discount

import (
    "encoding/json"
    "math"
    "os"

    "bitcraft.pw/dip/src/product/repository"
)

type Product struct {
    ID	     string `json:"id"`
    Name     string `json:"name"`
    Price    int 	`json:"price"`
    MinPrice int    `json:"min_price"`
}

func CalculateDiscount(filename string, discount float64) error {
    b, err := os.ReadFile(filename)
    if err != nil {
        return err
    }

    var products []Product
    if err = json.Unmarshal(b, &products); err != nil {
        return err
    }

    for _, p := range products {
        amount := math.Round(float64(p.Price) * (percent / 100.0))
        if dp := p.Price - int(discounted); p.MinPrice < dp && dp < p.Price {
            err = repository.SaveWithDiscount(
                p.ID,
                dp,
            )
            if err != nil {
                return err
            }
        }
    }
}

Функция читает из файла, преобразует прочитанное в слайс со структурами, рассчитывает скидку и записывает результат в некое хранилище. Такой набор операций согласно SLAP слишком обширен, так как в функции пересекаются обязанности кода разных уровней абстракции. Если DIP требует установить иерархические отношения между уровнями и модулями, то SLAP, по сути, накладывает требование на код модуля выполнять обязанности соответствующие конкретному уровню абстракции.
Поэтому SLAP является хорошим маркером, который может подсказать, что что-то идет не так при выстраивании зависимостей.

Coupling, интерфейсы и еще раз инверсия

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

Метрики модуля afferent coupling («Ca», количество типов в других модулях, которые знают о типах в данном модуле) и efferent coupling («Ce», количество типов в других модулях, о которых знают типы в данном модуле) показывают отношения модуля с окружающим его кодом. На основе afferent coupling и efferent coupling рассчитывается еще одна метрика — instability, отражающая устойчивость модуля, это отношение Ce к общему числу связей модуля (Сa + Ce). Высокое число использований кода модуля другими модулями и низкое количество использования кода других модулей внутри данного модуля приводит к тому, что модуль становится устойчивым и менее подверженным влиянию извне. Такой модуль сложнее сломать, сломав что-то, от чего он зависит. Что и логично — чем меньше код знает об окружающем мире, тем меньше на него этот мир влияет и чем больше этот код используется извне, то тем больше он имеет ответственности. 

Почему нам вообще нужно считать связность (coupling) между модулями? Потому что идея лежащая в основе DIP это переиспользование модулей верхнего уровня за счет их абстрагирования от низкоуровневого кода и уменьшения связности между модулями. Логично предположить, что при организации зависимостей согласно DIP afferent coupling высокоуровневых модулей должна расти, instability модулей в направлении зависимостей должна уменьшаться (Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. СПб.: Питер, 2020. С. 133), а efferent coupling от низкоуровневых модулей не должна возникать (отсутствующая efferent coupling от модулей низкого уровня как раз свидетельствует об абстрагированности от них).

Однако, при выстраивании зависимостей и попытке посчитать связность между модулями мы можем столкнуться с интересной коллизией компоновки, которая нарушает наше предположение о том как должны вести себя метрики. Если расположить интерфейс за пределами модуля верхнего уровня, то его метрика efferent coupling увеличится на число вынесенных за модуль контрактов, а afferent coupling, вместо ожидаемого увеличения, не изменится или уменьшится (рис. 5). Как следствие instability (на рис. 5 «I») верхнеуровневого модуля будет расти. 

Рисунок 5 — Неправильная компоновка модулей
Рисунок 5 — Неправильная компоновка модулей

Более того, в текущей компоновке модулей проявилась такая же проблема как на рис. 3 — модуль верхнего уровня стал зависеть от модулей нижнего уровня. Так как модуль скомпонован так, что интерфейсы, необходимые ему, находятся вне его границ и вне границ его уровня абстракции, но рядом с реализацией в модулях нижнего уровня.

Идея скомпоновать абстракцию и её реализацию рядом друг с другом в модуле нижнего уровня может и выглядит логично, но в нашем случае появляются следующие проблемы. Во-первых, верхнеуровневый модуль, которому эти контракты необходимы, начинает знать о чем-то, что находится за пределами его уровня абстракции и такая компоновка нарушает инкапсуляцию верхнеуровневого модуля (так как что-то, что модулю необходимо для решения его задач, находится за его границами). Во-вторых, увеличивается его efferent coupling и, как следствие, instability. Мы помним что DIP не накладывает ограничения на одноуровневые зависимости и efferent coupling от одноуровневых зависимостей вполне приемлем. Но в данном случае у нас меняется направление зависимостей, которое определяется DIP, и мы получаем efferent coupling, возникающий из-за того, что интерфейсы находятся в модуле, расположенном ниже по оси уровня абстракции.

Располагая интерфейс в конкретном модуле, мы подразумеваем, что интерфейс будет выражать требования модуля или служить его API. И расположив интерфейс вне модуля верхнего уровня, мы создали ситуацию, когда из описания необходимого для модуля верхнего уровня поведения контракт превратился в API низкоуровневого модуля, которое вызывается модулем верхнего уровня. Такая компоновка предполагает, что рано или поздно где-то появится (или уже есть) еще один модуль, который воспользуется поведением, описанным в интерфейсе модуля нижнего уровня. Рано или поздно возникнет пересечение требований по этому интерфейсу. Как должен развиваться этот контракт? Требования какого модуля будут приоритетней? Будет ли такой контракт «domain-relevant»

Посмотрим на Low Level Module с RepositoryInterface внутри. Это устойчивый модуль нижнего уровня, оказывающий влияние на модуль верхнего уровня (High Level Module I=0 рис. 5). Модулям, зависящим от RepositoryInterface, либо придется использовать универсальный контракт, семантически никак не связанный с решаемыми задачами предметной области (именование методов в духе findBy, findOneBy, findAll, etc., для того чтобы быть пригодным к использованию примерно везде), либо RepositoryInterface нарушит Interface Segregation Principle и станет включать множество методов, которые нужны разным модулям. Либо же, чтобы не нарушать ISP, модуль с RepositoryInterface будет содержать несколько интерфейсов, делящие между собой методы по их модулям-потребителям. Однако, тактические успехи в части следования ISP и каким угодно еще принципам не спасут нас от стратегических просчетов.

Из такого положения вещей мы получаем два следствия: первое — фокус с решения задач предметной области смещается на поддержку и развитие модуля нижнего уровня и его контрактов. Второе (и главное) — логика предметной области в модулях верхнего уровня становится ограниченно переиспользуемой или не переиспользуемой вовсе, так как зависит от поведения модулей нижнего уровня (это поведение нужно будет учитывать при вызовах модуля верхнего уровня). Также использование методов RepositoryInterface в ApplicationUseCase нарушает SLAP: модуль верхнего уровня использует что-то внутри метода своего класса, что расположено не на его уровне абстракции, а уровнем ниже. Таким образом, вместо переиспользуемого модуля верхнего уровня мы получаем переиспользуемый модуль нижнего уровня и это явно не тот результат применения DIP, который мы хотим видеть.

В случае с инвертированными зависимостями такая ситуация просто не может возникнуть. Каждый уровень абстрагирован от более низкого не просто за счет того, что зависимости между разноуровневыми модулями проходят по абстракциям. А за счет того, что эти абстракции описывают необходимое поведение для модулей верхнего уровня (и от этих же абстракций зависят модули и на верхнем, и на нижнем уровне). Это и обеспечивает общее направление зависимостей в сторону верхних уровней абстракции. Низкоуровневое поведение не должно появляться в модулях верхнего уровня. В контрактах должно описываться то поведение, которое соответствует уровню решаемых модулем задач. 

Попробуем исправить сложившуюся ситуацию — поменяем компоновку модулей и снова посчитаем метрики (рис. 6). 

Рисунок 6 — Правильная компоновка модулей
Рисунок 6 — Правильная компоновка модулей

Если вернуть ответственность за формирование контрактов в модуль верхнего уровня (переместив интерфейс, содержащий необходимое модулю поведение, внутрь его границ), то метрики High Level Module будут демонстрировать ожидаемое поведение — afferent coupling увеличится, instability уменьшится, а efferent coupling от низкоуровневых модулей пропадет. 

При такой компоновке логика расположенная в High Level Module станет более устойчива (и абстрагирована), а зависимости инвертированы — модули нижнего уровня зависят от модуля верхнего уровня; все три модуля зависят от абстракции; зависимости направлены в сторону высшего уровня абстракции. Модуль верхнего уровня сохраняет инкапсуляцию: он не зависит от поведения модулей нижнего уровня — требуемое ему поведение описано в его контракте и реализуется в модулях нижнего уровня. Инверсия зависимостей буквально освобождает модули на любом уровне абстракции от знания о чем-то, что находится на уровнях ниже. Метрики, описывая связи модуля, как и SLAP, являются отличным подспорьем для того, чтобы понимать, что происходит с вашим кодом. 

В коде

Вернемся к прошлому листингу и представим, будто бы это фрагмент гипотетического приложения из начала статьи до того, как мы декомпозировали его на специализированные модули. Попробуем выделить смешанные в одной функции уровни, обрисовав широкими мазками как мог бы выглядеть этот код, соответствуй он и DIP и SLAP.

Для это взглянем на функцию еще раз и найдем в ней код, принадлежащий разным уровням абстракций:

package discount

import (
    "encoding/json"
    "math"
    "os"

    "bitcraft.pw/dip/src/product/repository"
)

type Product struct {
    ID	     string `json:"id"`
    Name     string `json:"name"`
    Price    int    `json:"price"`
    MinPrice int    `json:"min_price"`
}

func CalculateDiscount(filename string, discount float64) error {
    // Чтение файла: инфраструктурный уровень
    b, err := os.ReadFile(filename)
    if err != nil {
        return err
    }

    // Преобразование байтов в слайс структур: инфраструктурный уровень
    var products []Product
    if err = json.Unmarshal(b, &products); err != nil {
        return err
    }

    // Перебор слайса в цикле: уровень приложения
    for _, p := range products {
        // Расчет скидки для товара: доменный уровень
        amount := math.Round(float64(p.Price) * (percent / 100.0))
    	// Расчет цены товара со скидкой: доменный уровень
    	// Цена со скидкой попадает в допустимый диапазон: доменный уровень
    	if dp := p.Price - int(amount); p.MinPrice < dp && dp < p.Price {
            // Запись в хранилище: инфраструктурный уровень
            err = repository.SaveWithDiscount(
                p.ID,
                dp,
        	)
        	if err != nil {
                return err
        	}
        }
    }
}

Определив, какой код решает задачи какого уровня абстракции, можно двигаться дальше и компоновать код в модули. Начнем делать это с выделения модулей верхнего уровня:

package product

import (
    "errors"
    "fmt"
    "math"
)

type product struct {
    id   	 string
    name 	 name
    price	 price
    minPrice price
}

const maxNameLength = 155

type name struct {
    value string
}

type price struct {
    value int
}

func newName(value string) (name, error) {
    if len([]rune(value)) > maxNameLength {
        return name{}, errors.New(fmt.Sprintf("product name longer than %d characters", maxNameLength))
    }

    return name{
        value: value,
    }, nil
}

func newPrice(value int) (price, error) {
    if value > 0 {
        return price{
        	value: value,
  	  }, nil
    }

    return price{}, errors.New("product price cannot be less than 0")
}

type Product interface {
    Id() string
    DiscountedPrice(percent float64) int
}

func newProduct(id string, name name, price, minPrice price) Product {
    return &product{
    	  id:   	id,
    	  name: 	name,
    	  price:	price,
    	  minPrice: minPrice,
    }
}

func (p product) Id() string {
    return p.id
}


func (p product) DiscountedPrice(percent float64) int {
    amount := math.Round(float64(p.price.value) * (percent / 100.0))
    if dp := p.price.value - int(amount); p.minPrice.value < dp && dp < p.price.value {
        return dp
    }

    return 0
}

type Factory interface {
    Product(id string, name string, price, minPrice int) (Product, error)
}

type factory struct {
    generator IdGenerator
}

func NewFactory(generator IdGenerator) *factory {
    return &factory{
        generator: generator,
    }
}

type IdGenerator interface{
    Generate() string
}

func (f factory) Product(id string, name string, price, minPrice int) (Product, error) {
    if len([]rune(id)) == 0 {
        id = f.generator.Generate()
    }
    
    nameType, err := newName(name)
    priceType, err := newPrice(price)
    minPriceType, err := newPrice(minPrice)

    if err != nil {
        return nil, err
    }

    return newProduct(id, nameType, priceType, minPriceType), nil
}

Теперь логика предметной области сконцентрирована в package product, также она выдвигает некие требования в виде интерфейса IdGenerator и предоставляет методы для ее запуска через интерфейс Product. Пройдем на уровень ниже и выделим модули уровня приложения:

package application

import "bitcraft.pw/dip/src/product"

type service struct {
    loader Loader
    repository Repository
}

type ProductService interface {
    CalculateDiscount(percent float64) error
}

type Loader interface {
    Load() ([]product.Product, error)
}

type Repository interface {
    SaveWithDiscount(id string, price int) error
}

func NewService(loader Loader, repository Repository) *service {
    return &service{
        loader: loader,
        repository: repository,
    }
}

func (s service) CalculateDiscount(percent float64) error {
    products, err := s.loader.Load()

    if err != nil {
        return err
    }

    for _,p := range products {
        price := p.DiscountedPrice(percent)
    	  if err = s.repository.SaveWithDiscount(p.Id(), price); err != nil {
            return err
        }
    }

    return nil
}

type generator struct {
    ...
}

func NewGenerator(...) *generator {
    return &generator{
        ...
    }
}

func (s generator) Generate() string {
    var result string

    ...

    return result
}

Для расчета скидки на товар нам необходимо получить данные, после их обработать и передать результат в хранилище. Определяем требования для модулей нижнего уровня в виде Repository и Loader интерфейсов и рассчитываем скидку с помощью интерфейса Product с уровня выше. Так же, как и интерфейс Product, интерфейс ProductService предоставляет метод CalculateDiscount в качестве API. Также в этом пакете реализуется интерфейс IdGenerator.

Идем дальше и смотрим, что происходит в самом низу:

package loader

import (
    "encoding/json"
    "os"

    "bitcraft.pw/dip/src/application"
    "bitcraft.pw/dip/src/product"
)

type fileProduct struct {
    Id	     string `json:"id"`
    Name     string `json:"name"`
    Price    int 	`json:"price"`
    MinPrice int    `json:"min_price"`
}

type loader struct {
    factory product.Factory
    filename string
}

func NewLoader(factory product.Factory, filename string) application.Loader {
    return &loader{
        factory: factory,
        filename: filename,
    }
}

func (l loader) Load() ([]product.Product, error) {
    b, err := os.ReadFile(l.filename)
    if err != nil {
        return nil, err
    }

    var fileProducts []fileProduct
    if err = json.Unmarshal(b, &fileProducts); err != nil {
        return nil, err
    }

    var products []product.Product
    for _, p := range fileProducts {
        product, err := l.factory.Product(p.Id, p.Name, p.Price, p.MinPrice)
        if err != nil {
            return nil, err
        }

    	products = append(products, product)
    }

    return products, nil
}

package repository

import "bitcraft.pw/dip/src/application"

type repository struct {
    dsn string
}

func NewRepository(dsn string) application.Repository {
    return &repository{
        dsn: dsn,
    }
}

func (r repository) SaveWithDiscount(id string, price int) error {
    // работаем с хранилищем и сохраняем в скидку на товар
    return nil
}

А в самом низу на инфраструктурном уровне происходит реализация интерфейсов уровня приложения Repository и Loader в package repository и package loader, соответственно. Также в package loader используется интерфейс Factory для того, чтобы инстанцировать модель Product предметной области. Здесь мы не ограничены ни конвенциями, ни архитектурными правилами и поэтому можем использовать высокоуровневый интерфейс напрямую, минуя application уровень, избегая излишней для примера конвертации типов данных.

Также где-то на этом уровне должен существовать пакет с обработчиками сигналов к приложению извне, в который делается инъекция с реализацией application.ProductService, у которой вызывается CalculateDiscount, чтобы запустить всю цепочку вызовов. 

Что же, кажется, мы декомпозировали код в модули, и теперь код соответствует и DIP, и SLAP. Посмотрим как выглядят связи между модулями в UML, где интересующий нас контекст модуля ограничен пакетом (рис. 7).

Рисунок 7 — Пример в UML
Рисунок 7 — Пример в UML

На схеме видно, что зависимости между пакетами направлены в сторону верхних уровней, а сами зависимости проходят через абстракции. В тоже время, абстракции или описывают необходимое для модулей верхнего уровня поведение, или служат в качестве API для таких модулей. Здесь я отображал связи между реализацией интерфейса с помощью «утиной типизации» и самим интерфейсом: пусть и неявная, но связь между абстракцией и её имплементацией все равно существует (строго говоря, такой тип связи можно не учитывать в расчетах, но в примере дальше я буду принимать их в расчет; в целом же вопрос «как считать связность утиной типизации?» мне кажется вопросом конвенций).

Если посчитать coupling и instability для пакетов, то получим следующее:

package

afferent coupling

efferent coupling

instability = Ce / (Ce + Ca)

product

4

0

0

application

2

2

0.5

repository

0

1

1

loader

0

3

1

Метрика instability уменьшается в направлении зависимостей: на самом верху оказались очень устойчивые модули, но чем ниже к I/O, тем выше instability. Кажется, что именно этого мы и добивались — на пакет с логикой, реализующей правила предметной области, не влияет ничего, кроме того, что расположено в этом же пакете (что, кстати говоря, кажется отличным вариантом с учетом того, что мы не хотим случайно повлиять на правила расчета скидок) и требований предметной области. 

При малой связности между модулями упрощается оперирование кодом, например, появляется простота в тестировании. При Ce=0 у type product не требуется никаких моков, testcontainers и тому подобных техник и технологий — старые добрые (и дешевые) unit-тесты вполне надежно (и быстро) проверят все кейсы расчета скидок на товар, а значит, самая важная часть вашего приложения будет еще и надежна. Изолированная на верхнем уровне предметная логика (Ce=0 у package product) также обещает беспроблемное написание тестов. А в целом такой подход это хорошая заявка на построение пирамиды тестирования: на модули сверху легко писать unit-тесты (более того, модулям верхнего уровня комплементарен подход Test-Driven Development), а модули ниже вполне удобно закрываются интеграционными тестами. Впрочем, про тестирование и организацию модулей верхнего уровня с их high level policies поговорим как-нибудь в другой раз.

Вероятно, читатель справедливо заметит, что маленькое и понятное превратилось в большое и развесистое и спросит про плюсы и минусы такого подхода. На что я отвечу, что маленькое и понятное оно только потому, что это синтетический пример, придуманный мной для этой статьи. В нашей печальной реальности это будет метод на несколько экранов с side-эффектами из-за неожиданной конфигурации условного хранилища или еще какого-нибудь ПО вокруг. Тем не менее, вопрос — что вам даст такой подход и что потребует взамен — имеет место быть, поэтому отмечу сначала преимущества. 

Преимущества такого подхода происходят из декомпозиции кода в модули, а выстраивание связей между модулями согласно DIP придает такой модульности следующий смысл:

  1. Снижается когнитивная нагрузка: программный продукт, в частности, его цели, становится прозрачным для разработчика. Код легко понимать, если держать в голове, что главное – это то, какой код «зарабатывает деньги». Человеку, впервые пришедшему на проект, достаточно изучить модули верхнего уровня, содержащие в себе дистиллированные правила предметной области, а во все остальное погружаться при необходимости. Не нужно тратить время на утомительный поиск и изучения логики-которая-приносит-деньги, размазанной по всему проекту и перемешанной с низкоуровневым кодом с имплицитной семантикой.  

  2. Равномерность поставки изменений: при разработке такого кода можно легко определить «ось изменений» (модули, которые будут разрабатываться или меняться) — ваш код прозрачный и предсказуемый, нет проблем с определением точки приложения усилий. Планировать разработку такого кода и модифицировать его значительно проще, так как модификация и разработка проходят через ряд изолированных друг от друга модулей с изменением и разработкой только необходимого. 

  3. Большая тестируемость: ваш код не отправит в доставку две единицы товара, вместо одной и 100% скидки на оплату ваш код тоже не посчитает (это ли не счастье спать спокойно?). Как минимум, вам легко получить минимальный набор гарантий работоспособности того, ради чего вы пишите ваше приложение, как максимум, вы можете построить классическую пирамиду тестирования. 

  4. DIP это framework agnostic by design: в примере наш код, начиная с application уровня, является framework agnostic — модули верхнего уровня свободны от знаний о деталях, они решают свои задачи, а все, что им необходимо находится в абстракциях и реализуется где-то внизу. Что-то конкретное про окружающий мир знают только модули инфраструктурного уровня — какой фреймворк и что за хранилище используется. Поэтому, например, переезд с файловой системы на S3 для модулей верхнего уровня пройдет незамеченным. Да и вообще любые изменения в любом модуле нижнего уровня не будут влиять на модули верхнего уровня. Модули нижнего уровня это просто плагины для вашей логики, которые можно легко менять.

  5. Изолированные проблемы: как минимум, у вас сократится время на поиск места где что-то идет не так, как максимум, вы сможете легко реализовать graceful degradation («это ли не счастье спать спокойно?»^2).

  6. Переиспользуемая бизнес-логика: зачем нам модульность без переиспользования кода? При таком подходе логика предметной области, инкапсулированная в модулях верхнего уровня, переиспользуется «естественным» образом (что, в общем-то, и является одной из главных целей DIP).

Преимущества мы осветили, теперь можно переходить и к перечислению платы за них:

  1. Инвестиции в разработку: как ни странно, но, чтобы заработать на этом деньги, вам придется сначала инвестировать время — в людей, в процессы, в существующий код, а вероятнее всего, во все это сразу. Нельзя просто так взять и внедрить best practices (да и серебряных пуль на рынке не осталось, увы).

  2. Архитектура: сам по себе DIP налагает известные ограничения на зависимости между модулями для уменьшения связности и только. Чтобы извлечь из модульности максимальную пользу необходимы дополнительные правила взаимодействия для модулей. А дополнительные правила ведут к дополнительным затратам, особенно на первых этапах, и приносят видимые дивиденды не сразу. 

  3. Существующие стандарты: явные или неявные стандарты, договоренности и конвенции существуют всегда и на разных уровнях и, как бы то ни было, но код мы обычно пишем так, чтобы он был понятен большинству разработчиков в вашей компании (или команде). Сложившиеся практики по управлению зависимостями сулят еще одну статью затрат («нельзя просто так взять и внедрить best practices» x2).

  4. Границы применимости: в любом случае, прежде чем применять какой-либо подход, необходимо убедиться, что он принесет пользу. Например, при написании маленьких (действительно маленьких — несколько экранов кода, пара-тройка ручек и т.п.) приложений вы возможно придете к выводу, что ни DIP, ни модульность вам не нужна. А, возможно, нужна, если у маленького приложения планируется большое будущее (увы, но иногда такое будущее наступает очень внезапно). Если же у вас присутствует необходимость экономить процессорные такты, то тоже стоит получше взвесить все за и против.

  5. Вам придется писать больше кода: модули, абстракции, уровни, архитектура — все это на самом деле раздует вашу кодовую базу. Но ничего не поделаешь: если разделять большое и сложное на много маленьких и относительно простых элементов, то правила и ограничения, при которых это разделение будет иметь смысл, потребуют плату в виде строчек кода. А еще всем этим нужно будет управлять для того, чтобы в один прекрасный момент ваша жизнь не превратилась в ад (впрочем, это справедливо для любой кодовой базы и любого IT-ландшафта). 

Заключение

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

Рассмотрев DIP и ограничения, накладываемые на наш код при его применении, мы подошли к тому, что можем просуммировать некоторые принципы, которые будут полезными при декомпозиции кода приложения в модули и организации зависимостей между ними:

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

  2. DIP это про level и coupling — логика организации зависимостей между модулями подчинена иерархическим отношениям между уровнями — зависимости должны быть направлены в сторону верхнего уровня абстракции. Просто зависеть от абстракции недостаточно, важен её уровень абстракции, её расположение и то поведение, которое она описывает. 

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

  4. API модуля — абстракция это не только требования, которые реализуются где-то ниже, но и «безопасный» контракт для обращения к высокоуровневым модулям снизу. Благодаря своей стабильности подобное API может использоваться в скольких угодно модулях нижнего уровня. 

  5. Метрики модуля и framework agnostic — модули верхних уровней должны иметь метрику efferent coupling, вызванную связями с кодом на своем уровне и выше и метрику afferent coupling, вызванную связями с кодом на своем уровне и ниже. Efferent coupling модуля не должна возникать от связи с кодом, расположенным на уровнях ниже — модули не должны знать ничего о чем-то, что находится ниже их уровня абстракции. Уменьшение метрики instability у модулей должно совпадать с общим направлением зависимостей. Это облегчает их тестирование и делает модули нижнего уровня легко заменяемыми плагинами для логики верхних уровней.

  6. SLAP и модули — на каждом уровне абстракции модули должны решать характерные для уровня задачи. Необходимое модулю поведение, определяемое интерфейсами, также должно соответствовать уровню абстракции.

  7. «From top to bottom» — лучший подход к организации зависимостей это начинать с определения модулей, находящихся на самом высоком уровне, а после определять их контракты, проходя уровень за уровнем вниз. Так мы сможем лучше контролировать связность модулей и получим более устойчивый дизайн. Это и контроль связности между модулями позволит вам построить любую желаемую архитектуру, в фокусе которой будет выполнение задач предметной области.

Источники

  1. Martin R. The Dependency Inversion Principle

  2. Martin R. Solid Relevance

  3. Мартин Р. Чистая архитектура. Искусство разработки программного обеспечения. СПб.: Питер, 2020

  4. Martin R. Clean code: a handbook of agile software craftsmanship. – Pearson Education, 2009

  5. Schuchert B. L. DIP in the Wild

  6. Wikipedia Software package metrics

банки

Комментарии (1)


  1. Vitaly_js
    29.01.2025 10:57

    1. Вы вводите термин "модуль".

    2. Начинаете разбирать определение 2020 года.

    3. Вы говорите, что модули выстраиваются в определенную систему.

    4. Буквально: "Далее, чтобы продолжить применять DIP, нам необходимо выделить в коде приложения модули, а затем расположить модули относительно вертикальной оси (далее эту ось мы будем называть ось уровня абстракции), выстроив иерархию между ними"

    Как это? Что значит продолжить применять DIP? Где вы его до этого применяли, если вы только собираетесь выделять модули?

    -----

    Что бы выделить модули в коде и строить зависимости:

    1. Необходимо классифицировать код. Дан пример классификации.

    2. "Модули объединяют в более крупные структуры — слои"

    Минуточку, все это началось с того, что мы хотели выделить модули. Как это модули объединяют в слои, если мы еще не выделили модули?

    И на этом с модулями все? Наверное, можно попытаться понять, что вы имели в виду, но получилась какая-то чехарда. Строгого выстроенного введения, по моему, не получилось.

    ----

    "Особенностью большинства увиденных мной подходов к написанию кода является то, что классификация по назначению не всегда соответствует (почти никогда) реальной декомпозиции кода на структурные элементы." - это на какие элементы? Вы выше ввели термин модуль и сразу про него забыли. Это модули имеются в виду?

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

    "Итак, код нашего гипотетического приложения мы сгруппировали в модули..." - как это??? Мы его только классифицировали. В лучшем случае мы худо бедно сгруппировали код в слои. До модулей мы же не дошли. Или дошли уже?

    Дальше пока читать не буду, потому что если там так же, то ни в чем разобраться не смогу.