Давайте ещё раз поговорим о SOLID. Если ваша работа хоть как-то связана с разработкой программного обеспечения или вы просто интересуетесь программированием, вы наверняка слышали этот печально известный акроним. Ему уже посвящены бесчисленные статьи, публикации в блогах и обучающие видео. Возможно, это одна из самых обсуждаемых аббревиатур в мире разработки. Но в этой статье я хочу подробнее остановиться на последней по порядку, но не по значимости букве – D, которая обозначает принцип инверсии зависимостей (Dependency Inversion PrincipleDIP).

Почему этот принцип важен для написания поддерживаемого кода? Важен ли он вообще? Зачем всё это нужно? Для ответа на эти вопросы попробуем инвертировать зависимости в одном эндпоинтe FastAPI.

Метафора инверсии зависимостей
Метафора инверсии зависимостей

Определение

Для начала стоит определиться с терминами. Классическая формулировка принципа звучит примерно так:

  1. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те и другие должны зависеть от абстракций.

  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

Проблема

Для понимания сути принципа полезно посмотреть на какой-то код. В конце концов, всё это прежде всего именно про код. Посмотрим на следующую функцию1 обработки HTTP-запросов, написанную с использованием популярного фреймворка FastAPI.

@app.post("/tickets", status_code=201)
async def create_ticket(
    request: CreateTicketRequest,
    db: AsyncSession = Depends(get_db),
    http_client: httpx.AsyncClient = Depends(get_http_client),
) -> CreateTicketResponse:
    llm_response = await CLIENT.chat.completions.create(
        model="gpt-5-mini",
        messages=[
            {"role": "developer", "content": LLM_INSTRUCTIONS},
            {"role": "user", "content": request.message},
        ],
    )
    is_critical = llm_response.choices[0].message.content == "CRITICAL"
    ticket = Ticket(
        id=uuid4(),
        customer_email=request.customer_email,
        message=request.message,
        is_critical=is_critical,
    )
    db.add(ticket)
    await db.commit()
    if is_critical:
        await http_client.post(
            f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
            json={"chat_id": MANAGER_ID, "text": ticket.message},
        )
    return CreateTicketResponse(id=ticket.id)

Представим, что мы разрабатываем большое и сложное приложение для работы с обращениями пользователей в службу поддержки. Перед нами HTTP-обработчик, который принимает новые обращения. Сначала он определяет, является ли обращение критическим важным. Затем сохраняет его в базе данных. Наконец, если обращение критически важное, отправляет уведомление менеджеру в Telegram.

Что можно сказать об этом коде? Плох он или хорош? Ну, как минимум, этот код решает поставленную задачу и делает то, что от него требуется. Если бы нам было нужно лишь реализовать этот API-эндпоинт и больше никогда к нему не возвращаться, такой код, скорее всего, был бы вполне приемлем.

Проблема становится гораздо актуальнее, если посмотреть на ситуацию в долгосрочной перспективе. Что, если этот код придётся поддерживать неопределённо долго? Что, если над приложением работают несколько разработчиков, которым приходится каждый день читать и изменять этот код? В этом случае не всё так просто, и некоторые факторы становятся определяющими для успеха всего приложения.

Связанность

Где же в этом примере находятся модули верхнего уровня из определения DIP? К таким модулям можно отнести код, который выражает основное назначение приложения, то есть бизнес-логику. Зачем вообще существует этот обработчик? Какую задачу он решает?

При первом чтении кода из примера ответы на эти вопросы могут быть неочевидны: приходится продираться через множество второстепенных технических деталей. Бизнес-логика – основное назначение приложения – «загрязнена» низкоуровневыми деталями: выполнением HTTP-запросов и обращениями к базе данных.

Но если вчитаться в код внимательнее, то можно выделить три основные операции:

  1. Определение приоритета обращения.

  2. Сохранение обращения в базу данных.

  3. Отправка уведомления, если требуется вмешательство.

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

Бизнес-логика зависит от низкоуровневых деталей
Бизнес-логика зависит от низкоуровневых деталей

Любое изменение низкоуровневого кода так или иначе затрагивает и бизнес-логику. Что, если нам потребуется перейти на другого провайдера LLM? Или отправлять уведомления по электронной почте? В любом из этих случаев придётся изменять код высокоуровневого модуля. Технические детали оказываются сильно связаны с кодом бизнес-уровня.

Давайте представим, что речь идёт не о простом примере из статьи на Хабре, а о сотне подобных API-эндпоинтов, которые должна поддерживать наша вымышленная команда. Проблема приобретает совершенно другой масштаб. Именно сильная связанность (high coupling) в долгосрочной перспективе «убивает» программные системы. Если ею не управлять, код приложения с очень большой вероятностью превратится в неподдерживаемый «большой ком грязи» (big ball of mud).

