Архитектура веб-платформ: как проектировать систему, которая растёт
Как проектировать архитектуру веб-платформ, которая выдерживает рост функциональности, команды и нагрузки: модульность, границы ответственности, данные, интеграции, наблюдаемость и выбор между монолитом и микросервисами.
Архитектура веб-платформы редко становится проблемой в первый день проекта. Проблемы появляются позже: когда пользователей становится больше, требования меняются каждую неделю, к системе подключаются новые интеграции, а команда перестаёт держать весь код в голове.
В этот момент становится видно, какие решения были приняты в начале.
Хорошая архитектура — это не самая сложная архитектура и не набор модных технологий. Это система, в которой изменения имеют предсказуемую стоимость, компоненты имеют понятные зоны ответственности, а рост нагрузки не требует каждый раз переписывать фундамент.
Именно поэтому при проектировании веб-платформы я смотрю не только на то, как она работает сегодня, но и на то, как она будет изменяться завтра.
Архитектура — это управление стоимостью изменений
Обычно архитектуру обсуждают через технологии: монолит или микросервисы, PostgreSQL или другая СУБД, REST или gRPC, Kubernetes или виртуальные машины.
Но это скорее следствия.
Главный архитектурный вопрос звучит иначе:
Насколько дорого будет изменить систему, когда бизнес-логика станет сложнее?
Представим платформу, в которой нужно добавить новый сценарий оплаты.
В хорошо организованной системе изменение затрагивает конкретный модуль и несколько явно определённых контрактов.
В плохо организованной системе приходится искать:
- где хранится состояние;
- кто меняет эту сущность;
- какие части системы используют таблицу;
- какие фоновые задачи завязаны на эти данные;
- какие интеграции вызываются побочно;
- где ещё продублирована эта логика.
Разница между двумя системами не обязательно видна на демо. Она становится заметна через год, когда каждое новое изменение начинает занимать всё больше времени.
Поэтому архитектуру имеет смысл оценивать не по количеству компонентов и диаграмм, а по стоимости и предсказуемости изменений.
Сначала бизнес-границы, потом технические компоненты
Одна из самых важных архитектурных задач — определить границы ответственности.
Плохая декомпозиция часто строится вокруг технических слоёв:
controllers/
services/
repositories/
models/
Такая структура может быть удобной на небольшом проекте, но сама по себе она ничего не говорит о бизнес-границах.
На более сложной платформе полезнее сначала определить функциональные области:
Пользователи
↓
Аутентификация
↓
Биллинг
↓
Заказы
↓
Уведомления
↓
Интеграции
Каждая область должна иметь понятную ответственность и ограниченный набор точек взаимодействия с другими областями.
Это близко к принципам bounded context из Domain-Driven Design: граница определяется не размером класса или количеством файлов, а самостоятельной областью модели и бизнес-правил. Microsoft, например, прямо связывает bounded context с владением собственной моделью и данными в микросервисной архитектуре.
Самое важное здесь — не название паттерна, а следствие:
модуль должен иметь владельца, ответственность и понятный контракт взаимодействия.
Модульность важнее микросервисов
Здесь часто возникает ошибка.
Команда говорит:
«Нужно, чтобы система масштабировалась, поэтому сразу делаем микросервисы».
Но микросервисы и модульность — не одно и то же.
Микросервис действительно создаёт более жёсткую границу: взаимодействие идёт через сеть, а прямой доступ к внутренностям другого сервиса становится технически сложнее.
Но ту же логическую границу можно создать внутри монолита.
Martin Fowler отдельно отмечает, что хороший модульный монолит вполне способен иметь сильные границы между компонентами. Более того, одна из проблем раннего перехода к микросервисам заключается именно в том, что команда может сначала неверно определить границы системы и затем закрепить эту ошибку распределённой архитектурой.
Поэтому для новой платформы часто разумнее сначала добиться такого состояния:
┌──────────────────────────────┐
│ Монолит │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Users │ │ Billing│ │
│ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────┐ │
│ │ Orders │ │ Notify │ │
│ └────────┘ └────────┘ │
└──────────────────────────────┘
а уже потом, если появляется реальная причина, вынести часть:
┌─────────────────┐
│ Core Platform │
│ Users / Orders │
└────────┬────────┘
│ API / Events
↓
┌─────────────────┐
│ Billing Service │
└─────────────────┘
Так граница появляется сначала логически, а затем при необходимости физически.
Именно такой эволюционный подход часто называют Monolith First. Fowler отмечал, что большинство известных успешных историй перехода к микросервисам начинались с монолита, а раннее распределение системы существенно повышает архитектурную и операционную сложность.
Когда микросервисы действительно оправданы
Микросервисы дают реальные преимущества:
- независимый deployment;
- автономное масштабирование отдельных компонентов;
- более жёсткие границы между командами;
- возможность выбирать разные технологии для разных подсистем.
Но вместе с этим появляется распределённая система.
Вместо:
service → database
появляются:
service A
↓
network
↓
service B
↓
database B
А вместе с сетью появляются новые классы проблем:
- временная недоступность;
- timeout;
- retries;
- duplicate requests;
- eventual consistency;
- distributed tracing;
- versioning контрактов;
- сложность интеграционных тестов.
Fowler прямо выделяет distribution, eventual consistency и operational complexity как фундаментальные издержки микросервисного подхода.
Поэтому вопрос должен звучать не «можем ли мы использовать микросервисы?», а:
Какая проблема требует их появления?
Например, отдельный сервис может быть оправдан, если его необходимо независимо масштабировать, независимо разворачивать или если его жизненный цикл существенно отличается от остальной системы.
Если такой причины нет, дополнительный network hop и распределённое состояние могут просто увеличить стоимость разработки.
Главный объект архитектуры — данные
Код можно переписать.
Данные — гораздо сложнее.
Поэтому база данных должна рассматриваться не просто как место хранения, а как долгосрочный контракт системы.
При проектировании необходимо понимать:
Кто создаёт сущность?
Кто имеет право её изменять?
Кто отвечает за её состояние?
Кто читает данные?
Какие данные являются производными?
Когда данные можно удалить?
Что происходит при изменении схемы?
Особенно важен вопрос владения.
В монолите несколько модулей могут использовать одну реляционную БД. Это даёт существенное преимущество: можно выполнять обычные ACID-транзакции и делать SQL-запросы между связанными таблицами.
При переходе к микросервисам модель меняется. Типичный принцип — данные конкретного сервиса становятся его внутренней ответственностью, а другие сервисы получают их через API или сообщения. Это уменьшает связанность, но делает межсервисные транзакции и запросы сложнее.
Поэтому переход на микросервисы — это не только изменение deployment-модели.
Это изменение модели владения данными.
Shared Database не всегда означает плохую архитектуру
Здесь тоже важно избегать крайностей.
Общая база данных внутри монолита часто является нормальным и рациональным решением.
Даже при микросервисной архитектуре несколько сервисов могут физически работать на одном экземпляре PostgreSQL или MySQL. Важнее логическое владение данными: отдельные схемы, таблицы и права доступа могут обеспечить границы без отдельного сервера БД на каждый сервис.
Проблема возникает тогда, когда сервисы начинают напрямую читать и менять таблицы друг друга.
Получается такая зависимость:
Service A ─────┐
├──→ shared table
Service B ─────┘
Теперь изменение структуры этой таблицы затрагивает сразу несколько компонентов.
Вместо независимых сервисов получается распределённый монолит.
Поэтому важен не сам факт наличия общей PostgreSQL, а наличие четкого владельца данных и контролируемого доступа.
Схема базы должна эволюционировать вместе с кодом
На живой платформе структура данных неизбежно меняется.
Добавляется поле.
Разделяется таблица.
Меняется тип значения.
Перестраивается индекс.
Переносится часть данных.
Проблема возникает, когда изменение базы рассматривают как одну большую операцию.
Например:
rename old_column → new_column
на production-системе может быть опасно, если одновременно работают разные версии приложения.
Более надёжный подход — backward-compatible migrations, в том числе паттерн Expand/Contract:
1. Добавить новую структуру
2. Заполнить новые данные
3. Научить код писать в обе структуры
4. Переключить чтение
5. Удалить старую структуру
Такое разбиение позволяет проводить изменения поэтапно, сохраняя совместимость между версиями приложения во время rollout.
Это особенно важно для систем с высокой частотой запросов, rolling deployment и несколькими экземплярами приложения.
Интеграции нужно проектировать как контракты
Внешняя система — это не часть вашего кода.
API платёжного провайдера может измениться.
CRM может начать возвращать другой формат.
Webhook может прийти повторно.
Внешний сервис может временно не отвечать.
Поэтому интеграционный слой лучше рассматривать как отдельную границу:
Business Logic
↓
Internal Interface
↓
Adapter
↓
External API
Внутренний код не должен зависеть от каждой особенности конкретного внешнего API.
Например, бизнес-логике не обязательно знать, как именно платёжный провайдер называет статус операции:
authorized
captured
failed
Она должна работать со своей моделью:
PaymentStatus
А преобразование внешнего формата должно находиться на границе системы.
Так внешний контракт меняется локально, а не распространяется по всей бизнес-логике.
Надёжность начинается с поведения при сбоях
На архитектурной схеме обычно рисуют только успешный сценарий:
API → Service → Database → Response
Production-система должна быть спроектирована вокруг другого вопроса:
Что произойдёт, если любой из компонентов не ответит?
Для внешнего API:
timeout
retry
idempotency
circuit breaker
fallback
Для очередей:
ack
retry
dead-letter queue
duplicate message
Для базы:
connection failure
lock contention
slow query
replication lag
Именно проектирование отказов превращает архитектурную диаграмму в реальную систему.
В микросервисной архитектуре это особенно важно: распределённые вызовы по определению нельзя считать надёжными как локальный вызов функции. Fowler отдельно подчёркивает, что удалённые вызовы медленнее и подвержены отказам, а распределённое состояние усложняет согласованность.
Наблюдаемость должна быть частью архитектуры
Логи после первого серьёзного инцидента — плохая стратегия.
Чтобы понимать поведение production-системы, нужны как минимум:
Metrics
Logs
Traces
Events
Причём важно не просто собирать данные, а связывать их с конкретными запросами и бизнес-операциями.
Например:
Request ID
↓
API
↓
Service
↓
RabbitMQ
↓
Worker
↓
Database
Если операция занимает пять секунд, разработчик должен иметь возможность определить, где именно возникла задержка.
AWS Well-Architected прямо рекомендует проектировать workload так, чтобы его внутреннее состояние можно было понимать через metrics, logs, events и traces, а наблюдаемость рассматривать как часть эксплуатационной архитектуры.
Для высоконагруженной платформы это уже не дополнительная функция.
Это средство управления системой.
Масштабирование начинается не с Kubernetes
При слове «масштабирование» часто сразу вспоминают Kubernetes, балансировщики и десятки контейнеров.
Но вертикальное и горизонтальное масштабирование приложения — только один из уровней.
Сначала нужно понять, что именно стало узким местом.
Например:
CPU
RAM
Database
Disk I/O
Network
Lock contention
External API
Queue
Cache
Если приложение упирается в PostgreSQL, увеличение количества backend-инстансов само по себе проблему не решит.
Если медленный запрос к внешнему API занимает 2 секунды, добавление ещё десяти экземпляров backend тоже не обязательно даст эффект.
Поэтому масштабирование должно следовать за измерениями:
Нагрузка
↓
Метрики
↓
Bottleneck
↓
Изменение архитектуры
↓
Повторное измерение
Это гораздо надёжнее, чем масштабировать систему «на всякий случай».
Кэш — часть архитектуры, а не просто ускоритель
Кэширование тоже необходимо проектировать осознанно.
Самый простой вариант:
Request
↓
Cache
↓ miss
Database
Но затем возникают вопросы:
- сколько живёт значение;
- что происходит после изменения данных;
- кто инвалидирует кэш;
- можно ли вернуть устаревшее значение;
- что происходит при падении Redis;
- какой процент запросов должен обслуживаться из кэша.
Особенно опасен кэш, который начинает хранить бизнес-состояние без чётко определённого источника истины.
Хорошая архитектура должна явно отвечать на вопрос:
Где находится canonical source of truth?
Для конкретной сущности это обычно один определённый источник, а кэш или materialized view являются производными представлениями.
Очереди позволяют отделить запрос от тяжёлой работы
Не вся работа должна выполняться в HTTP-запросе.
Например:
POST /orders
↓
Создание заказа
↓
Queue
↓
Worker
├── отправка email
├── генерация документа
├── синхронизация CRM
└── обработка webhook
Пользователю не обязательно ждать завершения всех этих операций.
Очередь позволяет отделить критический путь запроса от фоновых задач и контролировать нагрузку на downstream-системы.
Но очередь тоже добавляет семантику, которой нет у обычного вызова функции:
message may arrive twice
message may arrive later
worker may crash
consumer may restart
Поэтому обработчики очередей должны проектироваться с учётом идемпотентности.
Асинхронность не устраняет сложность — она переносит её в другое место.
Архитектура должна учитывать команду
Система проектируется не только под нагрузку, но и под людей, которые её развивают.
Если пять разработчиков работают над одной кодовой базой, им нужны границы, которые уменьшают количество конфликтов и взаимных зависимостей.
Если несколько команд владеют разными бизнес-направлениями, архитектура может отражать это разделение.
Это один из факторов, объясняющих популярность микросервисов в больших организациях: они помогают сделать границы между автономными командами более явными. Fowler также связывает ценность сильных модульных границ с ростом размера команды.
Но создавать отдельный сервис только ради того, чтобы каждый разработчик получил свой репозиторий, — слабая причина.
Архитектура должна уменьшать организационную сложность, а не просто перераспределять её.
Что я считаю хорошей отправной точкой
Для большинства новых веб-платформ я бы начинал не с микросервисов, а с модульной архитектуры.
Например:
┌───────────────┐
│ API │
└───────┬───────┘
↓
┌────────────────────────────────────┐
│ Application │
│ │
│ Users │ Billing │ Orders │ Notify │
└───┬──────┬────────┬────────┬───────┘
│ │ │ │
└──────┴────────┴────────┴─────┐
↓
Infrastructure
┌──────┬──────┬─────┐
│ DB │ Queue│ API │
└──────┴──────┴─────┘
При этом:
- бизнес-логика не должна зависеть от конкретного транспорта;
- внешние API скрываются за адаптерами;
- инфраструктурные детали не должны протекать в доменную логику;
- данные имеют понятного владельца;
- модули взаимодействуют через определённые контракты.
Если со временем один из модулей становится самостоятельным по нагрузке, жизненному циклу или ответственности, его можно вынести в отдельный сервис.
Получается постепенная эволюция:
Модульный монолит
↓
Выделение границы
↓
API / Events
↓
Отдельный сервис
↓
Независимое масштабирование
Это существенно безопаснее, чем заранее распределять ещё не устоявшуюся бизнес-модель по десяти сервисам.
Что стоит заложить с первого дня
Не всё будущее нужно предсказывать.
Но несколько вещей почти всегда окупаются.
Явные границы модулей
Даже если приложение остаётся одним deployment unit, модули должны иметь собственные зоны ответственности и понятные интерфейсы.
Миграции без разрушительных изменений
Схема базы должна изменяться поэтапно, особенно если приложение разворачивается без полной остановки.
Наблюдаемость
Ошибки, latency, ключевые бизнес-метрики и состояние фоновых задач должны быть измеримыми.
Идемпотентность
Повторный HTTP-запрос, webhook или сообщение из очереди не должны приводить к неконтролируемому повторному выполнению бизнес-операции.
Контракты интеграций
Внешние API должны быть отделены от внутренней модели приложения.
Возможность горизонтального масштабирования
Не обязательно масштабировать всё сразу, но критические компоненты не должны без необходимости зависеть от локального состояния конкретного экземпляра приложения.
Что не стоит закладывать заранее
Есть и обратная сторона.
Не стоит создавать:
20 микросервисов
Kubernetes
service mesh
event sourcing
CQRS
несколько типов БД
только потому, что они могут пригодиться через несколько лет.
Каждый такой компонент имеет стоимость разработки и сопровождения.
Fowler прямо отмечает, что преимущества микросервисов имеют цену, а значимость каждого компромисса зависит от конкретной системы. Универсального ответа «монолит или микросервисы» не существует.
Архитектура должна развиваться вместе с реальными требованиями.
Итог
Хорошая архитектура веб-платформы — это не набор технологий и не количество сервисов.
Это система ограничений и договорённостей, которые позволяют проекту развиваться без лавинообразного роста сложности.
Я бы сформулировал основные принципы так:
- Сначала определяйте бизнес-границы, потом технические компоненты.
- Создавайте модульность независимо от того, монолит у вас или микросервисы.
- Не используйте микросервисы без конкретной причины.
- Определяйте владельца каждой значимой сущности и набора данных.
- Проектируйте изменения схемы и API как эволюцию, а не как одноразовую операцию.
- Считайте отказы и наблюдаемость частью архитектуры.
- Масштабируйте узкие места, которые подтверждены измерениями.
- Оставляйте возможность вынести отдельный модуль в сервис, когда для этого появится реальная необходимость.
Архитектура — это всегда компромисс между простотой сегодня и стоимостью изменений завтра.
Поэтому самая практичная архитектура — не та, которая заранее решает все возможные проблемы, а та, которая решает текущие задачи и оставляет системе пространство для эволюции.
Релевантные разделы
Читайте также
Чистый код и поддерживаемость: почему читаемость важнее скорости
Как писать поддерживаемый код: понятные имена, небольшие функции, явные зависимости, простые интерфейсы, тесты и рефакторинг без усложнения проекта.
Автоматизация бизнес-процессов: с чего начать и где искать эффект
Как находить процессы для автоматизации, оценивать экономический эффект, выбирать между готовыми платформами и собственной разработкой и строить автоматизацию, устойчивую к ошибкам и изменениям.