Слоистая, гексагональная и чистая архитектура

Как организовать код приложения целиком, чтобы бизнес-логика не зависела от базы данных, фреймворка и протокола.

Слоистая, гексагональная и чистая архитектура — это три способа расположить код приложения слоями так, чтобы зависимости были направлены внутрь, к бизнес-логике: домен (правила предметной области) не знает ни про SQL, ни про HTTP, ни про конкретный фреймворк, а инфраструктура подключается к нему снаружи.

На уровне одного класса нам помогали GoF-паттерны. Но когда классов сотни, встаёт вопрос покрупнее: что от чего зависит? Если бизнес-правила напрямую вызывают ORM, а ORM тащит за собой драйвер БД, то «правило начисления скидки» нельзя ни протестировать без поднятой базы, ни перенести на другую СУБД. Слоистые архитектуры наводят порядок в направлении зависимостей — и это, возможно, самое важное архитектурное решение в проекте.

Слоистая архитектура: этажи здания

Самый распространённый подход — разбить приложение на горизонтальные слои, где каждый верхний зависит только от соседнего нижнего:

┌─────────────────────────────┐
│  Presentation (контроллеры)  │  HTTP, форматы ответа
├─────────────────────────────┤
│  Application (сценарии)       │  координация use case'ов
├─────────────────────────────┤
│  Domain (бизнес-правила)      │  сущности, инварианты
├─────────────────────────────┤
│  Infrastructure (БД, почта)   │  SQL, внешние API
└─────────────────────────────┘

Правило простое: верхний слой знает о нижнем, но не наоборот. Презентация вызывает сценарии, сценарии — домен. Проблема классической слоёнки в том, что домен в ней лежит над инфраструктурой и потому зависит от неё: чтобы сохранить заказ, доменный код вызывает слой БД. Это и есть та самая нежелательная связь, которую исправляют гексагональная и чистая архитектуры.

Гексагональная: порты и адаптеры

Гексагональная архитектура (Алистер Кокбёрн, «Ports and Adapters») переворачивает зависимость. В центре — приложение (домен + сценарии). Оно общается с внешним миром только через порты — интерфейсы, которые объявляет само приложение. Конкретные реализации этих интерфейсов — адаптеры — живут снаружи и подключаются к портам.

  • Порт — это контракт, нужный приложению («мне нужно где-то хранить пользователей»). Он описан в терминах домена, а не в терминах SQL.
  • Адаптер — конкретная реализация порта (PostgreSQL-репозиторий, заглушка в памяти, REST-клиент). Адаптеров может быть много для одного порта.

Ключ — в направлении: порт принадлежит приложению, адаптер зависит от порта (а не порт от адаптера). Поэтому стрелка зависимости идёт внутрь. Покажем это на чистом Python через абстрактный класс-порт:

from abc import ABC, abstractmethod


class UserRepository(ABC):          # ПОРТ: контракт, объявленный доменом
    @abstractmethod
    def save(self, name): ...

    @abstractmethod
    def count(self): ...


class RegisterUser:                 # СЦЕНАРИЙ: знает только про порт
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def execute(self, name):
        if not name.strip():
            raise ValueError("Имя не может быть пустым")
        self.repo.save(name)
        return f"Пользователь {name!r} зарегистрирован, всего: {self.repo.count()}"


class InMemoryUserRepository(UserRepository):   # АДАПТЕР: реализация порта
    def __init__(self):
        self._users = []

    def save(self, name):
        self._users.append(name)

    def count(self):
        return len(self._users)


repo = InMemoryUserRepository()
use_case = RegisterUser(repo)

print(use_case.execute("Ada"))
print(use_case.execute("Linus"))

Вывод:

Пользователь 'Ada' зарегистрирован, всего: 1
Пользователь 'Linus' зарегистрирован, всего: 2

Сценарий RegisterUser вообще не знает, где хранятся данные — в памяти, в Postgres или в файле. Чтобы переключиться на реальную БД, мы пишем новый адаптер PostgresUserRepository(UserRepository) и подставляем его в конструктор — ни строки в RegisterUser менять не нужно. А в тестах подходит InMemoryUserRepository: бизнес-логика проверяется мгновенно, без базы.

Чистая архитектура: правило зависимостей

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

Кольцо (изнутри наружу)Что содержитЗнает о внешних?
Entities (сущности)бизнес-правила предприятиянет
Use Cases (сценарии)правила приложения, портынет
Adapters (адаптеры)контроллеры, презентеры, репозиториида (о сценариях)
Frameworks (рамки)веб-фреймворк, БД, UIда (обо всём)

