Архитектурные паттерны в практике: что стоит знать, кроме Mvc

Архитектурные паттерны помогают удерживать сложность, когда MVC перестаёт масштабироваться по коду и команде. На практике важнее не знать список "лучших архитектурных паттернов для приложений", а уметь выбрать подходящий: Layered для простых доменов, Hexagonal для интеграций, clean architecture для долгоживущих систем, а event-driven/CQRS - для асинхронности и высокой изменчивости.

Ключевые соображения перед выбором архитектурного паттерна

  • Какая "боль" первична: изменение бизнес-правил, интеграции, скорость разработки, тестируемость, масштабирование?
  • Где находится доменная логика сейчас: в контроллерах/сервисах, в моделях, в SQL, в событиях?
  • Какой тип изменений чаще: добавление фич, изменение правил, смена провайдеров/шины/БД, рост команды?
  • Какие интеграции критичны: внешние API, очереди, платежи, поисковые движки, ETL?
  • Каковы ограничения эксплуатации: latency, консистентность, офлайн-режим, аудит, трассировка?
  • Сколько дисциплины команда готова поддерживать: границы модулей, контракты, code review?

Сигналы того, что MVC уже не тянет: реальные симптомы

MVC удобен как "первый" каркас, но в реальной разработке он часто превращается в "всё в контроллере + сервисы-утилиты". Проблема не в самом MVC, а в том, что он слабо задаёт границы домена и зависимостей: легко смешать HTTP, бизнес-правила, транзакции, интеграции и форматирование данных в одном потоке.

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

Практические симптомы:

  • Контроллеры растут, появляются "толстые" экшены с валидацией, транзакциями, маппингом и вызовами внешних API.
  • Сервисы становятся "God Service": десятки методов, скрытые сайд-эффекты, зависимость от ORM/HTTP/кеша одновременно.
  • Тесты либо отсутствуют, либо превращаются в медленные интеграционные (поднимают БД/очереди/контейнеры ради проверки простого правила).
  • Замена провайдера (платежи/почта/шина/поиск) требует каскадных правок по всему приложению.
  • Один и тот же бизнес-кейс реализован в нескольких местах (дублирование правил между REST, фоновой задачей и импортом).

Сравнение Layered, Hexagonal и Clean: структура и компромиссы

Ниже - три практичных варианта "что поставить вместо чистого MVC", если вам нужно упорядочить зависимости и ответственность. Важно: это не взаимоисключающие религии, а наборы правил о слоях, границах и направлении зависимостей. В реальности вы можете комбинировать: например, Layered внутри модуля, но с портами/адаптерами на границе.

Паттерн Контекст применения Плюсы Минусы/риски Стоимость внедрения
Layered (слоистая) CRUD/админки, стабильные правила, небольшой набор интеграций Просто объяснить, быстро внедрить, хорошо ложится на MVC Легко "протечь" зависимостями вниз-вверх; домен часто превращается в анемичную модель Низкая-средняя
Hexagonal (Ports & Adapters) Много внешних систем (API/очереди/платежи), частые замены провайдеров Изоляция интеграций, контрактность, проще мокать внешнее Нужна дисциплина в контрактах; риск "интерфейс ради интерфейса" Средняя
Clean Architecture Долгоживущие доменно-насыщенные продукты, сложные правила, несколько интерфейсов (REST/CLI/Jobs) Устойчивость к смене фреймворка/БД, сильная тестируемость домена Больше абстракций и шаблонного кода; нужен единый стиль на команду Средняя-высокая

Как это работает на уровне механики (практические правила):

  1. Layered: зависимости обычно идут сверху вниз (Controller → Service → Repository). Ограничьте "обратные" вызовы и не давайте репозиториям тянуть HTTP/DTO.
  2. Hexagonal: домен/юзкейсы определяют порты (интерфейсы), а инфраструктура предоставляет адаптеры (реализации). Внешний мир подключается на границе.
  3. Clean: разделите Entities (правила), Use Cases (сценарии), Interface Adapters (DTO/маппинг), Frameworks/Drivers (ORM/HTTP/queue). Зависимости направлены "внутрь".
  4. Контракты важнее папок: проверяйте направление зависимостей статикой/линтерами/архитектурными тестами, а не только структурой каталогов.
  5. Модульность: выбирайте модуль как единицу владения (feature/module), а не "слой" на весь монолит, иначе получите общий "service" на всю систему.

Мини-примеры структуры (по одному на паттерн):

Layered: минимально дисциплинированный слой сервисов

