Всем привет, меня зовут Сергей Прощаев. Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E-commerce, преподаю на курсах разработки и архитектуры в OTUS.

В этой статье разбираю системный подход: как системному аналитику спроектировать доменную модель, которая прямо ложится в код Java-микросервисов, и избежать ситуации, когда архитектор после передачи требований перекраивает всё вслед за словами «это не ложится на DDD».

Рис. 1. Иллюстрация проблемы: как превратить разрозненные бизнес-пожелания в стройную доменную модель на Java
Рис. 1. Иллюстрация проблемы: как превратить разрозненные бизнес-пожелания в стройную доменную модель на Java

Когда аналитик останавливается на уровне «система должна регистрировать пользователя и отправлять email», он фиксирует сценарий, но не границы предметных областей. Регистрация — это не один процесс, а несколько:

  • Управление учётной записью — создать, заблокировать, удалить.

  • Верификация контактов — отправить код, подтвердить email.

  • Уведомления — шаблон письма, политика повторов.

  • Аудит безопасности — кто, когда и откуда менял данные.

Если всё это попадает в один сервис «User», через полгода образуется монолит с высокой связанностью. Разработчикам придётся разбираться, какие намерения стояли за спецификацией, и перекраивать модель вслепую. Решение — начинать не со списка API, а с моделирования предметной области по принципам Domain-Driven Design (DDD).

Шаг 1. Вытащить домен из требований с помощью Event Storming

В одном проекте требовалось спроектировать сервис обработки заказов для B2B-платформы. Вот исходные условия, в которых мы работали:

  • Проект: B2B-платформа оптовых заказов.

  • Команда: 2–3 разработчика, продакт, системный аналитик.

  • Стек: Java 17, Spring Boot, Kafka.

  • Ограничение: строгие требования к консистентности финансовых данных — скидки и счета не должны расходиться.

Бизнес-формулировки были стандартными: «пользователь оформляет заказ, система считает скидки, отправляет уведомление менеджеру». Прямой путь вёл к транзакционному скрипту. Вместо этого я провёл двухчасовую сессию Event Storming с продактом и двумя разработчиками — только стикеры и вопросы: «какое событие происходит?», «кто его вызывает?», «нужно ли немедленное действие?».

Мы выстроили события:

  • Заказ отправлен

  • Скидка рассчитана

  • Заказ подтверждён

  • Счёт-фактура создана

  • Уведомление отправлено менеджеру

Почти сразу стало видно, что «расчёт скидки» и «создание счёта-фактуры» принадлежат разным контекстам: первое — ценообразованию, второе — биллингу/документообороту. События, которые сначала казались линейными, начали группироваться вокруг двух процессов: «Обработка заказа» и «Выставление счёта». Так родилась первая версия ограниченных контекстов (Bounded Context), показанная на рис. 2.

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

Рис. 2. Схема вычленения ограниченных контекстов из бизнес-процесса «оформление заказа»
Рис. 2. Схема вычленения ограниченных контекстов из бизнес-процесса «оформление заказа»

Главный вывод после этой схемы: контексты не соответствуют одному экрану или одному API. «Ценообразование» определяет скидки независимо от бухгалтерского учёта, а «Биллинг» отвечает за финансовые документы. Как только это зафиксировано, мы с архитектором сверили ядро: получилось три отдельных сервиса, а не один «Order Service». Аналитик здесь выступает соавтором границ сервисов, а не просто транслятором требований. Фактически он начинает влиять на архитектуру до того, как написана первая строчка кода.

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

Шаг 2. Перенести модель в Java-код без анемии

Теперь проектирую черновую структуру пакетов и ключевые интерфейсы на Java. Они выражают намерение точнее текста и сразу дают разработчикам опору для реализации.

Пример структуры для контекста «Заказ»:

// Пакет: com.example.order.domain
public class Order {
    private OrderId id;
    private CustomerId customerId;
    private List<OrderLine> lines;
    private OrderStatus status;
    private Money total;

    // Подтверждение заказа возможно только после получения результата ценообразования,
    // даже если скидка равна нулю. Это позволяет агрегату гарантировать,
    // что финальная стоимость согласована с контекстом Pricing.
    public void confirm(Discount discount) {
        if (lines.isEmpty()) {
            throw new EmptyOrderException();
        }
        if (!discount.isApplicableTo(this)) {
            throw new InvalidDiscountException();
        }
        this.total = discount.applyTo(calculateGrossTotal());
        this.status = OrderStatus.CONFIRMED;
        registerEvent(new OrderConfirmed(id, total));
    }

