Всем привет, меня зовут Сергей Прощаев, и в этой статье я расскажу про одну из самых сложных, но в то же время интересных тем в проектировании микросервисов — бизнес‑логику. За годы работы над разными проектами — от классического монолита до высоконагруженных распределенных систем — я пришел к выводу, что именно бизнес‑логика является тем самым сердцем приложения. Но в микросервисной архитектуре это сердце начинает биться в ритме, который требует совершенно иного подхода к проектированию.

Мы часто думаем, что микросервисы — это про технологии, контейнеризацию и оркестрацию. На самом деле, микросервисы — это в первую очередь про управление сложностью. И если вы неправильно организуете бизнес‑логику внутри каждого сервиса, ваша распределенная система превратится в монолит с распределенными страданиями. Сегодня мы разберем, как избежать этого, используя концепции из мира предметно‑ориентированного проектирования (DDD), которые я считаю обязательными для любого архитектора.

Вместо традиционного примера с заказами и ресторанами, как в классических книгах, я предлагаю рассмотреть другой, не менее популярный домен — управление корпоративными сотрудниками (HRM). Допустим, у нас есть платформа, которая управляет жизненным циклом сотрудника: от найма до увольнения. Наша задача — спроектировать бизнес‑логику так, чтобы она была устойчивой, тестируемой и не приводила к коллапсу при масштабировании.

Две стороны одной медали: сценарий транзакции против доменной модели

Когда вы только начинаете писать сервис, особенно под давлением дедлайнов, руки сами тянутся к процедурному коду. Вы создаете класс EmployeeService, добавляете метод hireEmployee() и начинаете туда писать: проверить email, сохранить в базу, отправить уведомление. Это и есть Сценарий транзакции (Transaction Script). Мартин Фаулер в своей классической книге описывает его как процедурную организацию бизнес‑логики, где один метод обрабатывает один запрос из UI.

В одном из моих проектов на стартап‑стадии это казалось идеальным решением. За три месяца мы написали 40 таких сценариев. Но когда бизнес‑логика усложнилась (появились правила о том, что сотрудник не может быть руководителем сам себе, ограничения по пересечению грейдов, сложная логика расчета KPI), этот подход дал трещину. Классы раздувались до 1000+ строк, а логика дублировалась. Один метод updateEmployee использовался и для смены отдела, и для повышения, и для переименования, обрастая флагами и if-else. Поддерживать это стало кошмаром.

Альтернатива — Доменная модель. Это не просто набор классов с геттерами и сеттерами. Это модель, где объекты обладают не только состоянием, но и поведением. Вместо того чтобы вызывать внешний сервис для проверки правил, вы говорите объекту: employee.promoteTo(Manager.class), и он сам решает, можно ли это сделать, и какие правила при этом нарушаются.

Я долгое время сопротивлялся внедрению DDD в проект, где «все горит», считая это оверинжинирингом. Но опыт показал обратное: если бизнес‑логика сложнее CRUD, использование Transaction Script неизбежно ведет к техническому долгу. Сложность никуда не девается — она просто переходит из кода в головы разработчиков, которые пытаются запомнить все побочные эффекты.

Агрегат как спасение от хаоса

Но просто сделать объектную модель недостаточно. В микросервисной архитектуре есть две ключевые проблемы, которые убивают традиционную объектную модель:

  1. Ссылки через границы. Если у вас есть объект Employee, который напрямую ссылается на объект Department из другого сервиса, вы теряете независимость.

  2. Транзакции. ACID‑транзакции не работают между сервисами.

Здесь на сцену выходит Агрегат (Aggregate) — концепция из DDD, которую я считаю лучшим строительным блоком для микросервисов. Агрегат — это кластер объектов, с которыми мы работаем как с единым целым. У него есть корневая сущность (Root), и именно через нее происходит все взаимодействие.

Давайте посмотрим на наш HR‑сервис. Вместо того чтобы создавать паутину из взаимосвязанных классов EmployeeContractAddressContactInfo, мы определим агрегат EmployeeProfile. Вот как это выглядит визуально:

Рис. 1 Агрегат EmployeeProfile
Рис. 1 Агрегат EmployeeProfile

Обратите внимание на ключевую деталь: departmentId — это не объектная ссылка на Department, а просто идентификатор. В книге Криса Ричардсона это правило названо одним из ключевых. Мы избавляемся от объектных ссылок, которые пересекают границы сервисов, заменяя их на ссылки по первичному ключу.