src/
  web/
    controllers/OrderController.ts
  app/
    services/PlaceOrderService.ts
  domain/
    Order.ts
    PricingPolicy.ts
  infra/
    repositories/OrderRepositorySql.ts

Hexagonal: порты в приложении, адаптеры снаружи

src/
  application/
    ports/PaymentGateway.ts
    usecases/PlaceOrder.ts
  domain/
    Order.ts
  adapters/
    primary/HttpOrderController.ts
    secondary/StripePaymentAdapter.ts
  infra/
    db/OrderRepositorySql.ts

Clean Architecture: use cases как центр принятия решений

src/
  entities/Order.ts
  usecases/PlaceOrderInteractor.ts
  interface_adapters/
    presenters/PlaceOrderPresenter.ts
    controllers/PlaceOrderController.ts
  frameworks/
    http/ExpressRoutes.ts
    persistence/OrderRepositoryPrisma.ts

Event-driven и CQRS на практике: сценарии применения и ограничения

Event-driven и CQRS часто рассматривают как "следующий уровень" архитектуры, но это инструменты под конкретные нагрузки: асинхронность, независимость частей системы, разные модели чтения/записи. Они отлично сочетаются с портами/адаптерами и clean architecture, но требуют зрелости в наблюдаемости и обработке ошибок.

Типичные сценарии, где это оправдано:

  1. Интеграции по событиям: после оформления заказа нужно уведомить склад/CRM/аналитику без жёсткой связки по API.
  2. Долгие процессы (saga/process manager): цепочки шагов с ожиданием внешних подтверждений (оплата → резерв → отгрузка).
  3. Нагрузка на чтение: отдельные read-модели (проекции) под UI/поиск/отчёты, если одна "универсальная" схема мешает.
  4. Аудит и трассируемость изменений: события как "факт" изменения состояния с возможностью воспроизведения.
  5. Параллельная разработка доменных частей: слабая связность между bounded contexts через события.

Ограничения, которые нужно принять заранее:

  • Консистентность: чаще всего eventual consistency; UI и бизнес-операции должны корректно жить с задержками.
  • Идемпотентность: повторная доставка сообщения - норма, обработчики обязаны быть устойчивыми.
  • Отладка: без корреляционных ID, логирования и метрик "починка" становится дорогой.
  • Сложность модели данных: CQRS увеличивает количество моделей (write/read), маппинг и миграции.

Мини-псевдокод (событие + проекция):

// command side
handle(PlaceOrder cmd) {
  order = Order.place(cmd.items)
  repo.save(order)
  bus.publish(new OrderPlaced(order.id))
}

// read side (projection)
on(OrderPlaced e) {
  readDb.upsert("order_list", { id: e.id, status: "PLACED" })
}

Пошаговая миграция от MVC: план, риски и контрольные точки

Архитектурные паттерны в практике: что стоит знать, кроме MVC - иллюстрация

Миграция - это не "переписать всё на clean architecture", а вытащить из MVC те места, где изменения обходятся дороже всего. Практичный подход: начать с одного флоу, закрепить правила границ и только затем масштабировать на остальные модули. Если вы параллельно планируете микросервисную архитектуру, сначала стабилизируйте границы и контракты в монолите - иначе получите распределённый монолит.

  1. Выберите один критичный сценарий (часто меняется/ломается/дорого тестируется) и сделайте его эталоном.
  2. Выделите use case: один вход, один выход, минимальный набор зависимостей.
  3. Определите порты для внешнего: репозитории, платежи, очереди, email, кеш.
  4. Перенесите бизнес-правила из контроллера/сервиса в домен/юзкейс, оставив в контроллере только HTTP/валидацию входа.
  5. Подключите адаптеры (ORM/HTTP/queue) на границе и запретите обратные зависимости.
  6. Зафиксируйте правила архитектурными тестами/линтерами и шаблоном PR.

Риски и как их контролировать:

  • Риск: "абстракции ради абстракций". Контроль: добавляйте порты только там, где реально есть сменяемость или тестовая потребность.
  • Риск: смешение DTO и доменных моделей. Контроль: отдельные модели для транспорта/представления; маппинг в адаптерах.
  • Риск: расползание транзакционных границ. Контроль: явный unit-of-work на уровне use case, а не в глубине домена.
  • Риск: "полумеры" без правил зависимостей. Контроль: договоритесь о направлениях импортов и регулярно проверяйте.

Границы контекстов и модульность: организация кода в крупном проекте

Архитектурные паттерны работают только при чётких границах. На уровне кода это выражается в модульности: кто имеет право импортировать кого, где лежат контракты, и что считается публичным API модуля. На уровне домена - это разделение на bounded contexts, где термины и правила не конфликтуют.

