Когда я проходил собеседование на текущее место работы, я упомянул о себе такую вещь: мне нравится участвовать в проектах, которые имеют социальные последствия. И талантливые менеджеры, нашли для меня аргументы, почему их проект именно такой и раcсказ меня очень подкупил. И даже больше — довольно быстро речь зашла о том, что текущие инструменты устаревают, требуется новое более гибкое решение.
Поначалу мне попали в работу легаси проекты, архитектура которых была Transactional Script или Table Module. Модули требовали рефакторинга, решения тех.долгов, встал вопрос о целесообразности рефакторинга и альтернативных реализаций. Как инженер, я решил, что единственный верный шаг прокачать себя, а затем и команду, теоритически, а потом предпринимать стратегические шаги. Если с TS и TM архитектурами я был хорошо знаком, то шаблон Domain Model был знаком только в самых общих чертах по книге Мартина Фаулера. На фоне общения на конференциях, чтения матёрых книг про рефакторингу, SOLID, Agile, пришло понимание почему именно изучение подобных архитектур оправдано: в Enterprise есть смысл стремиться к максимально адаптируемому к изменениям ПО, а для доменной модели изменения требований стоят несравнимо дешевле в реализации. И меня напрягало, что как раз доменные модели я если и применяю, то понаитию, бессистемно, невежественно. Так началось моё знакомство с предметно-ориентированным проектированием.
В этой первой части, о том какие наработки удалось получить команде.
Тактические паттерны
Для DDD подходит несколько архитектур, которые приблизительно об одном и том же, самые известные: луковая и гексогональная. Полученный реультат, мне больше нравится называть гексогональной архитектурой. У нас выделен ряд обязательных слоёв с разной ответственностью, взаимодействие между слоями как положено — по порядку, наружу доступны только абстракции. Далее хотел бы последовательно разобрать эти слои, последовательно описав, что там происходит.
Доменная модель
В этом слое описывается агрегат ограниченного контекста, связанные сущности, перечисления, спецификации, утверждения и т.п. Сам слой неоднородный, состоит из двух частей:
- Абстрактная модель. Публичные интерфейсы модели, которые могут быть доступны в других слоях. Сами интерфейсы пишутся так, чтобы они наследовали интерфейсы из нашего Seedworks, что позволяет избежать зоопарка в различных проектах. Абстрактная модель — первое с чего начинается любой сервис, т.к. содержит в себе ОБЩЕУПОТРЕБИМЫЙ ЯЗЫК.
- Реализация модели. Internal реализация агрегата, содержатся необходимые проверки, скрываются фабрики, бизнес-методы, утверждения и т.д.
Реализация агрегата
Команда рассматривала следующие способы реализации агрегата:
- Свойства с модифицированными set'ерами, в которых сокрыта логика обнаружения изменений. Код получается неоправданно усложнённым, и не совсем понятно зачем. Мы имели такую реализацию, когда ещё оперировали анемичной моделью (вспоминаю как страшный сон).
- Aggregate Snapshots. Механизм делает регулярно или по триггеру снимки агрегата и, если что-то поменялось, регистрируется событие.
- Иммутабельные агрегаты, порождающие через бизнес-методы новую версию агрегата. В нашей команде прижился 3й вариант — он сулит самые большие перспективы для распределённой системы.
Итак, строение агрегата.
- Анемичная модель. Анемичных модели у нас две: обычная, и "дефолтная", с пустыми объект-значениями и корнем. При этом анемичная модель — условная часть агрегата, существующая только для организации жизненного цикла данных, т.е. в репозитории, фабриках.
- Идентификаторы. Мы используем составной ключ <guid, long>. Первая часть идентифицирует агрегат, вторая его версию.
- Корень агрегата. Обязательная сущность, вокруг которой и строится ограниченный контекст. С этим элементом у нас были проблемы, мы ожидали что корень будет иммутабельным на всём протяжении жизненного цикла агрегата, однако, практика показала другое, нежели в книгах. Позже слышал на DDDevotion от Константина Густова то же самое.
- Объект-значения. Простой иммутабельный класс: конструкторы закрыты, фабричные методы открыты.
- Бизнес-методы. В нашей реализации составной объект, состоящий из предусловий и постусловий. Результат выполнения операции — усложнённая монада
Result
или сложная структура, возвращающая две анемичных модели и результат операции. Результаты операций на данный момент делим на:
- Успешные.
- Ошибочные по бизнес-проверкам, которые могут порождать новую версию агрегата, однако, могут иметь место проблемы с постусловиями.
- Фатальная проблема, когда предусловие говорит о том, что данная операция не может быть выполнена.
Доменные сервисы
Этот слой ответственен за работу с агрегатом. Состоит двух механизмов:
- Нотификатор доменных сообщений. Декорирует методы агрегата, реализуя его абстракцию. Данный механизм служит для обработки анемичной модели до вызова новой версии анемичной модели и результата операций. Из этих сообщений формируются доменное событие, которое жёсткой схемой отражено в SeedWorks и реактивно помещается в Шину Доменных Событий.
- Провайдер агрегатов. Данный механизм инкапсулирует репозитории и фабрики, т.е. служит для получения по идентификатору агрегата и поддержки жизненного цикла агрегата. Задача провайдера — построить агрегат из анемичной модели, декорировав его методы нотификатором.
Сначала мы пытались реализовать поиск через провайдер с применением спецификации, однако, отловили проблемы с EF. Эти проблемы застали думать о CQS. Теперь CQRS+ES у нас из коробки, т.е. доменные события отражаются на материализованных представлениях, и, в свою очередь, с их помощью происходит поиск нужного агрегата. В случае если агрегат не найден в мат.представлениях, провайдер соберёт агрегат с пустой моделью — это удобно тем, что бизнес-методы всегда остаются внутри агрегатов.
Слой приложения
Слой приложений довольно обыденный. Где-то свои обработчики, где-то на основе MediatR, но, в любом случае, всем командам ограниченного контекста надлежит получить из DI-контейнера провайдер агрегатов, а затем в обработчике из него (что?) определённую версию агрегата, у которой уже вызывается бизнес-метод.
Слой сервисов
С сервисами всё интересно. По умолчанию, мы продолжаем использовать .NET Core Web API, т.е. REST, протокол. Однако, REST — это про архитектуры TableModule и нельзя использовать глаголы PUT, DELETE для модифицирования агрегата. Контроллеры наших микросервисов повторяют методы агрегата, используя глагол POST, ведь для стратегических паттернов нужны идемпотентные операции. В итоге получается дисфункция использования контроллеров. Возможно, следует использовать gRPC.
Инфраструктурный слой
В этом слое обычно ряд модулей: маппинги, хранилища команд, материализованные представления.
Хранилище и материализованное представление напрямую не привязано к агрегату. Вместо этого, они прослушивают ШДС(шину доменных событий), асинхронно обрабатывая эти события. Из этого не трудно понять, что ШДС становится для нас механизмом масштабирования.
Как это выглядит в итоге
С одной стороны:
Описание | Зарисовка |
---|---|
Гексогональная архитектура микросервисов, декомпозированных по субдомену. | |
Команда над доменной сущностью порождает новый объект (версию). | |
Сравнение версий агрегата и метаданные команды (источник доменного события). | |
Для распространений изменений используется ШДС, что открывает возможности для CQRS и ES. | |
Версионирование команд и агрегатов должны помочь избежать блокировок и перепроверок при помощи оптимистичных блокирок. Появлется возможность ветвлений-сессий. |
С другой стороны:
- Тактические паттерны освоены костяком команды. Каждый может вести свою команду, распространять подход дальше.
- Наработки позволяют начинать работу с контестом даже если единый язык беден, оставив от модели лишь корень. По мере уточнения общеупотребимого языка, модель будет расширяться.
- Из всех взятых в работу ограниченных контекстов генерируются доменные события пригодные к использованию в смежных ограниченных контекстах.
- Предметная сложность полностью в модели. Даже инфраструктурных сложностей нет как таковых — понятная работа по материализованным представлениям, обработчикам слоя приложения. Вместе с решением технической сложности, появляется soft-slills сложности.
После принятия этих практик, формирования некоторой культуры кода, у нас начинает появляться время задумываться о несравненно более интересном — стратегических паттернах: о ядре домена, отношениях контекстов, плане их развития.
Данная часть, возможно, не настолько содержательная, как того хотелось бы читателю. Вместе с тем, я считаю что это и не важно, так как подсмотреть следует только цель, а путь нужно пройти с командой. В следующей части хотел бы рассказать о том, как я пытался организовать наш путь.