Введение
В предыдущей части мы говорили о том, что каждая парадигма программирования не расширяет возможности разработчика, а сознательно их ограничивает. Структурное программирование забрало неограниченные переходы, а взамен дало возможность разбирать программу на части и проверять каждую отдельно.
Теперь очередь второй парадигмы - объектно-ориентированной.
Разговор о ней мы начнём с ситуации, которая многим знакома. Бизнес просит заменить хранилище или добавить второй платёжный шлюз. По существу задача невелика: меняется одна деталь, правила расчёта остаются прежними. Но на оценке выясняется, что трогать придётся гораздо больше кода, чем казалось.
Разберём, откуда это берётся. Корень проблемы обнаружится в месте, на которое при написании кода смотрят в последнюю очередь.
Зависимость исходного кода
Возьмём интернет-магазин. В нём есть модуль, принимающий заказ: проверяет корзину, считает сумму, сохраняет результат. Назовём его ordering. И есть модуль работы с базой данных - persistence. База данных используется Postgres.
Чтобы сохранить заказ, ordering вызывает persistence. Значит, в его коде должна быть строка подобного вида:
import persistence.PostgresOrderRepository
Для тех, кто только начинает: зависимость - это ответ на вопрос «что ещё требуется под рукой, чтобы этот файл скомпилировался». Строка import говорит компилятору: здесь используется чужой код, найди его. Пока такая строка есть, два модуля связаны - один не существует без другого.
Что теперь нельзя сделать с модулем ordering?
Собрать отдельно - компилятору понадобится persistence, а тому драйвер базы данных. Протестировать без базы - код расчёта привязан к PostgresOrderRepository, заглушку подставить некуда. Отдать другой команде разработчиков - он утащит за собой половину системы. Заменить хранилище, не трогая расчёт заказа, - тоже нет, потому что расчёт упоминает хранилище по имени.
Ни одно из этих ограничений не связано с тем, что модуль считает. Логика скидок, налогов и доставки работала бы и в вакууме. Связала её строка импорта.
Теперь вопрос, ради которого всё затевалось. Эту строку приходится писать потому, что ordering вызывает persistence: чтобы вызвать чужой код, надо на него сослаться. Направление вызова диктует задача - следовательно, направление зависимости тоже вроде бы диктует задача.
Оказывается, не диктует. Объектно-ориентированные языки дают механизм, который разворачивает эту стрелку, оставляя вызов на месте. В этом, если смотреть глазами архитектора, их основная ценность.
До механизма доберёмся через пару разделов. Сначала придётся расчистить площадку: привычные ответы на вопрос «что такое ООП» нашей задаче не помогают, и стоит понять почему.
Почему привычные определения не помогают
Спросите нескольких разработчиков, что такое объектно-ориентированное программирование, и скорее всего вы услышите одну из двух формулировок.
Первая: это объединение данных и функций в одном месте. Определение подразумевает, что вызов o.f() отличается от вызова f(o). Разница есть, но небольшая: в первом случае объект передаётся неявно, во втором явно. Структуры передавали в функции первым аргументом задолго до появления объектно-ориентированных языков.
На нашу задачу это не влияет никак. Соберите данные и функции хоть в одном классе, хоть в десяти - строка import persistence.PostgresOrderRepository останется на месте.
Вторая: это способ моделировать реальный мир. Здесь сложнее понять, что имеется в виду. Что означает «моделировать реальный мир» применительно к банковской транзакции, HTTP-запросу или индексу базы данных? Предполагается, видимо, что близость к реальности делает код понятнее. Возможно, так и есть. Но куда девать злополучный импорт, из этого не следует.
Для тех, кто только начинает: речь не о том, что объектно-ориентированный подход бесполезен. Речь о том, чего хочется от определения: чтобы оно работало как инструмент и отвечало на вопрос «что я стану делать по-другому, узнав это». Оба ответа выше на него не отвечают.
Остаётся третий вариант, самый расхожий: инкапсуляция, наследование и полиморфизм. Три слова, знакомые по собеседованиям.
Проверим их по очереди на нашей задаче. Вопрос один: помогает ли это убрать зависимость от базы данных.
Инкапсуляция
Инкапсуляция очерчивает границу вокруг группы данных и функций: снаружи видны некоторые функции, данные скрыты. Приватные поля, публичные методы.
Задачу не решает. Сделайте поля PostgresOrderRepository приватными - импорт в модуле ordering останется. Инкапсуляция прячет внутренности класса от пользователя, но не отменяет самого факта использования.
При этом инкапсуляцию объектно-ориентированные языки не изобрели. В языке C она была устроена так, что скрывала больше.
Вот заголовочный файл на C - это всё, что видит пользователь:
/* point.h */
struct Point; /* объявлена, но не описана */
struct Point* makePoint(double x, double y);
double distance(struct Point* p1, struct Point* p2);
Устройство структуры живёт в отдельном файле и наружу не выходит:
/* point.c */
struct Point {
double x, y;
};
Для тех, кто только начинает: пример на старом языке приведён ради детали, которой в Java, C# и Kotlin нет, - физического разделения объявления и реализации на два файла.
Деталь вот какая. Пользователь point.h не только не обратится к полям x и y - он вообще не знает об их существовании. Переименуйте поля, добавьте третье, смените типы: вызывающие модули не потребуют перекомпиляции, достаточно пересобрать файл реализации и слинковать заново.
Потом пришёл C++ с классами, и поля переехали в заголовок:
// point.h
class Point {
public:
Point(double x, double y);
double distance(const Point& p) const;
private:
double x; // видны всем, кто подключит заголовок
double y;
};
Компилятор по-прежнему не даст обратиться к x и y напрямую. Но теперь они написаны в файле, открытом для клиентов, и переименование поля заставит пересобрать каждого. Справедливости ради: в C++ скрытие восстанавливают приёмом pimpl - поля уезжают во вложенную структуру, в заголовке остаётся указатель на неё. Приём рабочий, хотя и не бесплатный: добавляется выделение памяти, теряется встраивание.
Java, C# и Kotlin отменили разделение на объявление и реализацию вовсе. Приватное поле в Kotlin держится на обещании компилятора, а на JVM вызов setAccessible(true) снимает это обещание.
Выходит, языки инкапсуляцию скорее ослабили. Держится она не столько на языке, сколько на договорённостях внутри команды.
Где инкапсуляция всё-таки работает
Из этого не следует, что она не нужна. Скорее, искать её стоит не на уровне класса, а на уровне модуля.
Граница, способная удержать, проходит по единице сборки. В Kotlin за это отвечает модификатор internal:
// модуль billing
// Видно всем, кто подключил модуль
interface InvoiceCalculator {
fun calculate(order: Order): Invoice
}
// Видно только внутри модуля billing
internal class DefaultInvoiceCalculator(
private val taxPolicy: TaxPolicy
) : InvoiceCalculator {
override fun calculate(order: Order): Invoice { /* ... */ }
}
Модулем документация Kotlin называет набор файлов, компилируемых вместе: модуль IntelliJ IDEA, проект Maven, исходный набор Gradle. Соседний модуль на DefaultInvoiceCalculator не сошлётся - компилятор запретит.
Приватное поле просит не трогать. Отдельный модуль со скрытыми классами не даёт дотянуться. Первое - соглашение, второе - ограничение, а в прошлой части мы видели, что архитектура строится на ограничениях.
Для специалистов с опытом: оговорка, о которой в учебниках обычно не пишут. Запрет живёт на уровне компилятора Kotlin. Документация по совместимости с Java говорит прямо: internal-объявления становятся в Java публичными. Имена internal-членов компилятор искажает, чтобы ими не воспользовались по случайности, но имена публичных членов internal-класса не искажаются и остаются вызываемыми из Java. Команде на чистом Kotlin этой границы обычно хватает; в смешанном проекте её подпирают разделением артефактов сборки. В Java похожую роль играют пакетная видимость и система модулей с файлом module-info.java.
Пока запомним и двинемся дальше: первое слово из трёх нашу задачу не решило.
Наследование
Наследование позволяет объявить класс через другой класс, добавив своё. Результат тот же: от какого бы класса ни наследовался PostgresOrderRepository, модуль ordering обязан его импортировать, чтобы создать.
Приём существовал и до объектно-ориентированных языков, только делался руками: объявить структуру, первые поля которой повторяют поля другой структуры, в том же порядке.
struct NamedPoint {
double x, y; /* те же два поля, что и в Point, в том же порядке */
char* name;
};
Раскладка полей в памяти совпадает, поэтому указатель на NamedPoint приводят к указателю на Point и передают в функцию, не подозревающую о существовании NamedPoint:
printf("distance=%f\n",
distance((struct Point*) origin, (struct Point*) corner));
Работает: программа печатает distance=1.414214. Примерно так же компиляторы C++ реализуют одиночное наследование - подобъект базового класса размещается в начале производного. Правда, раскладку диктует соглашение платформы, а не стандарт языка, и с появлением виртуальных функций в начало объекта встаёт указатель на таблицу методов, после чего совпадение со структурой пропадает.
Полноценным наследованием такой приём не назвать. Приведение типа пишется руками, компилятор ничего не проверяет, перестановка полей ломает программу незаметно. Языки сделали то же самое безопасным - улучшение, пусть и не изобретение:
open class Point(val x: Double, val y: Double)
class NamedPoint(x: Double, y: Double, val name: String) : Point(x, y)
// приведение типов не требуется, компилятор проверит сам
Про слово open
Без open этот код не скомпилируется: в Kotlin класс по умолчанию закрыт для наследования.
С темой статьи решение связано напрямую. Наследование создаёт очень жёсткую связь: наследник зависит не только от публичного контракта предка, но и от его внутреннего устройства. Правка в базовом классе ломает потомков, невидимых автору правки.
Отсюда известное правило, сформулированное в 1994 году в книге «Приёмы объектно-ориентированного проектирования» Гаммы, Хелма, Джонсона и Влиссидеса: предпочитайте композицию объектов наследованию классов. Там же сказано и то, что наследование нарушает инкапсуляцию.
// Сомнительно: отчёт наследуется ради пары чужих методов
class PdfReport : ReportBase() { /* ... */ }
// Лучше: отчёт получает то, что ему требуется, и ничего не наследует
class PdfReport(private val renderer: DocumentRenderer) { /* ... */ }
Во втором варианте PdfReport не знает внутреннего устройства renderer.
Два слова из трёх проверены, задача на месте. Остаётся третье.
Полиморфизм
Полиморфизм означает, что один и тот же вызов приводит к выполнению разного кода - смотря какой объект оказался на другом конце.
Вернёмся к магазину. Вместо прямого вызова хранилища опишем, что нам от него нужно:
interface OrderRepository {
fun findById(id: OrderId): Order?
fun save(order: Order)
}
Это интерфейс - список обещаний без строчки исполнения. Кто бы ни взялся быть хранилищем заказов, он обязан уметь найти заказ по идентификатору и сохранить заказ. Как именно - его дело.
Код расчёта теперь принимает такое хранилище снаружи:
class PlaceOrder(private val orders: OrderRepository) {
fun execute(draft: OrderDraft): Result<Order> {
val order = Order.from(draft).getOrElse { return Result.failure(it) }
orders.save(order) // кто именно сохранит - неизвестно
return Result.success(order)
}
}
А реализаций может быть сколько угодно, и PlaceOrder о них не знает:
class PostgresOrderRepository(private val db: Database) : OrderRepository { /* ... */ }
class InMemoryOrderRepository : OrderRepository { /* ... */ } // для тестов
Для тех, кто только начинает: бытовая аналогия - розетка. Производитель чайника не знает, какая электростанция даст ток, а электростанция не знает про чайники. Обе стороны знают стандарт розетки. Поэтому чайник работает в любом доме, а станцию можно заменить, не меняя чайники. Аналогия грубая, но направление мысли передаёт.
Как это устроено внутри
Механизм придумали задолго до объектно-ориентированных языков. Понимать его полезно - иначе полиморфизм остаётся магией.
Ядро UNIX хранило таблицу драйверов устройств, где каждая запись была набором указателей на функции с заранее оговорёнными сигнатурами. В шестой и седьмой редакциях структура символьного устройства cdevsw содержала пять таких указателей: открыть, закрыть, прочитать, записать и управлять устройством. Последний назывался d_sgtty и позже превратился в привычный ioctl.
На языке C подобную таблицу собирают руками:
/* Упрощение: в настоящем ядре структура называлась cdevsw */
struct Device {
void (*open)(char* name, int mode);
int (*read)(void);
void (*write)(int c);
};
Драйвер консоли заполняет её указателями на свои функции:
struct Device console = { consoleOpen, consoleRead, consoleWrite };
И чтение символа сводится к вызову через указатель - адрес функции становится известен во время работы программы:
int readChar(void) {
return STDIN->read();
}
В основе полиморфизма лежит именно это: вызов функции по адресу, выбранному во время выполнения. Когда вы вызываете метод интерфейса в Kotlin, работает та же модель, только таблицу строит и проверяет компилятор.
Разница между двумя записями не в возможностях, а в цене ошибки. Указатели на функции легко забыть инициализировать, легко перепутать местами, легко вызвать в обход правил - и ошибка проявится не там, где сделана. Языки избавили от необходимости держать соглашения в голове, и полиморфизм подешевел до того уровня, когда его применяют повсюду.
В этом смысле объектно-ориентированное программирование и накладывает ограничение на косвенную передачу управления: оно забирает свободное обращение с указателями на функции, а взамен даёт механизм, проверяемый компилятором.
Инструмент получен. Посмотрим, что он делает с нашей строкой импорта.
Инверсия зависимости
Для начала разделим два понятия, которые в разговоре часто сливаются.
Поток управления - кто кого вызывает во время работы программы.
Зависимость исходного кода - кто на кого ссылается в тексте программы, та самая строка import.
До появления надёжного полиморфизма эти две вещи были связаны жёстко. Главная функция вызывает функции верхнего уровня, те - функции среднего уровня, те - нижнего. Чтобы вызвать функцию, модуль обязан на неё сослаться. Обе стрелки смотрят в одну сторону.

