Всем привет, меня зовут Сергей Прощаев, и в этой статье я расскажу, как не утонуть в бизнес-логике при переходе на микросервисы.
Мы уже прошли путь от монолита к микросервисам. Звучит красиво, но на практике это часто оборачивается большими сложностями: бизнес-логика, которая раньше была компактно упакована в одном месте, превращается в паутину вызовов, и понять, как именно работает приложение, становится почти невозможно.
Мне доводилось переписывать различные legacy-системы на микросервисы. И всегда решение о переходе на микросервисы принимают, когда возникает классическая ситуация: код в legacy-системе разрастается до невероятных размеров и все бизнес-правила начинают пересекаться между собой.
Сегодня я поделюсь этим опытом, используя в качестве примера классический UserService, и расскажу, как паттерны из DDD помогают навести порядок.
Проблема: почему «грязный» код убивает микросервисы
Давайте представим, что перед нами стоит задача написать простой UserService.
Когда мы только начинаем его проектировать, первая мысль: «Давайте просто реализуем CRUD». Но в реальности функционал оказывается сложнее. Появляется необходимость регистрации с верификацией, восстановлением пароля, админкой для управления сотрудниками и кучей всего.
В классическом монолите мы бы создали пару классов, соединили их через Hibernate, и всё работало бы в рамках одной транзакции. В микросервисной архитектуре всё иначе. Ключевых проблем, которые ломают привычный подход, две:
Ссылки через границы. В монолите я мог написать
order.getUser().getEmail(). В микросервисах Order и User — это разные сервисы. Если мы позволим себе объектные ссылки, то неизбежно начнем тащить за собой весь граф объектов, создавая жесткую связанность.Транзакции. ACID-транзакции — это роскошь одного сервиса. Как только нам нужно согласовать данные между UserService и EmailService или AuthService, привычный
@Transactionalперестает работать. Мы не можем атомарно создать пользователя и отправить письмо.
И тут можно наступить на грабли. Если мы храним в сущности User прямую ссылку на объект Role из другого сервиса, то при обработке запроса на получение одного пользователя автоматом тянутся за собой данные о ролях, правах доступа и даже история логинов из смежного сервиса. Это напоминает коллапс.
Спасательный круг: DDD и шаблоны организации логики
Чтобы выбраться из этого болота, нам понадобилась четкая структура. Можно выделить два основных подхода к организации бизнес-логики.
Первый — «Сценарий транзакции» (Transaction Script). Это процедурный подход, когда на каждый запрос у нас есть отдельный метод.
Например, UserRegistrationService.register() содержит всю логику: валидацию, сохранение, отправку письма. Для простых операций это идеально. Но как только бизнес-логика усложняется (появляются статусы верификации, блокировки, сценарии сброса пароля), такие классы превращаются в свалку.
В нашем UserService мы обычно начинаем именно с этого. И очень скоро UserService разрастается до 3000+ строк. В нем появляется всё: логика валидации, логика отправки уведомлений, правила блокировки. Изменить что-то становится страшно, потому что любое изменение может незаметно нарушить работу другого сценария.
Второй — «Доменная модель» (Domain Model). Это классическое ООП. Но в микросервисной архитектуре классического ООП недостаточно. Нам нужна его более строгая версия — Предметно-ориентированное проектирование (DDD).
Агрегат как ядро сервиса
Эрик Эванс ввёл понятие агрегата. Это не просто класс, а кластер объектов, с которыми мы работаем как с единым целым. Агрегат решает ровно те две проблемы, которые описаны выше.
Четкие границы. Агрегат не может ссылаться на другой агрегат по объектной ссылке. Только по идентификатору (ID). Это автоматически исключает ситуацию, когда мы пытаемся сериализовать полграфа объектов.
Границы согласованности. DDD рекомендует, чтобы одна транзакция изменяла только один агрегат². Это идеально ложится на модель микросервисов, где транзакции изолированы. Для систем с высокой нагрузкой это становится практически обязательным правилом.
В UserService выделим агрегат User. Посмотрим, как он выглядит в коде.
Структура агрегата User
Вот пример агрегата User, написанного с использованием Spring Boot и JPA. Важно: в учебных примерах часто совмещают доменную модель и JPA-аннотации. В production-проекте лучше разделить доменный слой и инфраструктуру (persistence model), но для наглядности я оставлю упрощённый вариант. Обратите внимание: мы не храним здесь ссылки на объекты ролей или токенов, только их идентификаторы.
@Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String passwordHash; @Enumerated(EnumType.STRING) private UserStatus status; // ACTIVE, PENDING_VERIFICATION, BLOCKED @Version private Long version; // для оптимистичной блокировки // Ссылки на другие агрегаты — только по ID. // Здесь роли — это отдельные агрегаты, поэтому храним их идентификаторы. // В реальном проекте коллекцию ролей, скорее всего, вынесут в отдельную // сущность или репозиторий; здесь же для простоты используем сериализованный JSON. @Column private String roles; // например, "[1,2,3]" — идентификаторы ролей // Приватный конструктор для JPA protected User() {} // Фабричный метод для создания пользователя public static User create(String email, String rawPassword, PasswordEncoder encoder) { User user = new User(); user.email = email; user.passwordHash = encoder.encode(rawPassword); user.status = UserStatus.PENDING_VERIFICATION; user.roles = "[]"; // Генерируем доменное событие user.registerEvent(new UserCreatedEvent(user.id, user.email)); return user; } // Бизнес-метод: подтверждение email public void verify() { if (this.status != UserStatus.PENDING_VERIFICATION) { throw new IllegalStateException("User is not pending verification"); } this.status = UserStatus.ACTIVE; registerEvent(new UserVerifiedEvent(this.id)); } // Бизнес-метод: блокировка пользователя public void block(String reason, Long adminId) { if (this.status == UserStatus.BLOCKED) { return; // уже заблокирован } if (this.id.equals(adminId)) { throw new BusinessRuleException("Administrator cannot block themselves"); } this.status = UserStatus.BLOCKED; registerEvent(new UserBlockedEvent(this.id, reason, adminId)); } // Механизм хранения событий перед публикацией @Transient private List<DomainEvent> events = new ArrayList<>(); private void registerEvent(DomainEvent event) { events.add(event); } public List<DomainEvent> getEvents() { return List.copyOf(events); } public void clearEvents() { events.clear(); } }
Что здесь важно:
Статический фабричный метод
create(). Он не просто создает объект, но и генерирует событиеUserCreatedEvent.Методы
verify(),block(). Они содержат бизнес-логику и проверку инвариантов (правил). Например, «Администратор не может заблокировать сам себя» — это инвариант, который живет прямо внутри агрегата.Ссылка на роли через идентификаторы. Мы не храним объекты
Role, только их ID. В коде выше я упростил хранение до сериализованного поля. В реальном проекте роли были бы отдельным агрегатом, и связь с ними — только через ID.Поле
version. Это ключ к оптимистичной блокировке, который гарантирует, что два параллельных запроса не нарушат согласованность агрегата.
Правила работы с агрегатами
При проектировании агрегатов я придерживаюсь трёх правил, которые спасли нас от множества проблем:
Ссылаемся только на корень агрегата. Никто не должен получать доступ к вложенным сущностям напрямую. Все изменения — только через методы корня (User).
Межагрегатные ссылки — только по ID. Это радикально снижает связанность. В нашем UserService нет никаких
@ManyToOneна другие сервисы.Одна транзакция — один агрегат. Если нужно обновить несколько агрегатов (например, User и его Account), мы не делаем это в одной транзакции. Для этого используем Saga (повествование), о котором поговорим дальше.
Доменные события: надёжная интеграция через Transactional Outbox
Агрегаты не живут в вакууме. Когда пользователь регистрируется, нам нужно:
отправить письмо на почту;
создать запись в AuthService (для выдачи токенов);
записать лог в AuditService.
Как это сделать, не нарушая правило «одна транзакция — один агрегат»? Решение — доменные события, публикуемые надёжным способом.
Ключевая проблема: если мы опубликуем событие сразу после сохранения пользователя в базу (например, через ApplicationEventPublisher Spring), а сервис упадёт до того, как событие будет доставлено, оно будет потеряно. Это недопустимо для критичных интеграций.
Правильный подход — паттерн Transactional Outbox³. Идея проста:
В рамках той же ACID-транзакции, которая сохраняет агрегат, мы сохраняем событие в специальную таблицу
outbox.Отдельный процесс (CDC или простой poller) читает эту таблицу и отправляет события в брокер сообщений (Kafka, RabbitMQ), удаляя или помечая их как отправленные.
Брокер гарантирует доставку потребителям.
Реализация на Spring Boot (упрощённый вариант)
@Service @Transactional public class UserService { private final UserRepository userRepository; private final OutboxRepository outboxRepository; public User register(RegistrationRequest request) { // 1. Создаём агрегат User user = User.create(request.getEmail(), request.getPassword(), passwordEncoder); // 2. Сохраняем пользователя в БД userRepository.save(user); // 3. Сохраняем события в OUTBOX в той же транзакции List<DomainEvent> events = user.getEvents(); for (DomainEvent event : events) { outboxRepository.save(new OutboxRecord( event.getAggregateType(), event.getAggregateId(), event.getClass().getName(), serialize(event) )); } user.clearEvents(); return user; } }
Отдельный компонент‑отправитель (например, шедулер или Debezium) читает outbox и отправляет в Kafka:
@Component public class OutboxPoller { @Scheduled(fixedDelay = 1000) @Transactional public void processOutbox() { List<OutboxRecord> records = outboxRepository.findUnsent(); for (OutboxRecord record : records) { try { kafkaTemplate.send("domain-events", record.getEventType(), record.getPayload()); record.markSent(); outboxRepository.save(record); } catch (Exception e) { // логируем ошибку, событие остаётся в outbox для повторной попытки } } } }
В production‑средах вместо самописного poller часто используют Debezium (CDC) или Transactional Outbox с поддержкой на уровне фреймворка (например, в Axon Framework, Eventuate Tram). Это обеспечивает практически нулевую задержку и надёжную доставку.
Собираем всё вместе: диаграмма последовательности
Чтобы визуализировать, как агрегат, сервис и внешние системы взаимодействуют, я использую диаграммы последовательности. Вот как выглядит процесс регистрации пользователя с учётом Transactional Outbox (см. рис. 1).

Обратите внимание на ключевые детали:
Сохранение событий в outbox происходит в той же транзакции, что и пользователь. Если что‑то пойдёт не так, транзакция откатится целиком.
Отправка в Kafka (или другой брокер) выполняется асинхронно, после фиксации транзакции.
Даже если сервис упадёт после сохранения пользователя, но до отправки событий, poller на следующем цикле подхватит неотправленные записи и доставит их. Это гарантирует at‑least‑once доставку.
Заключение: Стратегия, а не писательство
Проектирование бизнес-логики в микросервисной архитектуре — это не про написание кода. Это про умение структурировать хаос. Агрегаты из DDD дают нам ту самую «клетчатую структуру», которая не позволяет бизнес-логике расползаться. Они принудительно заставляют нас:
определять границы объектов;
разрывать прямые объектные связи;
использовать асинхронное взаимодействие через события с надёжной доставкой.
Если вы чувствуете, что ваш сервис превращается в «транзакционный скрипт» с километрами кода, и вам нужна система, а не интуиция — рекомендую глубже погрузиться в паттерны DDD и микросервисную архитектуру.

Поделиться своим опытом и разобрать реальные кейсы я планирую на открытом уроке «Основы проектирования бизнес-логики в микросервисной архитектуре», который пройдёт 15 апреля 2026 года в рамках курса Microservice Architecture. Мы разберём не только теорию, но и то, как писать код, который не развалится при масштабировании. Регистрируйтесь на странице курса.
Пройдите бесплатное тестирование по курсу, чтобы оценить свои знания и навыки. До 30 апреля за прохождение теста действует
скидка 15%
