Чистая архитектура и микросервисы: разбор реальных продакшен-проектов
Мы изучили два материала суммарно около двух часов: первый — детальный обзор живой образовательной платформы из 13 микросервисов на .NET в production, второй — практическое объяснение чистой архитектуры на Django с разбором реального спагетти-кода на 800 тысяч строк. Аудитория обоих материалов — разработчики, которые уже пишут что-то рабочее, но пока не разобрались, как это правильно организовать.
Оба разбора объединяет одна мысль: язык и фреймворк — детали. Принципы разделения на слои, управления зависимостями и изоляции бизнес-логики одинаково работают на C#, Python, Go и чём угодно ещё. Это не абстрактная теория из учебника — оба проекта живут в production и решают реальные задачи.
Главный вывод, который мы вынесли: архитектурные решения, принятые в начале проекта, определяют стоимость любого изменения через год. И этот счёт выставляется неожиданно.
Монолит, модульный монолит, микросервисы — где на самом деле граница
Прежде чем разбирать детали, надо зафиксировать базу. В свежих материалах встречается путаница: разработчики либо сразу строят микросервисы «потому что так делают в Netflix», либо годами сидят в монолите и боятся его тронуть. Оба крайних варианта — ошибка.
Монолит — это когда всё приложение живёт в одном процессе, использует одну базу данных, и модули незаметно расползаются друг в друга. Работает быстро на старте, превращается в проблему при росте. Классический признак — разработчик боится менять модуль А, потому что непонятно, как это отразится на модуле Б.
Модульный монолит — один процесс, но внутри жёсткое разделение на изолированные модули. Модули общаются только через публичные контракты-интерфейсы, не лезут к данным друг друга напрямую. База данных одна, но разбита на схемы — по одной на модуль. Внутри может использоваться брокер сообщений. Это архитектура, которую легко поддерживать в команде 3–10 человек, и которую при необходимости можно превратить в микросервисы без полного переписывания.
Микросервисы — каждый модуль живёт в отдельном процессе на своём порту, деплоится независимо, может масштабироваться отдельно. Звучит красиво. Но это требует отдельного CI/CD для каждого сервиса, мониторинга, брокера сообщений, паттернов надёжности и постоянного решения вопроса «кто кому что должен сообщить». Цена входа — высокая.
Конкретный пример из нашего разбора: образовательная платформа использует 13 микросервисов, и автор прямо признаёт — выбор в том числе учебный. Для реального проекта он рекомендует другое соотношение: один крупный модульный монолит с основной бизнес-логикой плюс несколько выделенных микросервисов для специфичных задач — авторизации, работы с файлами, платежей. Это не компромисс от лени, а осознанное архитектурное решение.
Микросервисы решают проблему масштабирования команды и инфраструктуры — но сначала нужно иметь эту проблему.
Что такое Use Case и почему его нельзя добавить «потом»
Это центральный тезис второго разбора. Разберём по пунктам.
В типичном Django-проекте без архитектуры бизнес-логика размазана везде: во вьюхах, в методах моделей, в сигналах, в сервисных слоях, которые сами вызывают другие сервисные слои. 800 тысяч строк кода — и разработчик не может тронуть один метод, не проверив полдюжины мест, куда он тянется через сигналы и цепочки вызовов.
Чистая архитектура решает это через три слоя:
Handler (хендлер / вьюха)
- Принимает HTTP-запрос
- Достаёт нужные данные (user ID, параметры)
- Приводит их к примитивам — int, str, не Django-объекты
- Передаёт в Use Case
- Возвращает ответ клиенту
- Никакой логики. Никаких условий. Только маршрутизация.
Use Case (бизнес-логика) - Изолированный класс - Принимает примитивы, не знает о существовании Django, HTTP, базы данных - Содержит только бизнес-правила: «если сумма больше порога — применить скидку» - Обращается к данным через Repository — и в идеале не через прямой импорт, а через Dependency Injection - Возвращает результат обратно в Handler
Repository (репозиторий)
- Здесь живёт ORM: filter, select_related, save и всё остальное
- Возвращает данные в Use Case в виде QuerySet или простых объектов
Суть простая: Use Case не знает, откуда берутся данные. Из PostgreSQL, из Redis, из mock-объекта при тестировании — ему всё равно. Это и есть инверсия зависимостей в действии.
А вот тут важный момент, который часто упускают: выделить Use Case можно только в начале проекта. Точнее — чем позже, тем дороже. В проекте на 800 тысяч строк команда потратила 4–5 месяцев, чтобы перевести на чистую архитектуру ~30% кодовой базы. Те части, которые отрефакторили — работать с ними стало кардинально проще. Остальные 70% продолжают жить по старым правилам.
Stateless против Stateful: без этого горизонтальное масштабирование не работает
Одна из вещей, которую мы вынесли из разбора продакшен-платформы — это жёсткое требование к stateless-сервисам. Звучит банально, но на практике это ломает архитектуру чаще, чем кажется.
Stateful-сервис держит данные в локальной памяти процесса. Сессии, кеши в памяти, WebSocket-соединения без внешнего хранилища. Запустить второй экземпляр такого сервиса — и сразу проблема: балансировщик отправляет запрос на второй экземпляр, а там нет данных от предыдущей сессии. Либо нужна липкая сессия (sticky session), либо боль.
Stateless-сервис ничего не хранит внутри себя. Всё состояние — в PostgreSQL, Redis или другом внешнем хранилище. Любой экземпляр может обработать любой запрос, потому что данные достаёт из общего места. Запускаешь три экземпляра — и они все работают одинаково.
В разобранной платформе из 13 сервисов почти все stateless. Исключение — Notification Service: он держит постоянное SSE-соединение (Server-Sent Events) с браузером клиента. Соединение привязано к конкретному экземпляру. Горизонтальное масштабирование этого сервиса — нетривиальная задача, требующая дополнительных решений.
Redis в этом стеке работает как универсальный инструмент для stateless-архитектуры: хранит коды входа, данные о доступах пользователей, обеспечивает Rate Limiting, работает как распределённый кеш. PostgreSQL разбит на 13 схем — по одной на каждый микросервис — что при единой физической базе сохраняет логическую изоляцию данных.
Синхронная и асинхронная коммуникация между сервисами
Когда сервисов больше одного, сразу встаёт вопрос: как они разговаривают друг с другом. Есть два базовых варианта, и каждый подходит для своего случая.
Синхронная коммуникация (HTTP / gRPC)
Сервис А отправляет запрос сервису Б, ждёт ответа, и только после этого продолжает работу. Используется там, где нужен немедленный результат.
Конкретный пример: когда пользователь открывает страницу курса, Education Content Service синхронно запрашивает у Access Service — есть ли у этого пользователя доступ. Без ответа страницу не показать. Синхрон тут оправдан.
Минус: если сервис Б медленно отвечает или упал — сервис А висит и ждёт. Цепочки синхронных вызовов через несколько сервисов — это потенциальные каскадные сбои.
Асинхронная коммуникация (через брокер сообщений)
Сервис публикует событие в RabbitMQ и не ждёт. RabbitMQ хранит событие и доставляет его подписчикам, когда те готовы. Сервис-отправитель продолжает работу немедленно.
Пример из платформы: студент выполняет задание → Progress Service фиксирует это и публикует событие в RabbitMQ → AI-сервис забирает событие и запускает проверку pull request в фоне → одновременно Notification Service забирает то же событие и отправляет пользователю уведомление. Всё это происходит параллельно, клиент не ждёт ни секунды.
Асинхрон отлично подходит для длинных операций, уведомлений, фоновых задач. Но добавляет сложность: что если сервис опубликовал событие, а потом упал до того, как оно записалось в брокер?
Для этого используется Outbox Pattern: событие сначала записывается в таблицу базы данных (outbox), и только после успешной записи отправляется в брокер. Если произошёл сбой — отдельный процесс перечитает outbox и повторно отправит событие. Событие не потеряется.
Мониторинг — не опция, а часть архитектуры
Без логов, метрик и трассировок в production — это не архитектура, это лотерея. Когда что-то ломается (а ломается всегда), первый вопрос: «где именно?». Без наблюдаемости (observability) ответ на него — угадывание.
Стек, который используется в разобранной платформе, стал де-факто стандартом для self-hosted мониторинга:
- Prometheus — хранит метрики (количество запросов, время ответа, использование памяти)
- Loki — хранит логи
- Tempo — хранит трассировки (traces), показывает полный путь запроса через все сервисы
- Grafana — дашборды поверх всего этого, алертинг
- OpenTelemetry (OTLP) — протокол передачи данных от сервисов к стеку мониторинга
Все четыре инструмента разворачиваются вместе и интегрируются через Grafana. Для соло-проекта или небольшой команды — это реальный и не избыточный вариант. Настроить один раз, и дальше у тебя есть ответ на вопрос «что происходит» в любой момент времени.
Из нашего разбора: при 13 микросервисах без трассировок понять, какой именно сервис замедляет запрос — задача нетривиальная. Tempo в связке с OpenTelemetry даёт граф вызовов с временными метками по каждому переходу между сервисами.
Что мы заметили: где материалы сходятся, а где расходятся
Оба разбора сделаны независимо, на разных стеках (.NET и Django), но в нескольких ключевых точках они говорят одно и то же. А кое-где расходятся — и это тоже интересно.
Где полное совпадение
Use Case как обязательный элемент с первого дня. Оба разбора настаивают на этом. Не «можно добавить потом», а «потом будет стоить в разы дороже». Пример с 4–5 месяцами рефакторинга 30% кодовой базы — это конкретная цена откладывания.
Язык и фреймворк вторичны. Принципы изоляции слоёв, инверсии зависимостей и