Тестируемость

Поскольку мы стремимся быть ответственными разработчиками, наш код нужно покрыть тестами. Посмотрим, какие у нас есть варианты. Первое, что бросается в глаза, – настолько связанный код тестировать довольно трудно. Он зависит от глобальных переменных, таких как CLIENT, и от объектов сторонних библиотек, например db.

Но, к счастью, мы пишем на Python и можем воспользоваться всей его магией (ведь все любят магию, правда?). Можно на полную задействовать моки и monkey patching:

# test_api.py
# Arrange
monkeypatch.setattr(
    api,
    "CLIENT",
    SimpleNamespace(
        chat=SimpleNamespace(
            completions=SimpleNamespace(
                create=AsyncMock(return_value=_llm_response("NORMAL"))
            )
        )
    ),
)
db = Mock()
db.commit = AsyncMock()
http_client = Mock()
http_client.post = AsyncMock()

А затем протестировать обработчик с помощью всей этой конструкции:

# test_api.py
# Act
request = api.CreateTicketRequest(
    customer_email="user@example.com",
    message="The export button is slightly misaligned.",
)
response = asyncio.run(
    api.create_ticket(request=request, db=db, http_client=http_client)
)

# Assert
db.add.assert_called_once()
db.commit.assert_awaited_once()
http_client.post.assert_not_awaited()

Замечательно. Почему бы просто не использовать все эти возможности модуля unittest.mock повсюду и не сэкономить время и силы? Проблема в том, что такие тесты очень хрупкие и сложные в сопровождении. Моков слишком много. Можно легко представить, что однажды бизнес-логика изменится, а тесты при этом продолжат радостно проходить. Да и вообще, что именно мы здесь проверяем: бизнес-логику приложения или работу моков?

Если для тестирования некоторого фрагмента кода приходится так активно применять моки и monkey patching, это часто указывает на более глубокие структурные проблемы и сильную связанность. Мы не можем изолировать код для тестирования и поэтому вынуждены искать обходные пути. К тому же в Python-сообществе чрезмерное использование магических моков и monkey patching обычно считается признаком плохого дизайна. Это отношение ярко выражает фраза: «Monkey patching – это банкротство ПО».

Другой вариант, который у нас есть для тестирования, – это полностью положиться на интеграционные и сквозные (end-to-end) тесты. Мы могли бы запустить базу данных в отдельном Docker-контейнере и обращаться к ней в каждом тесте. Могли бы даже поднять отдельный HTTP-сервер для обработки тестовых запросов. Технически это сработало бы. Но представим, что у нас сотни таких тестов. Каждому из них нужен доступ к базе данных, а значит, перед тестом и после него придётся подготавливать и очищать тестовые данные, готовить специальное состояние. Всё это сложно настраивать и поддерживать, а весь набор тестов каждый раз будет выполняться непозволительно долго, снижая мотивацию и производительность нашей команды. Кроме того, если обратиться к устоявшимся практикам в индустрии, окажется, что наша пирамида тестирования перевёрнута вверх ногами. Это тоже вряд ли хороший знак.

Перевёрнутая пирамида тестирования
Перевёрнутая пирамида тестирования

Изменения

Реальный мир постоянно меняется: появляются новые технологии, входят в моду новые фреймворки и библиотеки или нашему продукт-менеджеру внезапно приходит в голову очередная блестящая идея. Как гласит известное выражение, нет ничего более постоянного, чем перемены. Поэтому и мы, и наш код должны быть к ним готовы.

Посмотрим, насколько легко в текущей реализации заменить зависимости или добавить новое поведение. Например, переход с OpenAI SDK на Anthropic SDK, как уже говорилось выше, потребовал бы изменений в модуле, который содержит бизнес-логику. Нам пришлось бы очень осторожно убирать старый связанный код и заменять его новым – практически хирургическая операция.

А что, если нам понадобится добавить мониторинг запросов к базе данных, чтобы анализировать их производительность? В текущем дизайне это могло бы выглядеть примерно так:

# async def create_ticket(...):
start = perf_counter()
ticket = Ticket(
    id=uuid4(),
    customer_email=request.customer_email,
    message=request.message,
    is_critical=is_critical,
)
db.add(ticket)
await db.commit()
end = perf_counter()
print("Total time:", end - start)
# ...

Каждый раз, когда мы вносим подобные низкоуровневые изменения в высокоуровневый модуль, вероятность случайно добавить ошибку в бизнес-логику становится выше. Каждое следующее изменение такого рода реализовать сложнее, чем предыдущее. Бизнес-логика всё сильнее и сильнее «загрязняется» посторонними техническими деталями, и сопровождать её становится всё труднее.

