Уровень 2 · Стили Глава 03 10 мин

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

Определение Layered Architecture (слоистая архитектура)

Структура, при которой приложение разделено на горизонтальные слои по технической ответственности. Верхние слои используют нижние; нижние не знают о верхних.

— Fowler, PoEAA, 2002

Классическая 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 знает про Business через порт.

В 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 · закрепить

Проверьте себя

  1. Q1. Главная структурная проблема классической Layered — это...

  2. Q2. Что такое Transaction Script (Fowler) в контексте Layered?

  3. Q3. Когда Layered — уместный выбор?

  4. Q4. Симптом «толстых сервисов и тонких слоёв» проявляется как...

  5. Q5. Ключевое отличие Hexagonal от Layered — это...