Layered Architecture
Классическая 3-слойка (Presentation / Business / Data). Что она решала, где не сработала, и почему выросли Hexagonal, Onion, Clean.
TL;DR
- Layered — самая распространённая архитектура. Не плохая, а с явными ограничениями.
- Разделение по техническим слоям порождает тонкие слои и толстые сервисы (thin layers, fat services).
- Транзитивная зависимость: Presentation через Business знает о структуре Data. Смена БД становится многослойным рефакторингом.
- Проблема не в слоях, а в направлении зависимостей. Clean/Hexagonal/Onion — не отменяют слои, а инвертируют стрелки.
Зачем эта глава
Открываете чужой Django/FastAPI/Spring-проект — почти наверняка увидите Layered. Она никуда не делась и не уйдёт. Понять её честно — важнее, чем ругать. Именно из её ограничений выросли Hexagonal, Onion и Clean.
Что такое Layered
Структура, при которой приложение разделено на горизонтальные слои по технической ответственности. Верхние слои используют нижние; нижние не знают о верхних.
Классическая 3-слойка:
Presentation Layer. Контроллеры, view-templates, JSON-сериализация. Знает про HTTP.
Business Logic Layer (BLL). Сервисы, orchestration, бизнес-правила. Не знает про HTTP.
Data Access Layer (DAL). ORM, DAO, SQL-запросы. Не знает про HTTP и (в идеале) про Business.
Иногда добавляют 4-й слой (Database) или 5-й (Cross-Cutting: logging, security). Суть та же — техническая нарезка.
Что она решала
В 1990-х программы часто представляли собой single-file cocktail: контроллер, SQL, HTML — всё вперемешку. Разделение на слои дало:
- Специализацию команд. Frontend, backend, DBA работают в разных пакетах.
- Локализацию изменений — новый экран не заставляет менять SQL.
- Тестируемость по слоям — Business юнит-тестами, DAL — интеграционными.
- Понятность. Куда положить
def register_user()— понятно с ходу.
Всё это — реальная польза. Layered — рабочая архитектура для CRUD-сервиса.
Где Layered перестаёт работать
Проблема 1 — направление зависимостей
Классическая формулировка: «верхний слой зависит от нижнего». Значит, Presentation → Business → DAL → БД. Импорт-граф выглядит как стрелка вниз.
Именно эту проблему решает инверсия зависимостей: Business определяет интерфейс OrderRepositoryPort, DAL реализует. Стрелка меняет направление — Business больше не знает про SQL. Так родилась Hexagonal.
Проблема 2 — тонкие слои, толстые сервисы
Реальность 3-слойки:
UserController → UserService → UserRepository → users_table
UserController — прочитал JSON, вызвал сервис, вернул JSON. Одна строчка.
UserRepository — вызвал ORM. Одна строчка.
UserService — вся логика: валидация, оркестрация, бизнес-правила, транзакции.
Слои превращаются в procedural pass-through. Единственный слой, где живёт логика — Service. Он растёт, потом делится по типам сущностей: UserService, OrderService, PaymentService. Вся система — набор жирных сервисов с CRUD.
Vernon называет это transaction script (термин Fowler’а). Работает — но domain-модели нет. Анемичная модель почти неизбежна.
Проблема 3 — DTO протекают между слоями
UserDTO определён в Presentation. UserService принимает UserDTO. UserRepository возвращает UserRow (ORM). Между Business и Presentation теперь несколько типов, и сервис вынужден их конвертировать сам.
Часто программисты «оптимизируют» — используют один User от ORM во всех слоях. Presentation сериализует User напрямую, DAL возвращает User, Service работает с User. Все слои теперь зависят от ORM. Разделение — фикция.
Проблема 4 — сквозные концерны прорастают повсюду
Логирование, транзакции, валидация, авторизация. По логике — cross-cutting. По практике — в каждом сервисе try/except, logger.info(...), with session.begin(), if not user.is_admin.
Layered не даёт ответа, где это должно жить. Ответ приходит извне: AOP, декораторы, middleware. Но встраивать их в чистой Layered — тяжело.
Как правильно применять Layered
Layered — не «антипаттерн». Она уместна, если:
- CRUD-сервис с прямолинейным domain’ом.
- Малая команда (1–3 разработчика) — накладные Clean не окупятся.
- Короткий жизненный цикл — прототип, MVP, скрипт.
Правила для «правильной Layered»:
Каждый слой в своём пакете. Не services/, а presentation/, business/, data/. Явные границы.
Слой ниже не знает про слой выше. DAL не импортирует Business.
Между слоями — свои модели. DAL: UserRow. Business: User. Presentation: UserResponse. Маппинг явный.
Тесты по слоям. Business — unit (без БД). DAL — integration с in-memory SQLite.
Не смешивать сервисы разных сущностей. OrderService.create_user() — запах.
Как не надо
1. Использовать ORM-модели везде
# data/models.py
class User(Base):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str]
# business/user_service.py
def register_user(email: str) -> User:
user = User(email=email)
session.add(user)
session.commit()
return user
# presentation/user_controller.py
@app.post('/users')
def create(payload: dict):
user = user_service.register_user(payload['email'])
return {'id': user.id, 'email': user.email} # SQLAlchemy object serialization
Три слоя, но все знают про SQLAlchemy. User протекает от DAL до JSON. Смена ORM = переписать всё.
2. Transaction Script вместо domain
# business/order_service.py
class OrderService:
def confirm_order(self, order_id: int) -> None:
order = self.repo.get(order_id)
if order.status != 'pending':
raise ValueError('cannot confirm')
if order.total > 1000 and not order.customer.is_premium:
raise ValueError('over limit for non-premium')
order.status = 'confirmed'
order.confirmed_at = datetime.utcnow()
self.repo.save(order)
Проблема: Order — анемичный dict/dataclass, OrderService.confirm_order — процедура. Инвариант «нельзя подтвердить не-pending» живёт в сервисе, а не в Order. Через полгода тот же инвариант обнаружится ещё в двух местах, потому что забыли.
Правильно: order.confirm(customer) — метод aggregate. OrderService только координирует.
3. Круговые импорты через shared
business/user_service.py imports from data/user_repo.py
data/user_repo.py imports from business/models.py # !!
Классический симптом: чтобы вернуть Business-модель из DAL, DAL импортирует Business. Правило «нижний не знает про верхний» нарушено. Дальше — циклические импорты, TYPE_CHECKING-костыли, разъединение через shared/, откуда всё импортируется.
Правильно: DAL возвращает свои модели, Business слой маппит. Или через инверсию — Business определяет Port, DAL реализует.
От Layered к Hexagonal
Ключевая мысль: проблема не в наличии слоёв, а в направлении зависимостей.
Разница показывается одной картинкой:
Layered
Presentation ↓ Business Logic ↓ Data Access ← знает про SQL
Hexagonal
HTTP Adapter → [Application] ↑ RepositoryPort ↑ SQL Adapter
В Layered Business знает Data. В Hexagonal Data (адаптер) знает Application (порт). Стрелка перевёрнута через инверсию зависимостей.
Trade-offs
| Ситуация | Layered ок | Hexagonal/Clean лучше |
|---|---|---|
| CRUD-микросервис на 3 таблицы | ✓ | |
| Прототип на 1 месяц | ✓ | |
| Внутренний админ-инструмент | ✓ | |
| Продакшн-сервис с большим доменом | ✓ | |
| Нужны быстрые unit-тесты бизнес-логики | ✓ | |
| Планируется смена БД / ORM | ✓ | |
| Много разных транспортов (HTTP + AMQP + gRPC) | ✓ |
В твоём же коде
Анонимизированный микросервис из case study формально Hexagonal, а не Layered. Но полезно посмотреть, каким бы он получился, если бы его писали строго слоями:
# presentation/handlers.py
async def handle_order_item(msg):
await order_service.standardize(msg)
# business/order_service.py
class OrderService:
def __init__(self, session_factory):
self.sf = session_factory
self.http_client = httpx.AsyncClient()
async def standardize(self, msg):
properties = self._build_properties(msg)
response = await self.http_client.post(...)
async with self.sf() as session:
...
# data/repository.py — ORM, session, models
Проблемы Layered-версии:
OrderServiceзнает про httpx и SQLAlchemy. Тестировать без обоих нельзя.httpx.AsyncClientв__init__— жизненный цикл клиента размазан.- Никакой инверсии, никаких портов.
Hexagonal-версия ровно то же дробит на OrderStandardizeUseCase (в application/) + два адаптера + composition root — и всё это тестируется юнит-тестами без сети и БД.
Дальнейшее чтение
- Martin Fowler. Patterns of Enterprise Application Architecture. Addison-Wesley, 2002. Chapters 1–2, Transaction Script и Domain Model — обсуждение подходов внутри Layered.
- Robert C. Martin. Clean Architecture. Prentice Hall, 2017. Chapter 4 — критика классической Layered.
- Herberto Graça. The Software Architecture Chronicles. Исторический контекст: от Von Neumann до микросервисов.
- Simon Brown. C4 Model. Как рисовать слои понятно.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Главная структурная проблема классической Layered — это...
Q2. Что такое Transaction Script (Fowler) в контексте Layered?
Q3. Когда Layered — уместный выбор?
Q4. Симптом «толстых сервисов и тонких слоёв» проявляется как...
Q5. Ключевое отличие Hexagonal от Layered — это...