Как же внутренний сценарий «вызывает» базу, не зная о ней? Через инверсию зависимостей: сценарий объявляет интерфейс (порт) и зовёт его; реализация снаружи. Так стрелка кода идёт внутрь, хотя поток управления во время выполнения — наружу. Это та же связка порт/адаптер, что и в гексагоне, только вписанная в кольца. На практике границы выражают абстрактными классами и внедрением зависимостей: главный модуль («composite root») на старте создаёт конкретные адаптеры и передаёт их в сценарии.

Как это работает под капотом

Магии тут нет — всё держится на одном приёме: принципе инверсии зависимостей (буква D из SOLID). Если высокоуровневый модуль (домен) не должен зависеть от низкоуровневого (БД), мы вставляем между ними абстракцию (порт), и оба зависят от неё. Низкоуровневый модуль реализует абстракцию — значит, стрелка зависимости от него идёт вверх, к домену, а не вниз. В коде это абстрактный класс или интерфейс: домен импортирует UserRepository (порт), адаптер импортирует UserRepository и наследует его — но домен про адаптер не знает ничего. Компилятор/интерпретатор подтверждает изоляцию: в доменных модулях просто нет import инфраструктурных пакетов. Сборка зависимостей происходит в одной точке входа, где конкретные классы «впрыскиваются» в абстрактные параметры. Поэтому тест домена не требует ни сети, ни диска: вы подаёте фейковый адаптер — и проверяете чистую логику за миллисекунды. Именно эта проверяемость и независимость от инфраструктуры — главная практическая выгода, ради которой всё затевается.

Частые ошибки

  • Доменный код импортирует ORM/фреймворк. Стоит в сущности появиться наследованию от модели Django/SQLAlchemy — и домен уже привязан к БД. Сущности должны быть обычными классами; маппинг на таблицы — забота адаптера.
  • Анемичная модель + «сервисы-боги». Сущности превращают в пустые мешки данных без поведения, а все правила сваливают в один огромный сервис. Бизнес-правила должны жить в домене, рядом с данными, которые они охраняют.
  • Дырявые порты. В интерфейс порта протаскивают типы инфраструктуры (Connection, QuerySet, HTTP-ответ). Тогда домен снова зависит от деталей. Порт говорит на языке домена: save(user), а не execute(sql).
  • Слои ради слоёв. Для CRUD-приложения из трёх форм четыре кольца с десятком интерфейсов — лишняя церемония. Глубину архитектуры соизмеряют со сложностью домена.
  • Транзитные слои. Контроллер вызывает сервис, который только перекладывает вызов в репозиторий, ничего не добавляя. Такой «слой ради приличия» лишь множит код; слой оправдан, только если несёт собственную ответственность.

Итоги

  • Слоистая архитектура делит код на горизонтальные этажи (presentation → application → domain → infrastructure); минус классики — домен зависит от инфраструктуры.
  • Гексагональная (порты и адаптеры) переворачивает зависимость: приложение объявляет порты-интерфейсы, а адаптеры снаружи их реализуют — зависимость направлена внутрь.
  • Чистая архитектура обобщает это в концентрические кольца с правилом зависимостей: код зависит только в сторону центра, внутренние кольца не знают о внешних.
  • Технический фундамент всех трёх — принцип инверсии зависимостей: абстракция (порт) между высоким и низким уровнем, реализуемая снаружи.
  • Главная выгода — изоляция домена: бизнес-логику можно тестировать без БД и фреймворка и менять инфраструктуру, не трогая правила.
Проверьте себя
1. В гексагональной архитектуре кто кому принадлежит и куда направлена зависимость?
AПорт (интерфейс) принадлежит приложению, а адаптер снаружи зависит от порта — зависимость направлена внутрь, к домену
BАдаптер объявляет порт, а домен зависит от адаптера
CПорт и адаптер не связаны зависимостями вообще
DДомен зависит от конкретной базы данных напрямую, без интерфейсов
2. Что гласит «правило зависимостей» в чистой архитектуре?
AИсходный код может зависеть только в направлении внутрь, к центру; внутренние кольца не знают о внешних
BВсе кольца должны зависеть друг от друга для гибкости
CВнешние кольца не имеют права зависеть от внутренних
DЗависимости должны идти строго наружу, от домена к фреймворку
3. Благодаря какому приёму внутренний сценарий может «обращаться» к базе данных, не завися от неё в коде?
AИнверсия зависимостей: сценарий объявляет абстрактный порт и вызывает его, а конкретная реализация подставляется снаружи
BПрямой импорт класса БД внутри сценария
CГлобальная переменная с подключением к базе
DКопирование кода базы данных внутрь домена