Рисунок 1. В классической многоуровневой структуре зависимость исходного кода повторяет поток управления. У архитектора нет выбора: направление связей задано тем, кто кого вызывает.
Та же картина, с которой мы начали. Модуль расчёта заказа вызывает хранилище - значит, импортирует его. Правила скидок оказываются привязаны к драйверу PostgreSQL, хотя сами скидки от хранилища не зависят.
Полиморфизм эту связь разрывает.

Рисунок 2. Слева прямая связь: ссылка в исходном коде повторяет вызов. Справа та же пара модулей через интерфейс. Поток управления по-прежнему идёт от HL1 к ML1, а зависимость исходного кода от ML1 направлена вверх, к интерфейсу, то есть против потока управления.
Разберём правую половину рисунка.
Модуль верхнего уровня HL1 вызывает метод F(). В тексте программы он ссылается только на интерфейс I, лежащий здесь же, в его собственном модуле. Модуль ML1 этот интерфейс реализует и потому ссылается на него снизу вверх, пересекая границу модуля.
А во время выполнения никакого интерфейса не существует. HL1 вызывает код внутри ML1, как и раньше, поток управления не изменился ни на шаг. Изменилось направление зависимости в тексте программы - теперь оно противоположно направлению вызова.
Приём называется инверсией зависимости.
Вот наш магазин после перестановки:
// ===== Модуль ordering: бизнес-правила =====
// Здесь объявлен интерфейс. Ссылок на базу данных нет
interface OrderRepository {
fun findById(id: OrderId): Order?
fun save(order: Order)
}
class PlaceOrder(private val orders: OrderRepository) { /* ... */ }
// ===== Модуль persistence: деталь реализации =====
// Ссылается на модуль ordering. Обратной ссылки нет
internal class PostgresOrderRepository(
private val db: Database
) : OrderRepository {
override fun findById(id: OrderId): Order? =
db.query("SELECT ... WHERE id = ?", id.value)?.toDomain()
override fun save(order: Order) {
db.upsert(order.toRow())
}
}
Строка import persistence.PostgresOrderRepository из модуля ordering исчезла. Вместо неё появилась обратная ссылка в модуле persistence. Вызов при этом идёт по-прежнему из бизнес-логики в базу.
Тот же приём применим и к остальным стрелкам с первого рисунка: направление каждой из них задаётся отдельно, независимо от того, кто кого вызывает. Архитектор получает контроль над структурой связей в тексте программы - а она, как мы говорили в первой части, и определяет стоимость будущих изменений.
Приём несложен, и, может быть, поэтому его часто применяют формально. Об этом стоит сказать отдельно.
Кому принадлежит интерфейс
Интерфейс стоит объявлять в модуле, который его использует, а не в модуле, который его реализует.
Объявите OrderRepository в модуле persistence рядом с реализацией - и инверсии не получится. Бизнес-логике придётся импортировать тип из модуля базы данных, теперь уже интерфейс вместо класса. Стрелка осталась на месте, сущностей стало больше.
Проверить себя несложно. Удалите модуль persistence из сборки и попробуйте скомпилировать ordering. Собралось - инверсия настоящая. Не собралось - значит, нет.
Из того же рассуждения следует, как формулировать интерфейс. Он описывает потребность вызывающей стороны, а не возможности реализации:
// Признак проблемы: интерфейс описывает устройство хранилища
interface OrderRepository {
fun executeQuery(sql: String): ResultSet
fun beginTransaction(): Transaction
}
// Лучше: интерфейс описывает потребность бизнес-логики
interface OrderRepository {
fun findById(id: OrderId): Order?
fun save(order: Order)
}
Второй вариант переживёт смену хранилища. Первый - вряд ли: в него уже протекли понятия конкретной технологии, строка запроса и транзакция. Интерфейс, повторяющий публичные методы одного класса, обычно писали от реализации. Меняется он тогда же, когда меняется класс.
Для специалистов с опытом: стоит разделять принцип и инструмент. Инверсия зависимости - про направление ссылок в исходном коде. Внедрение зависимостей - про способ передать реализацию в объект, и оно работает через обычный конструктор. Контейнер внедрения зависимостей - механика сборки для больших систем. Само по себе подключение контейнера инверсии не даёт: если интерфейс объявлен не в том модуле, направление стрелок от фреймворка не зависит.
Что это даёт системе
Развернув зависимости, систему выстраивают так, чтобы пользовательский интерфейс и база данных зависели от бизнес-правил, а не наоборот.