Типичные ошибки и мифы:

  • Миф: "достаточно разложить по папкам /controllers /services /repositories". Ошибка: зависимости всё равно хаотично пересекаются.
  • Ошибка: один общий модуль common для всего. Итог - неявная связность и циклы зависимостей.
  • Ошибка: домен знает про ORM/HTTP "для удобства". Итог - тестируемость падает, миграции дорожают.
  • Миф: "микросервисная архитектура решит модульность". На практике микросервисы усиливают цену ошибок границ и контрактов.
  • Ошибка: разрез по слоям вместо разреза по фичам/поддоменам. Итог - изменения требуют правок во множестве модулей.

Как паттерн влияет на тестируемость, производительность и сопровождение

Выбор паттерна напрямую меняет стоимость тестов и изменения кода. Layered часто ускоряет старт, но усложняет изоляцию домена. Hexagonal и clean architecture повышают тестируемость за счёт портов и инверсии зависимостей; производительность обычно определяется не "паттерном", а тем, как вы управляете I/O, транзакциями и моделью чтения (особенно при CQRS).

Мини-кейс: "оформление заказа" с портом репозитория и порта платежей (удобно тестировать без БД и внешнего API):

// ports
interface OrderRepo { save(order): void }
interface PaymentGateway { charge(amount): PaymentResult }

// use case
class PlaceOrder {
  constructor(private repo: OrderRepo, private pay: PaymentGateway) {}

  execute(cmd) {
    const order = Order.place(cmd.items)
    const res = this.pay.charge(order.total())
    order.markPaid(res.txId)
    this.repo.save(order)
    return order.id
  }
}

// unit test (без HTTP/ORM)
fakePay.charge = (_) => ({ txId: "t1" })
usecase.execute({ items: [...] })
  • Тестируемость: порты позволяют тестировать use case как обычную функцию; интеграции проверяются отдельными тестами адаптеров.
  • Производительность: явные границы помогают видеть I/O. Если use case делает 5 запросов в БД - это видно и исправляется (батчинг, проекции, кеш).
  • Сопровождение: новые интерфейсы (REST/CLI/cron) подключаются как первичные адаптеры без копирования бизнес-логики.

Чек-лист самопроверки перед внедрением

  • Я могу назвать 1-2 сценария, где MVC уже ломается, и измерить "дороговизну" изменения (по времени/рискам), без попытки переписать всё.
  • Для выбранного сценария я отделил use case от HTTP/ORM и знаю, какие зависимости должны быть портами.
  • Я зафиксировал направление зависимостей (что кому можно импортировать) и добавил проверку в ревью/линтер/архитектурный тест.
  • Я понимаю, нужна ли мне асинхронность (event-driven/CQRS) или достаточно модульного монолита с чёткими границами.

Разрешение типичных практических сомнений разработчиков

Какие архитектурные паттерны выбрать, если проект - в основном CRUD?

Начните с Layered, но ограничьте размер контроллеров и сервисов, вынесите правила в доменные объекты/политики. Порты добавляйте точечно на интеграциях, где возможна замена провайдера.

Clean architecture - это обязательно много файлов и интерфейсов?

Нет: в clean architecture обязательны границы и направление зависимостей, а не количество абстракций. Интерфейс имеет смысл там, где есть альтернативная реализация или тестовая изоляция.

Когда Hexagonal даёт реальную выгоду, а не "красоту"?

Когда у вас несколько внешних систем и регулярные изменения интеграций: платежи, доставки, очереди, сторонние API. Тогда порты/адаптеры снижают стоимость замены и упрощают контрактные тесты.

Нужно ли сразу переходить на микросервисную архитектуру, чтобы уйти от MVC?

Нет: сначала стабилизируйте модульные границы и контракты в монолите. Иначе микросервисы превратятся в распределённый монолит с дорогой отладкой.

CQRS стоит применять всегда, если есть производительность?

Архитектурные паттерны в практике: что стоит знать, кроме MVC - иллюстрация

Нет: CQRS оправдан, когда модель чтения заметно отличается от модели записи или когда нужны проекции под разные потребности. Для многих систем достаточно оптимизаций запросов, кеширования и аккуратных транзакций.

Как не утонуть в event-driven из-за дублей и повторной доставки?

Сразу проектируйте идемпотентные обработчики и храните ключи дедупликации/версии. Добавьте корреляционные идентификаторы и наблюдаемость (логи/метрики) на уровне событий.

Прокрутить вверх