Слоистая, гексагональная и чистая архитектура
Как организовать код приложения целиком, чтобы бизнес-логика не зависела от базы данных, фреймворка и протокола.
Слоистая, гексагональная и чистая архитектура — это три способа расположить код приложения слоями так, чтобы зависимости были направлены внутрь, к бизнес-логике: домен (правила предметной области) не знает ни про 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); минус классики — домен зависит от инфраструктуры.
- Гексагональная (порты и адаптеры) переворачивает зависимость: приложение объявляет порты-интерфейсы, а адаптеры снаружи их реализуют — зависимость направлена внутрь.
- Чистая архитектура обобщает это в концентрические кольца с правилом зависимостей: код зависит только в сторону центра, внутренние кольца не знают о внешних.
- Технический фундамент всех трёх — принцип инверсии зависимостей: абстракция (порт) между высоким и низким уровнем, реализуемая снаружи.
- Главная выгода — изоляция домена: бизнес-логику можно тестировать без БД и фреймворка и менять инфраструктуру, не трогая правила.