    private Money calculateGrossTotal() { /* ... */ }
}
// Абстракция доступа к агрегатам в доменном слое
public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
}

Агрегат предоставляет метод проверки применимости скидки, не раскрывая деталей вычисления хэша позиций. Это видно в реализации Discount:

// Discount — Value Object, содержащий идентификатор заказа или хэш позиций.
// Это гарантирует, что скидка применима именно к текущему состоянию агрегата.
public record Discount(OrderId orderId, Money amount, CustomerSegment segment,
                       int positionsHash) {
    public boolean isApplicableTo(Order order) {
        // Хэш используется, чтобы не раскрывать состав заказа вне агрегата
        return order.getId().equals(orderId) 
               && order.positionsHash() == positionsHash;
    }
}

// Контекст ценообразования — отдельный доменный сервис
public interface DiscountCalculator {
    Discount calculate(List<OrderLine> lines, CustomerSegment segment);
}

Я размещаю такой код прямо в Confluence рядом с User Story. Разработчики видят не «система должна рассчитать скидку», а место в модели, куда эта скидка встанет, и инварианты, которые гарантируют целостность. Споров о структуре становится вдвое меньше — не нужно угадывать замысел аналитика.

Без предварительного моделирования путаница неизбежна. Спецификация на сорок страниц без единого фрагмента кода, зато с десятками UML-диаграмм, которые ничего не говорят о бизнес-инвариантах, часто скрывает тот факт, что «расчёт комиссии» — отдельный контекст, а не метод внутри Order.

Проверка: попробуйте мысленно выполнить сценарий «подтверждение заказа без скидки». Если агрегат не падает и сохраняет инвариант, структура выдерживает базовый тест.

Шаг 3. Согласовать взаимодействие через интеграционные события

После построения модели я провожу 30-минутную синхронизацию с командой. Показываю три вещи: предлагаемые пакеты, расположение бизнес-правил и интеграционные события, пересекающие границы контекстов (рис. 3).

Рис. 3. Асинхронное взаимодействие контекстов через интеграционные события: заказ подтверждается с применением рассчитанной скидки в рамках бизнес-инварианта агрегата
Рис. 3. Асинхронное взаимодействие контекстов через интеграционные события: заказ подтверждается с применением рассчитанной скидки в рамках бизнес-инварианта агрегата

Важно: заказ не «ждёт» скидку синхронно. После публикации OrderPlaced он переходит в статус PRICING_PENDING, а подтверждение происходит отдельной командой после получения события DiscountCalculated. Так каждый контекст остаётся автономным, и распределённой транзакции не требуется.

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

Проверка: убедитесь, что при «падении» сервиса Pricing, заказ остаётся в статусе PRICING_PENDING и может быть подтверждён позже, когда ценообразование восстановится.

Лучшая практика: границы контекстов и границы команд

Вспоминается реальный случай из финтех-проекта. Команды «Платежи» и «Клиенты» постоянно блокировали релизы друг друга: любое изменение в карточке клиента ломало интеграции с платёжным шлюзом. Причина — общая модель Customer, используемая обеими командами. Как только мы, системные аналитики, предложили развести её на два ограниченных контекста — Identity(ФИО, паспортные данные) и PaymentProfile (карты, лимиты) — конфликт исчез. Модель стала развязанной, и границы контекстов совпали с границами ответственности команд. Этот шаблон теперь применяю в большинстве проектов.

В стартапе из трёх человек такой подход может быть избыточен, но как только команд становится больше, явные контексты становятся обязательным условием независимой разработки.

Что делать с этим знанием

Системный аналитик, владеющий Event Storming, выделением Bounded Context и проектированием агрегатов, перестаёт быть просто составителем требований. Он влияет на архитектуру микросервисов, а разработчики получают модель, а не гадание. Если вы хотите освоить этот переход системно и попрактиковаться в построении доменных моделей под руководством практиков, обратите внимание на курс OTUS «Системный аналитик: проектирование и DDD». На нём мы разбираем реальные кейсы вплоть до Java-реализации.

Когда требования описаны подробно, но доменная модель не собрана, команда всё равно приходит к хаосу: один сервис разрастается, бизнес-правила размазываются по коду, а любое изменение начинает ломать соседние контексты.

Чтобы такого не происходило, приходите на демо-уроки:

Уроки бесплатные, проходят в рамках онлайн-курсов OTUS и помогают познакомиться с преподавателями-практиками, протестировать формат обучения и задать вопросы.

А ещё подписывайтесь на канал OTUS в MAX — там публикуем анонсы открытых уроков, полезные материалы и разборы для тех, кто хочет расти в разработке, архитектуре, аналитике и управлении.