Почему это важно? В сети есть описание инцидента в одном из финтех‑проектов, где разработчики использовали Hibernate с ленивой загрузкой между агрегатами. В итоге во время одного из релизов получили миллион N+1 запросов к базе, потому что при загрузке одного объекта цеплялась вся графовая модель, которая уже не помещалась в одну транзакцию. Переход на ссылки через ID снизил связанность и позволил нам четко разделить транзакционные границы.

Правила агрегации, которые требуют внимания

DDD требует, чтобы агрегаты подчинялись набору правил. Эти правила — не догма, а прагматичный инструмент для снижения сложности в распределенных системах.

Правило 1. Транзакция = один агрегат

Самое контринтуитивное правило. В монолите мы привыкли оборачивать в транзакцию обновление десятка таблиц. В микросервисе транзакция базы данных может обновлять только один экземпляр агрегата.

Когда я впервые прочитал это в книге Эрика Эванса, я подумал: «Это же неудобно». И действительно, неудобно. Но это ограничение заставляет нас мыслить в терминах согласованности в конечном счете. Если нам нужно завести сотрудника и создать для него аккаунт в соседнем Auth‑сервисе, мы не можем делать это в одной транзакции.

Решение — Повествование (Saga). Мы либо используем хореографию (события), либо оркестрацию (центральный координатор). В нашем HR‑сервисе мы пошли по пути оркестрации, создав Saga HireEmployeeSaga. Первый шаг — создаем агрегат EmployeeProfile со статусом PENDING. Второй шаг (уже в другом сервисе) — создаем учетную запись. Если что‑то пошло не так, мы откатываем изменения компенсирующими действиями.

Правило 2. Внешние ссылки — только через ID

Это правило мы уже затронули. Но я хочу подчеркнуть его важность на примере нефункциональных требований. Когда агрегаты ссылаются друг на друга по ID, их становится невероятно просто шардировать (сегментировать). Вам не нужно заботиться о том, чтобы связанные объекты лежали в одной ноде. Это делает масштабирование базы данных (например, в MongoDB или Cassandra) тривиальной задачей.

Правило 3. Размер имеет значение

Как понять, где заканчивается один агрегат и начинается другой? Это, пожалуй, самое сложное решение для архитектора. Я использую простое эмпирическое правило: агрегаты должны быть мелкими, насколько это возможно, но настолько крупными, насколько это необходимо для обеспечения инвариантов.

В нашем примере я мог бы сделать EmployeeProfile гигантским и включить в него всех подчиненных сотрудника. Тогда увольнение руководителя атомарно блокировало бы изменения всех его подчиненных. Но это убило бы масштабируемость. Обновления по разным руководителям сериализовались бы в одном агрегате, создавая узкое место.

Мы выбрали компромисс: EmployeeProfile содержит только самого сотрудника и его контракт. Управление иерархией вынесено в отдельный агрегат TeamHierarchy. Если менеджер увольняется, Saga сначала проверяет, что у него нет активных подчиненных, и только потом обновляет агрегат EmployeeProfile.

Доменные события: мост между сервисами

Агрегаты — это хорошо, но как они взаимодействуют в распределенной системе? Синхронные вызовы REST между сервисами для обновления данных — это путь к созданию распределенного монолита. Мы используем Доменные события.

В моей практике был случай, когда команда долго спорила, как синхронизировать данные между HR‑сервисом и сервисом зарплат (Payroll). Синхронный вызов приводил к тому, что при сбое Payroll не мог уволиться ни один сотрудник. Асинхронные события решили проблему.

Доменное событие — это, по сути, глагол в прошедшем времени, который описывает факт изменения состояния агрегата. Например, EmployeeHiredEventDepartmentChangedEventContractTerminatedEvent.

Мы реализуем генерацию событий прямо в методах агрегата. Это гарантирует, что событие не будет потеряно. Вот как это обычно выглядит в коде на Java (листинг концептуальный, но отражает суть):

public class EmployeeProfile {
    public ResultWithEvents hire(HireCommand cmd) {
        // Бизнес-логика: проверка уникальности, валидация
        this.status = PENDING;
        // Возвращаем событие
        return new ResultWithEvents(this, new EmployeeHiredEvent(cmd.getEmail()));
    }
}

Сервисный слой затем берет это событие и публикует его в брокер сообщений (Kafka, RabbitMQ) в рамках той же транзакции, что и сохранение агрегата. Это ключевой момент. Без транзакционной публикации вы рискуете получить состояние, когда агрегат сохранен, а событие не ушло, или наоборот.