Рисунок 3. Все стрелки зависимости направлены внутрь, к бизнес-правилам, хотя поток управления идёт в обе стороны. Интерфейс, хранилище и внешние службы становятся сменными модулями вокруг неизменного ядра.
В коде бизнес-правил нет упоминаний ни об интерфейсе, ни о базе данных, ни о платёжном шлюзе. Они стали плагинами - подключаемыми модулями, замена которых ядра не касается.
Идея появилась из практической задачи. В пятидесятых программы писали под конкретное устройство: прочитать колоду перфокарт, пробить новую с результатом. Потом заказчики понесли данные на магнитной ленте, и программу пришлось переписывать, хотя логика обработки не изменилась ни на букву. Сменились поколения техники, а задача осталась та же, с которой мы начали статью.
Из такой схемы вытекают три следствия. Каждое сокращает свою статью расходов - те самые усилия на изменения из первой части.
Независимость сборки. Бизнес-правила, интерфейс и работа с данными собираются в три отдельных модуля, и модуль бизнес-правил не зависит ни от одного из остальных.
Сокращается рабочее время. Правка задевает один модуль - пересобирается один модуль, и цикл «изменил, собрал, проверил» укорачивается. Эффект заметен не столько на отдельной правке, сколько на их количестве: разработчик проходит этот цикл много раз в день. Есть и побочный эффект: при долгой сборке её перестают запускать после каждой мелкой правки, изменения копятся пачками, и ошибки в них обнаруживаются позже, чем могли бы.
Независимость развёртывания. Изменилась реализация хранилища - пересобрать и выкатить придётся только её.
Экономия здесь не на сборке, а на проверках и риске. Выкатывая один модуль, вы проверяете один модуль; при отказе откатываете тоже один. Объём регрессионного тестирования и площадь возможной поломки задаются не размером правки, а размером того, что уезжает в продакшен.
Независимость разработки. Модули, развёртываемые по отдельности, можно писать по отдельности - разными людьми и разными командами.
Сокращается согласование. Его стоимость растёт не столько от числа людей, сколько от числа мест, где их работа пересекается. Интерфейс сводит переговоры к разовой договорённости: условились один раз, дальше две команды идут параллельно.
Конкретные цифры здесь привести не получится - они зависят от размера системы, частоты релизов и устройства сборки, и переносить их с проекта на проект бессмысленно. Но статьи расходов именно эти три, и разговор с руководством в них ведётся предметнее, чем в абстрактном «качестве кода».
Четвёртое следствие: тест без базы данных
Вернёмся к одной из бед, с которых начиналась статья, - невозможности написать тест, не подняв базу:
class InMemoryOrderRepository : OrderRepository {
private val storage = mutableMapOf<OrderId, Order>()
override fun findById(id: OrderId) = storage[id]
override fun save(order: Order) { storage[order.id] = order }
}
@Test
fun `заказ с пустой корзиной не принимается`() {
val useCase = PlaceOrder(InMemoryOrderRepository())
val result = useCase.execute(OrderDraft(items = emptyList()))
assertTrue(result.isFailure)
}
Ни базы данных, ни контейнера, ни сетевых вызовов. Тест отрабатывает быстро и падает тогда, когда сломана бизнес-логика.
Существует такое высказывание: Если что-то трудно протестировать, смотреть надо на структуру, а не на тесты. Вот продолжение этой мысли - тестируемость получается побочным эффектом правильно направленных зависимостей.
Где этого делать не надо
Остался вопрос, без ответа на который совет легко довести до абсурда. Инвертировать все зависимости подряд незачем.
Каждый интерфейс - лишняя сущность, дополнительный уровень косвенности и чуть большее усилие при чтении кода. Платить эту цену стоит там, где проходит граница: между бизнес-логикой и внешним миром. База данных, сторонние службы, файловая система, доставка сообщений, пользовательский интерфейс.
Внутри одного смыслового слоя интерфейсы чаще мешают. Класс, считающий налог, и класс, применяющий скидку, живут в одном модуле, меняются по одним причинам и обходятся прямыми вызовами. Интерфейс между ними ничего не развяжет, зато заставит прыгать по файлам при чтении.
Практический ориентир: инвертируйте зависимость там, где вы можете внятно назвать второй возможный вариант реализации. Для хранилища он есть почти всегда - хотя бы версия в памяти для теста. Для класса расчёта налога его обычно нет, и интерфейс там, скорее всего, преждевременен.
Здесь же пригождается приём из раздела об инкапсуляции. Классы внутри модуля-плагина помечают как internal: наружу торчит реализуемый интерфейс, остальное соседям недоступно. Граница, проведённая направлением зависимостей, подкрепляется границей видимости.
Заключение
Коротко о главном.
Начали мы с задачи: заменить базу данных, добавить платёжный шлюз, написать тест - и увидели, что мешает этому строка импорта, связывающая бизнес-логику с деталью реализации.
Из трёх привычных понятий ООП два этой задаче не помогают. Инкапсуляция появилась раньше объектно-ориентированных языков, и языки её скорее ослабили; рабочая граница проходит по модулю сборки, а не по ключевому слову private. Наследование тоже существовало до них в виде ручного приёма, а связь создаёт жёсткую, так что композиция нередко оказывается удачнее.
Задачу решает третье понятие. Полиморфизм был возможен и раньше - через указатели на функции, - но обходился слишком дорого в ошибках. Языки сделали его дешёвым и проверяемым.
Для архитектора объектно-ориентированное программирование означает прежде всего контроль над направлением зависимостей в исходном коде. Стрелку разворачивают вставкой интерфейса, и поток управления этому не мешает.
Отсюда вырастает архитектура сменных модулей, где база данных и пользовательский интерфейс становятся плагинами к бизнес-правилам. Выигрыш распределяется по трём статьям: время сборки, объём проверок при релизе, затраты на согласование между командами.
Интерфейс стоит объявлять там, где им пользуются, а не там, где его реализуют. Проверка простая: удалите модуль с реализацией и посмотрите, соберётся ли оставшееся.
И последнее: инвертировать всё подряд не нужно. Интерфейс оправдан на границе, за которой лежит внешний мир или реальная альтернатива. Внутри одного слоя он добавляет шума.
Спасибо, что дочитали до конца.
В следующей части мы разберём третью, последнюю парадигму - функциональное программирование. Поговорим о том, почему ограничение на присваивание оказалось не причудой математиков, что такое функциональное ядро в императивной оболочке и почему хранение событий вместо текущего состояния меняет взгляд на устройство данных.
Следите за продолжением.