Агрегаты в микросервисах: гайд по проектированию бизнес-логики с примерами на Java
Всем привет, меня зовут Сергей Прощаев, и в этой статье я расскажу, как не утонуть в бизнес-логике при переходе на микросервисы.
Мы уже прошли путь от монолита к микросервисам. Звучит красиво, но на практике это часто оборачивается большими сложностями: бизнес-логика, которая раньше была компактно упакована в одном месте, превращается в паутину вызовов, и понять, как именно работает приложение, становится почти невозможно.
Мне доводилось переписывать различные 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 и микросервисную архитектуру.