Понятность кода

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

При чтении подобного кода приходится одновременно разбираться в деталях HTTP-запросов, измерении производительности и работе с базой данных. Мы вынуждены постоянно переключаться между высокоуровневыми концепциями и низкоуровневыми деталями, что создаёт лишнюю когнитивную нагрузку. Как мы уже увидели, текущая реализация серьёзно ухудшает читаемость и понятность приложения. А чем труднее читать код, тем труднее его поддерживать.

DIP

Как принцип инверсии зависимостей может помочь решить все эти проблемы? Согласно DIP, низкоуровневые детали должны зависеть от высокоуровневых модулей, а не наоборот. Бизнес-логика не должна зависеть от инфраструктурных механизмов, но в текущем дизайне происходит именно это. Нам нужно каким-то образом развернуть направление зависимостей. Для этого попробуем изолировать бизнес-логику, введя абстракции, которые соответствуют нашей предметной задаче:

# core.py
class PriorityDetector(Protocol):
    async def detect(self, text: str) -> Priority: ...


class TicketRepository(Protocol):
    async def save(self, ticket: Ticket) -> None: ...


class Notifier(Protocol):
    async def notify(self, ticket: Ticket) -> None: ...


async def submit_ticket(
    customer_email: str,
    message: str,
    ticket_repository: TicketRepository,
    priority_detector: PriorityDetector,
    notifier: Notifier,
) -> Ticket:
    priority = await priority_detector.detect(message)
    ticket = Ticket(
        customer_email=customer_email,
        message=message,
        priority=priority,
    )
    await ticket_repository.save(ticket)
    if ticket.is_critical:
        await notifier.notify(ticket)
    return ticket

Здесь core.py – это модуль верхнего уровня, в котором бизнес-логика выражена через абстракции. В этом модуле нет ни OpenAI SDK, ни HTTP-запросов, ни запросов к базе данных. Функция submit_ticket представляет бизнес-логику и не зависит от инфраструктуры. Она работает с обычными Python-классами и зависит только от абстракций, как того требует первый пункт принципа.

Согласно DIP, низкоуровневые модули тоже должны зависеть от абстракций. В нашем случае можно создать отдельный модуль impl.py с конкретными классами, реализующими высокоуровневые протоколы. Например, определение приоритета обращения можно реализовать с помощью OpenAI SDK:

# impl.py
class OpenAiPriorityDetector(PriorityDetector):
    def __init__(self, api_key: str) -> None:
        self._client = AsyncOpenAI(api_key=api_key)

    async def detect(self, text: str) -> Priority:
        llm_instructions = (
            "Analyze the priority of the following support ticket. "
            "Respond with 'CRITICAL' or 'NORMAL'."
        )
        llm_response = await self._client.chat.completions.create(
            model="gpt-5-mini",
            messages=[
                {"role": "developer", "content": llm_instructions},
                {"role": "user", "content": text},
            ],
        )
        return (
            Priority.CRITICAL
            if llm_response.choices[0].message.content == "CRITICAL"
            else Priority.NORMAL
        )

TicketRepository и Notifier 2 можно реализовать аналогичным образом, используя любые подходящие технологии и библиотеки. Теперь зависимости инвертированы.

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

Обратите внимание: именно высокоуровневый модуль core.py «владеет» абстракциями. Они определены строго в терминах предметной области, а не технических механизмов. У нас нет протокола OpenAiClient или абстрактного класса TelegramClient. Детали зависят от абстракций, как того требует второй пункт принципа.

Связанность

По сути, мы «развязали» бизнес-логику и низкоуровневые детали. Теперь оба уровня нашего приложения гораздо проще изменять независимо друг от друга. Например, если будет нужно отправлять уведомления для всех обращений, а не только для критических, то такое изменение можно локально внести в функцию submit_ticket. Остальные части приложения останутся нетронутыми. Точно так же OpenAI SDK можно заменить на Anthropic SDK, просто реализовав новый класс, соответствующий протоколу PriorityDetector. Содержащий бизнес-логику модуль при этом вообще не изменится.

Тестируемость

Поскольку бизнес-логика теперь полностью изолирована, для неё можно написать столько модульных тестов, сколько потребуется. Все пограничные случаи и редкие сценарии можно проверять отдельно от тяжёлых инфраструктурных зависимостей. Для таких тестов не нужно поднимать и очищать базу данных или запускать HTTP-сервер. Кроме того, выполняться они будут гораздо быстрее.

Функция submit_ticket явно перечисляет все свои зависимости в сигнатуре. Поэтому для тестирования остаётся только реализовать несколько вспомогательных классов, которые обычно называют тестовыми дублёрами (test doubles):

