Архитектурные паттерны помогают удерживать сложность, когда 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) | Устойчивость к смене фреймворка/БД, сильная тестируемость домена | Больше абстракций и шаблонного кода; нужен единый стиль на команду | Средняя-высокая |
Как это работает на уровне механики (практические правила):
- Layered: зависимости обычно идут сверху вниз (Controller → Service → Repository). Ограничьте "обратные" вызовы и не давайте репозиториям тянуть HTTP/DTO.
- Hexagonal: домен/юзкейсы определяют порты (интерфейсы), а инфраструктура предоставляет адаптеры (реализации). Внешний мир подключается на границе.
- Clean: разделите Entities (правила), Use Cases (сценарии), Interface Adapters (DTO/маппинг), Frameworks/Drivers (ORM/HTTP/queue). Зависимости направлены "внутрь".
- Контракты важнее папок: проверяйте направление зависимостей статикой/линтерами/архитектурными тестами, а не только структурой каталогов.
- Модульность: выбирайте модуль как единицу владения (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, но требуют зрелости в наблюдаемости и обработке ошибок.
Типичные сценарии, где это оправдано:
- Интеграции по событиям: после оформления заказа нужно уведомить склад/CRM/аналитику без жёсткой связки по API.
- Долгие процессы (saga/process manager): цепочки шагов с ожиданием внешних подтверждений (оплата → резерв → отгрузка).
- Нагрузка на чтение: отдельные read-модели (проекции) под UI/поиск/отчёты, если одна "универсальная" схема мешает.
- Аудит и трассируемость изменений: события как "факт" изменения состояния с возможностью воспроизведения.
- Параллельная разработка доменных частей: слабая связность между 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: план, риски и контрольные точки

Миграция - это не "переписать всё на clean architecture", а вытащить из MVC те места, где изменения обходятся дороже всего. Практичный подход: начать с одного флоу, закрепить правила границ и только затем масштабировать на остальные модули. Если вы параллельно планируете микросервисную архитектуру, сначала стабилизируйте границы и контракты в монолите - иначе получите распределённый монолит.
- Выберите один критичный сценарий (часто меняется/ломается/дорого тестируется) и сделайте его эталоном.
- Выделите use case: один вход, один выход, минимальный набор зависимостей.
- Определите порты для внешнего: репозитории, платежи, очереди, email, кеш.
- Перенесите бизнес-правила из контроллера/сервиса в домен/юзкейс, оставив в контроллере только HTTP/валидацию входа.
- Подключите адаптеры (ORM/HTTP/queue) на границе и запретите обратные зависимости.
- Зафиксируйте правила архитектурными тестами/линтерами и шаблоном 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 стоит применять всегда, если есть производительность?

Нет: CQRS оправдан, когда модель чтения заметно отличается от модели записи или когда нужны проекции под разные потребности. Для многих систем достаточно оптимизаций запросов, кеширования и аккуратных транзакций.
Как не утонуть в event-driven из-за дублей и повторной доставки?
Сразу проектируйте идемпотентные обработчики и храните ключи дедупликации/версии. Добавьте корреляционные идентификаторы и наблюдаемость (логи/метрики) на уровне событий.