Простая самописная реализация с таблицей OUTBOX (Transactional Outbox) решают эту проблему. Мы используем подход с таблицей OUTBOX: при сохранении агрегата мы пишем событие в ту же базу данных в таблицу outbox. Отдельный процесс‑публикатор читает эту таблицу и отправляет сообщения в брокер. Это гарантирует надежность.

Реальный кейс: история про увольнение 5000 сотрудников

В сети можно найти историю, которая отлично иллюстрирует важность продуманного проектирования бизнес‑логики. В одном крупном ритейлере стояла задача мигрировать HR‑систему с устаревшего монолита на микросервисы. Сервис должен был управлять профилями сотрудников.

Казалось бы, тривиальная задача: CRUD для сотрудников. Но в процессе анализа выяснилось, что существует бизнес‑правило: при увольнении сотрудника его данные не удаляются (по закону их нужно хранить 75 лет), а переходят в архив. Но самое страшное — при увольнении руководителя автоматически должна перестраиваться иерархия подчинения для тысяч сотрудников, и все их зарплатные контракты должны быть переподписаны новым руководителем.

Если в этой задаче применять проектирование сервиса по принципу «сценарий транзакции», то метод terminateEmployee разросся бы до невероятных размеров, пытаясь за одну синхронную операцию обновить все связанные сущности, включая те, что лежат в других микросервисах. Система бы просто упала под нагрузкой.

Как можно выйти из ситуации? Здесь возможно применить подход с агрегатами и событиями:

  1. Агрегат EmployeeProfile отвечает только за свой статус. При увольнении он переходит в статус TERMINATED и генерирует событие EmployeeTerminated.

  2. Отдельный агрегат OrgStructure (оркестратор) подписывается на это событие.

  3. Получив событие, Saga ReassignSubordinatesSaga запускает процесс переподчинения. Каждый шаг этой Saga обновляет ровно один агрегат (другого сотрудника), и так до тех пор, пока всех подчиненных не переведут.

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

Нефункциональные требования и тестирование

Хороший архитектор не останавливается на функциональной схеме. Он задает вопросы про производительность, доступность и безопасность. Для нашего HR‑сервиса мы определили следующие NFR:

  • Производительность: операция найма сотрудника должна занимать < 200 мс (время отклика для пользователя), несмотря на то, что за кулисами запускается Saga из 3 шагов.

  • Атомарность данных: событие должно быть доставлено хотя бы один раз (at‑least‑once), следовательно, все потребители событий должны быть идемпотентными.

  • Аудитинг: каждое изменение агрегата, инициированное админом, должно логироваться с указанием who, when, what.

Прописывая эти требования на этапе проектирования, мы закладываем архитектуру. Например, требование к производительности заставило нас сделать EmployeeProfile полностью изолированным от внешних синхронных вызовов. Все интеграции — асинхронные.

Заключение

Проектирование бизнес‑логики в микросервисной архитектуре — это всегда баланс между прагматизмом и инженерией. Вы можете начать с простых сценариев транзакций, и если бизнес‑логика остается CRUD‑подобной, это может быть оправдано. Но как только появляются сложные правила, инварианты и требования к масштабируемости, без DDD и агрегатов не обойтись.

Хороший системный архитектор учит не столько инструментам, сколько мышлению. Инструменты меняются, но принципы управления сложностью остаются неизменными. Агрегаты, доменные события и ограничения на транзакции — это те самые принципы, которые позволяют нам не бояться изменений и уверенно масштабировать систему.

Если этот разбор был полезен, приходите обсуждать архитектуру в комментариях. Какие подходы к организации бизнес‑логики используете вы? Сталкивались ли с проблемой размытых границ агрегатов в своих проектах?

В OTUS есть несколько курсов по архитектуре, на которых можно серьезно прокачать свои скилы в этом направлении: «Архитектор программного обеспечения», «Микросервисная архитектура», «Domain Driven Design и асинхронная архитектура»

[Больше курсов в каталоге]

Микросервисы усложняют не код, а принятие решений: где границы, как сохранить целостность данных и не превратить систему в хаос — бесплатные уроки ниже как раз об этом. Приходите, чтобы познакомиться с преподавателями курсов и задать свои вопросы:

  • 14 апреля, 20:00. «Влияние нефункциональных требований на архитектуру». Записаться

  • 15 апреля, 20:00. «Джон, которого нет: Как микросервисы убивают целостность данных и что с этим делать системному аналитику». Записаться