# conftest.py
class FakePriorityDetector(PriorityDetector):
    CRITICAL_MESSAGE = "critical"

    async def detect(self, text: str) -> Priority:
        return (
            Priority.CRITICAL
            if text == self.CRITICAL_MESSAGE
            else Priority.NORMAL
        )


class FakeTicketRepository(TicketRepository):
    # ...


class FakeNotifier(Notifier):
    # ...

После этого модульный тест можно написать с использованием этих тестовых реализаций:

# test_core.py
async def test_submit_ticket_notifies_for_critical_ticket(
    ticket_repository: FakeTicketRepository,
    priority_detector: FakePriorityDetector,
    notifier: FakeNotifier,
) -> None:
    # Act
    ticket = await submit_ticket(
        customer_email="customer@example.com",
        message=FakePriorityDetector.CRITICAL_MESSAGE,
        ticket_repository=ticket_repository,
        priority_detector=priority_detector,
        notifier=notifier,
    )
    # Assert
    assert await ticket_repository.get(ticket.id) == ticket
    assert ticket.priority is Priority.CRITICAL
    notifier.assert_notification_sent()

Интеграционные тесты, в свою очередь, можно написать отдельно для каждой конкретной реализации зависимости. Например, чтобы проверить взаимодействие с настоящей базой данных, можно создать несколько интеграционных тестов для PostgresTicketRepository (или репозитория для любой другой используемой БД) и убедиться, что все запросы работают правильно. Нет необходимости запускать каждый небольшой тест с реальной базой данных. Достаточно делать это только для той части набора тестов, которой действительно нужна БД. Большую часть приложения теперь можно проверять изолированно с помощью модульных тестов. Пирамида тестирования снова стоит устойчиво на широком основании.

Устойчивая пирамида тестирования
Устойчивая пирамида тестирования

Изменения

Благодаря слабой связанности добавлять новое поведение и изменять существующее стало гораздо проще. Замена одной реализации протокола на другую – например, уже упомянутый переход на Anthropic SDK – теперь почти тривиальна. Достаточно написать новый класс, реализующий соответствующий протокол.

Посмотрим ещё раз на пример с мониторингом запросов к базе данных. Теперь высокоуровневый код вообще не нужно изменять. Вместо этого мы можем добавить новое поведение, используя паттерн проектирования «Декоратор»:

class TicketRepositoryWithInstrumentation(TicketRepository):
    def __init__(self, inner: TicketRepository) -> None:
        self._inner = inner

    async def save(self, ticket: Ticket) -> None:
        start = perf_counter()
        await self._inner.save(ticket)
        end = perf_counter()
        print("Total time:", end - start)

Объект этого класса можно просто передать в высокоуровневую функцию submit_ticket в качестве аргумента. Что особенно важно, с точки зрения бизнес-логики ничего не изменилось: функция по-прежнему принимает какую-то реализацию протокола TicketRepository. Но фактически мы добавили в приложение новое поведение. Например, если потребуется добавить кэширование на уровне работы с базой данных, тот же приём можно повторить ещё раз с другим классом-декоратором.

Использование паттерна «Декоратор» для добавления новой функциональности
Использование паттерна «Декоратор» для добавления новой функциональности

Это также пример принципа открытости/закрытости (open–closed principle) в действии: мы добавляем новое поведение, не изменяя существующие классы и модули.

Понятность кода

После применения DIP проблема смешения разных уровней абстракции становится гораздо менее выраженной. Модули лучше отделены друг от друга. Бизнес-логика в модуле core.py содержит только высокоуровневый код. Низкоуровневые детали также выделены в отдельные модули. Читателю кода больше не нужно пробираться через лабиринт технических нюансов, чтобы понять бизнес-логику.

Можно построить всё приложение на основе одного простого, но строгого правила – правила направления зависимостей. В совокупности всё это снижает когнитивную нагрузку при чтении и понимании кода.

В качестве заключения

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

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

Но если над приложением работает команда из нескольких разработчиков, а срок его поддержки заранее не ограничен, ситуация меняется. В таком случае связанностью необходимо управлять. Нужно внимательно проектировать абстракции и бережно относиться к бизнес-логике. Иначе «большой ком грязи» всегда готов начать катиться.

Что ещё почитать

  1. Глава On Coupling and Abstractions из отличной книги Architecture Patterns with Python.

  2. Layers, Onions, Ports, Adapters: it’s all the same.

  3. DIP in the Wild.

  4. Increasing Cohesion in Go with Generic Decorators.

Примечания

  1. Полная версия кода доступна в репозитории на Github.

  2. В реальном приложении стоит рассмотреть использование паттерна Outbox для отправки уведомлений.

  3. Оригинальная англоязычная версия этой статьи опубликована в моём блоге.