От сердца к коду
76 subscribers
5 photos
28 links
Блог для техлидов и тех, кто к этому идёт. Про Go, архитектуру приложений, DDD, SOLID и паттерны — с практикой, книгами и примерами из реальной разработки. Плюс управление командой: люди, рост, собеседования, делегирование. Пишу как руководитель разработк
Download Telegram
Стратегические паттерны DDD

Последним постом про Dependency Inversion мы закончили краткий обзор принципов SOLID. Я очень надеюсь, что вы пошли немного дальше и постарались разобраться с каждым принципом внимательнее, по крайней мере, почитали ссылки, которые я приводил.

Теперь хотелось бы перейти к теме посерьезнее - Domain Driven Design. Я думаю тут не получится обойтись краткостью, и иногда придется возвращаться назад, что-то дополнять, возможно, переосмысливать. Признаюсь честно, не все темы в DDD мне кристально ясны, некоторые спорны, обсудим и это также.

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

В этом посте пройдемся вкратце по стратегическим паттернам DDD.

1️⃣ Предметная область

От классика: это любая область знаний.

На этом этапе нам нужно понять, как разрабатываемая часть работает в действительности, в бизнесе. С этим нам помогают эксперты предметной области: бизнес-аналитики, конечные пользователи, сам бизнес, который пришел к нам за приложением. Еще раз: здесь мы собираем требования к системе.

2️⃣ Единый язык

В процессе взаимодействия с экспертами мы слушаем, какие слова произносит эксперт, и используем их в коде! Если эксперт говорит "клиент" - у нас в коде должен быть класс Client, не Customer, не User. Код отражает язык бизнеса, а не наоборот!

Не только лишь классы называем так, как говорит эксперт, но и методы классов - это также какие-то действия, которые мы слышим от эксперта.

3️⃣ Ограниченные контексты

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

Хорошим маркером отдельного контекста будет, когда придет эксперт и скажет, что-то про пользователей (User), не клиентов (Client), и окажется, что есть некоторые отличия между клиентом и пользователем. Вот именно тут происходит разделение контекста. Или еще глубже - когда для разных частей системы понятие клиента значит разное.

4️⃣ Домены / Субдомены

Эванс использует эти понятия как взаимозаменяемые. Это классификация бизнес-логики по важности для бизнеса:

🔸Core Domain: дает конкурентные преимущества, сложный, разрабатывается внутри компании самыми квалифицированными кадрами. Чаще всего реализуется в отдельном ограниченном контексте.

🔸Supporting Domain: вспомогательная логика, специфичная для бизнеса, но не универсальная. Чаще всего CRUDы, что-то не очень сложное. Часто могут быть внутри какого-то контекста.

🔸Generic Domain: что-то универсальное, то, что все компании делают одинаково. Логи, авторизация. Часто отдельный контекст - использование готового решения.

Связь между ограниченным контекстом и доменом не так проста, вынесем это в отдельный пост.

5️⃣ Стратегия DDD

🔸Выделяем Core Domain и строим вокруг него всю систему.
🔸Изолируем его от Supporting и Generic через контексты.
🔸Управляем взаимодействием контекстов через Context Mapping.

Ссылки: https://t.me/heart2code/37

#DDD
👍3🫡3🔥1
Тактические паттерны DDD

В прошлом посте мы теоретически собрали требования от экспертов доменной области, используя единый язык. Разделили приложение на ограниченные контексты и выделили Core Domain.

В этой части сделаем краткий обзор на то, как теперь это все закодить.

1️⃣ Моделирование домена

С чего же нужно начинать разработку?

По незнанию и при нехватке квалификации разработка начинается с проектирования БД, консюмеров Kafka, выбора фреймворка. То есть выбираем технологии. Это абсолютно неправильно.

Разработка всегда начинается с модели данных. Простыми словами - проектирование классов и их поведения без привязки к технологиям. Новый проект начинается с проектирования модели Core Domain, используя простые классы и единый язык.

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

2️⃣ Паттерны моделирования

🔸Entities (Сущности): объекты с уникальной идентичностью (у них всегда есть поле ID).
🔸Value Objects (Объекты-значения): неизменяемые объекты, характеристики Entity.
🔸Aggregates (Агрегаты): группа связанных Entities и Value Objects, управляемых через корневой объект (сложная тема, вот тут сам пока еще только дохожу до понимания).
🔸Domain Events (Доменные события): события изменений в модели данных.
🔸Domain Services (Доменные сервисы): компоненты, реализующие логику, не принадлежащую конкретной сущности или объекту-значению. Например, операции, затрагивающие несколько агрегатов или сущностей. Инкапсуляция сложных бизнес-правил. О разнице между Domain Service, Application Service и Usecase я расскажу в отдельном посте.
🔸Repositories (Репозитории): интерфейсы для доступа к агрегатам (сущностям).

Используя описанные выше паттерны, первым шагом разработки всегда будет проектирование модели предметной области.

3️⃣ Выбор архитектуры

Набросок модели готов, и приходит время подумать про выбор технологий, инфраструктуру.

Для себя я выделяю, по сути, 3 подхода к организации архитектуры:
🔸Hexagonal Architecture (Ports & Adapters)
🔸Clean Architecture
🔸CQRS (Command Query Responsibility Segregation)

Выбор влияет и на то, как будет выглядеть структура директорий проекта. Об этом поговорим отдельно.

4️⃣ Документация

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

На этом все, дальше уже будем детальнее обсуждать какие-то части DDD.

#DDD
🫡2👍1
Лучше потратить два дня на Event Storming, чем два месяца на переписывание кода


В комментариях к прошлому посту мой коллега Дима вполне рационально спросил - зачем лезть в код до проектирования концептуальной модели? И он прав, я использовал упрощение, но в идеальном мире, прежде чем садиться за код, нужно как-то спроектировать систему.

1️⃣ Что это такое

Event Storming - это метод проектирования и анализа сложных систем, который очень хорошо ложится на концепцию DDD. Если еще точнее, на DDD + CQRS.

В процессе проектирования системы выделяются ивенты, агрегаты, команды и ограниченные контексты. Как раз то, что нам нужно.

У меня был реальный опыт проектирования интеграционного фреймворка через Event Storming. Мы собрались в Zoom: несколько разработчиков, CTO и PO (эксперты предметной области). Открыли общую доску в Miro и просто пошли по шагам. Заняло у нас это примерно 4-5 сессий по 2 часа. И на выходе получили архитектуру фреймворка из 5 микросервисов (ограниченные контексты), агрегаты в них, команды (единый язык) и ивенты между ними. Оставалось самое простое - закодить.

2️⃣ Преимущества Event Storming

🔸Проектирование архитектуры. Можно использовать как для новых систем, так и для рефакторинга старых.
🔸Устраняет разрыв между бизнесом и разработкой. Все начинают говорить на одном языке.
🔸Снижает риски. Раннее обнаружение проблемных мест.

3️⃣ Шаги

Ниже будут 10 шагов проведения Event Storming, взятых из книги Влада Хононова - Изучаем DDD. За подробностями обращаемся к ней, гуглим.

- Собираем все возможные доменные события в данном бизнес-домене.
- Выстраиваем последовательность этих событий, в которой они появляются.
- Выделяем "болевые" точки - места, в которых нам не хватает знаний.
- Выделяем переходы между состояниями, фазами, контекстами.
- Выделяем команды (операции), которые запускают ивенты из 1 пункта.
- Выделяем инварианты/правила.
- Выделяем представления для акторов.
- Выделяем внешние системы.
- Объединяем команды и ивенты, относящиеся к общему агрегату.
- Выделяем ограниченные контексты.

На выходе получается готовая, разноцветная схема.

4️⃣ Читаем

- https://habr.com/ru/companies/oleg-bunin/articles/537862/
- https://contextmapper.org/
- Влад Хононов, "Изучаем DDD"
- Альберто Брандани, "Introducing EventStorming"

#DDD
👍3🫡2👏1
Связь ограниченных контекстов и доменов

1️⃣ Домены

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

🔸Core Domain (основной): уникальная ценность компании, то, что отличает ее от конкурентов.

Главная задача - изолировать основной домен от других типов, т.к. он сложнее и чаще всего изменяется. Изолировать - не всегда значит перенести в микросервис, иногда это логическое разделение внутри монолита.

Разрабатывается всегда внутри компании. Именно здесь должны быть самые большие инвестиции, предназначенные для разработки ПО.

🔸Supporting Subdomain (вспомогательный): простая бизнес-логика, не уникальная для бизнеса, напоминает ETL-процессы или CRUDы.

Не дает конкурентных преимуществ, имеет низкую сложность, можно отдавать на аутсорсинг.

🔸Generic Subdomain (универсальный): общие задачи, которые можно решить готовыми решениями.

Имеет высокую сложность, ищем что-то готовое, дешевле купить, чем писать самим.

Поддомен — это конкретная область знаний или деятельности, которую необходимо смоделировать в ПО.

2️⃣ Ограниченный контекст

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

В двух разных контекстах один и тот же термин может иметь разное значение, структуру и поведение.

Ограниченные контексты проектируются!

Ограниченный контекст должен принадлежать только одной команде разработки! У одной команды может быть несколько ограниченных контекстов, но нельзя, чтобы несколько команд разрабатывали один контекст.

Ограниченные контексты (Bounded Contexts) — логические части домена, где:
- Определенные термины (Ubiquitous Language) имеют конкретный смысл,
- Модели данных (Entities, Value Objects) адаптированы под задачи контекста.
- Могут быть реализованы как: микросервисы, модули в монолите или отдельные либы.

3️⃣ Связь домена и ограниченного контекста

🔸Каждый поддомен может быть реализован в одном или нескольких ограниченных контекстах.

🔸Один контекст может охватывать часть или поддомен целиком.

🔸Один контекст может содержать домены нескольких типов: например, Payment контекст может включать и core-логику (эксклюзивный метод оплаты) и generic-логику (например интеграцию с СБП)

4️⃣ Итого

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

Поддомены - это стратегическое разделение по бизнес-ценности, а контексты - тактическое разделение по технической реализации.

В следующем посте продолжим про интеграцию ограниченных контекстов.

#DDD
1🫡4👍2
Интеграция ограниченных контекстов (часть 1)

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

В целом, каждый ограниченный контекст должен иметь свою собственную модель данных: свои сущности, события. Но что, если кажется, что одна модель данных ложится на несколько контекстов?

1️⃣ Валидация модели

Точно ли одна сущность подходит к двум контекстам? А не хочется ли в нее добавить полей, специфичных для какого-то одного контекста?

🔸Пусть даже названия совпадают, но семантически это могут быть разные сущности.
🔸Пусть каждый контекст имеет свою сущность.
🔸Кроме того, не запрещено для связей использовать идентификаторы (user_id)

2️⃣ Shared Kernel (Общее ядро)

Действительно, бывают случаи, когда одна модель данных подходит на несколько доменов. Тогда выносим общие сущности в отдельный модуль.

Не забываем о последствиях:
🔸Изменения в Shared Kernel влияют на все контексты.
🔸Риск возникновения скрытой связанности.
🔸Допустимо внутри одной команды.
🔸Подходит для стабильной, редко изменяемой логики.
🔸Хорошо подходит для Core Domain.

3️⃣ Дублирование с преобразованием

Каждый контекст имеет свою копию или очень близкую к копии версию сущности. Данные синхронизируются через ивенты.

🔸Можно применять только, когда контексты разрабатываются разными командами.
🔸Огромные расходы на синхронизацию.

4️⃣ Выделение отдельного ограниченного контекста

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

Как пример, сущность Address можно выделить в отдельный контекст Геоданных.

Заключение

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

#DDD
🔥3👍2🫡2
Интеграция ограниченных контекстов (часть 2)

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

1️⃣ Anti-Corruption Layer

Этот паттерн применяется со стороны получателя сервиса, того, кто использует внешний сервис и не хочет подстраиваться под модель поставщика.

Чтобы избежать использования внешней модели, сервис-получатель преобразует внешнюю модель в свою, подходящую ему.

Банальный пример: через API мы получаем модель Client, но внутри используется своя сущность Customer. ACL говорит о том, что нужно сделать маппинг на внутренние сущности, чтобы убрать зависимость от внешних.

Примеры использования:
🔸Поставщик - это легаси-система.
🔸Получатель - это Core Subdomain.
🔸Когда контракт поставщика часто изменяется, и нам нужно защитить свою модель от этого.

2️⃣ Open-Host Service

Этот паттерн - противоположный ACL. Тут сила на стороне получателя, т.е. поставщик заинтересован в предоставлении наилучшего сервиса.

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

Обычно есть строгий контракт (OpenAPI или Protobuf), который поставщик старается не менять.

Хорошо подходит для Core-доменов, для скрытия своей сложности.

3️⃣ Domain Events

Классика микросервисов: контексты обмениваются информацией через асинхронные события, уведомляя о произошедших изменениях.

Хорошо подходит для слабосвязанных контекстов.

Заключение

Если два контекста используют «одну и ту же» сущность, но с разным поведением или данными — это разные сущности.

Следующим постом разберемся с Context Mapping.

#DDD
👍5🫡3❤1
Context Mapping

Это карта взаимодействия ограниченных контекстов.

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

🔸Сами ограниченные контексты.
🔸Зависимости между ними.
🔸Паттерны интеграций.

Зачем нужна карта контекстов:

🔸Понять сложность системы без погружения в код.
🔸Выбрать оптимальные способы интеграции, чтобы изменения в одном контексте не ломали другие.
🔸Получившаяся диаграмма становится общим языком для разработчиков, архитекторов и бизнес-экспертов.

Последнее, что можем обсудить - где рисовать? Да где удобнее: draw.io, contextmapper.org, dochub.info . Просто не забываем поддерживать актуальное состояние нашей диаграммы.

#DDD
👍2🔥1🫡1
Тактические паттерны DDD: Entity

Этим постом мы начинаем разбор тактических паттернов проектирования модели предметной области.

Тактические паттерны в DDD относятся напрямую к написанию кода. Чаще всего на собеседованиях, если вопрос заходит про DDD, разработчики рассказывают именно про тактику. Мне кажется, потому что эти паттерны доступнее понять, в то же время стратегические паттерны чаще недопоняты и недооценены.

Итак, что же такое Entity или сущность?

type ClientStatus string

func (s ClientStatus) String() string {
return string(s)
}

const (
New ClientStatus = "new"
Registered ClientStatus = "registered"
Blocked ClientStatus = "blocked"
)

type Client struct {
id ClientID
firstName string
lastName string
email Email
phone Phone
status ClientStatus
}

func NewClient(id ClientID, firstName, lastName string, email Email, phone Phone) (*Client, error) {
// какая-то валидация полей
if firstName == "" || lastName == "" {
return nil, errors.New("Имя и фамилия должны быть заданы")
}

if !phone.IsValid() {
return nil, errors.New("Задан неверный телефон")
}

return &Client{
id: id,
firstName: firstName,
lastName: lastName,
email: email,
phone: phone,
status: New,
}, nil
}

func (c *Client) Block() error {
if c.status != Registered {
return errors.New("Заблокировать можно только в статусе registerd")
}

c.status = Blocked
}


🔸Во-первых, это фундамент любого домена. С проектирования сущностей начинается разработка проекта.

🔸Главная цель сущности - отобразить поведение предметной области через свои методы.

🔸В коде сущность выглядит как класс или структура с полями, чьи типы в большинстве должны быть Value Object'ами (например, ClientID, ClientStatus).

🔸Сущность всегда имеет уникальный идентификатор - ClientID, по сути это обертка над UUID. Этот идентификатор не должен меняться в течение всей жизни сущности.

🔸Создается при помощи фабричного метода (NewClient()).

🔸Правильный подход говорит о том, что все поля сущности должны быть приватными. Вот почему:

- Always Valid Domain Model: нельзя просто так взять и поменять поле сущности, это может сломать ее работу.
- Второе: должна быть какая-то причина для изменения поля сущности. Как раз эта причина характеризуется поведением (методом) сущности (c.Block()).

🔸Не забываем о том, что Энтити должна существовать внутри определенного ограниченного контекста.

🔸Энтити не является отражением на БД!

🔸Сущность может передать своё поведение доменному сервису (Domain Service). Не путать с Application Service или Usecases. Как дойдем до них, объясняю в чем разница.

#DDD
🔥1🫡1
Тактические паттерны DDD: Value Object

Начнем с примера простой сущности:

type Client struct {
id uuid
phone string
email string
address string
}


По мне, так абсолютно невыразительная сущность. Структура Клиент вообще никак себя не характеризует. Просто какой-то набор полей с примитивными типами.

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

А теперь взглянем на сущность здорового человека:

type Client struct {
id ClientID
phone Phone
email Email
address Address
}

type ClientID uuid

type Phone string

func (s Phone) String() string {
return string(s)
}

func (s Phone) Validate() error {
// логика по валидации
return nil
}

type Email string

func (s Email) String() string {
return string(s)
}

type Address struct {
street string
city string
country string
}

func NewAddress(street, city, country string) (Address, error) {
if street == "" || city == "" || country == "" {
return Address{}, errors.New("invalid address")
}
return Address{street, city, country}, nil
}

func (a Address) Street() string {
return a.street
}

func (a Address) IsEqual(addr Address) bool {
return a.street == addr.Street() &&
a.city == addr.City() &&
a.country == addr.Country()
}


А также некоторые выдуманные интерфейсы:

type ClientRepository interface {
Load(ctx context.Context, id ClientID) (*Client, error)
LoadByEmail(ctx context.Context, email Email) (*Client, error)
}


Какая красота (подумали вы). Вот это и есть Value Objects (VO сокращенно):

🔸Сущность характеризуется своими value-объектами. VO отражают бизнес-концепции.

🔸VO не имеет идентификатора.

🔸Является неизменяемым (immutable). Нельзя поменять поля структуры Address, придется пересоздать новый адрес. Следовательно код не должен содержать никаких сеттеров для value-объектов.

🔸Инкапсулирует в себя бизнес-логику и валидацию.

🔸Существует в контексте сущности.

🔸Если необходимо сравнить два VO, нужно сравнивать значения их полей.

🔸Для создания сложного VO - можно использовать фабричные методы с валидацией.

🔸Как VO должен храниться в БД?
- Поля VO это колонка в таблице сущности
- Поле jsonb (маршаллинг)
- Редко можно вынести в отдельную таблицу, но тогда не забываем, что для VO не должно быть своего репозитория, он загружается вместе с сущностью

#DDD
👏2🫡2
Тактические паттерны DDD: Aggregate

Ну вот мы и подошли к самому интересному. Кто-то считает что агрегаты это самое важное в технической части DDD. Кто-то их использует очень редко. Для меня это очень сложное понятие, давайте разбираться.

Если сущность это объект, то агрегат - это концепция (понятие) предметной области. Например, заказ или финансовая транзакция.

// Агрегат
type Order struct {
    id         OrderID
    status     Status
    // Связь с другим агрегатом
    customerID CustomerID
    items      []OrderItem
}

// Сущность
type OrderItem struct {
    productID ProductID
    quantity  Quantity
    price     Money
}

// Value Object
type Money int64

func NewOrder(id OrderID, customerID CustomerID) *Order {
    return &Order{
        id:         id,
        status:     New,
        customerID: customerID,
        items:      make([]OrderItem, 0),
    }
}

func (o *Order) AddItem(productID ProductID, quantity Quantity, price Money) error {
    if quantity <= 0 {
        return errors.New("quantity must be positive")
    }

    if price <= 0 {
        return errors.New("price must be positive")
    }

    o.items = append(o.items, OrderItem{
        productID: productID,
        quantity:  quantity,
        price:     price,
    })

    return nil
}

func (o *Order) Total() Money {
    var total int64
    for _, item := range o.items {
        total += int64(item.price) * int64(item.quantity)
    }

    return Money(total)
}



func (o *Order) Cancel() error {
    if o.status == Paid {
        return errors.New("cannot cancel paid order")
    }

    o.status = Cancelled
    return nil
}


🔸Представляет собой кластер объектов: объединяет связанные сущности и value-объекты в одну логическую единицу. Другими словами, агрегат управляет состоянием связанных объектов.

🔸Order - это корень агрегата. Это значит, что всё взаимодействие должно идти только через него. Нельзя просто так взять и создать или модифицировать OrderItem. Агрегат отвечает за целостность данных.

🔸Во многих процессах есть непреклонные бизнес-правила, как например нельзя добавить вещь в заказ у которой цена или количество равны нулю. Такие бизнес-правила называются инвариантами. Эти инварианты должны отражаться в модели (агрегатах, сущностях, VO)

🔸Агрегат сохраняется в БД как единое целое, в транзакции. Кстати, загружаются тоже целиком.

🔸Изменение состояния возможно только через вызов методов. Как и в сущностях.

🔸Агрегат может быть связан с другим агрегатом через ID.

🔸Внимание: сущность или VO не может существовать отдельно от агрегата:
1. Каждый раз добавляя сущность или VO, думаем к какому агрегату он принадлежат.
2. Если об этом не подумать, то мы не сможем гарантировать валидное состояние системы.
3. Давайте примем факт, что возможны исключения. Например, для простых, самодостаточных сущностей. Сущность сама может быть агрегатом.

🔸Внимание 2: как понять надо ли объединять два агрегата? Если они никак не связаны инвариантами - это два разных агрегата.

🔸Может ли быть несколько агрегатов внутри одного контекста? Может, но их не должно быть много. Если в рамках одного контекста нужно изменить несколько агрегатов, это делается через разные транзакции или сагу.

Агрегаты в одном контексте должны быть высоко связаны! Должны решать какую-то общую задачу.

🔸Как взаимодействуют агрегаты в одном контексте? Связь через ID, я об этом говорю выше. Через доменные события или взаимодействие происходит в сервисах.

🔸Божество :) Дада, не надо делать божественные агрегаты, лучше несколько.

Накидывайте в комментах кто что думает, где я не прав? может что-то упустил? нам нужна истина!
Повторить: https://github.com/MaksimDzhangirov/practicalDDD/blob/master/docs/aggregate.md

#DDD
👍3🫡3👏2
Тактические паттерны DDD: Domain Events

Это сообщения, отражающие значимые изменения в домене (OrderPlaced, UserRegistered, MoneyTransfered).

Я уже писал, что доменные события это один из способов интеграции ограниченных контекстов. Ну и как следствие - слабая связность компонентов. Кроме этого, взаимодействие через события упрощает масштабирование системы.

// Event базовый интерфейс для всех событий
type Event interface {
    EventName() string
    OccurredAt() time.Time
}

// OrderPlaced событие оформления заказа
type OrderPlaced struct {
    EventID    uuid.UUID
    OrderID    uuid.UUID
    CustomerID uuid.UUID
    Items      []OrderItemDTO
    CreatedAt  time.Time
}

func (e OrderPlaced) EventName() string {
    return "OrderPlaced"
}

func (e OrderPlaced) OccurredAt() time.Time {
    return e.CreatedAt
}


Характеристики: именование в прошедшем времени, immutable, может содержать контекст (что произошло, когда, связанные данные, например ID).

И теперь самое важное - событие генерируется агрегатом:

🔸Вызов метода агрегата может возвращать событие.
🔸Или агрегат накапливает события в себе и есть метод, который возвращает все накопленные события.

// Агрегат из прошлого поста
type Order struct {
    id         OrderID
    status     Status
    customerID CustomerID
    items      []OrderItem
// собираем ивенты
    events     []Event
}

// Place оформление заказа
func (o *Order) Place() error {
    if o.status != Draft {
        return errors.New("order cannot be placed")
    }

    o.status = Placed

    // Генерация события
    event := OrderPlaced{
        EventID:    uuid.New(),
        OrderID:    o.id,
        CustomerID: o.customerID,
        Items:      o.convertItemsToDTO(),
        CreatedAt:  time.Now(),
    }
    o.events = append(o.events, event)

    return nil
}

// PullEvents возвращает события и очищает список
func (o *Order) PullEvents() []Event {
    events := o.events
    o.events = nil
    return events
}


Хорошо, мы получили события агрегата, что с ними делаем дальше?
🔸Используем паттерн медиатор https://github.com/mehdihadeli/Go-MediatR .
🔸Отправляем эти ивенты в брокер с использованием паттерна Outbox.

Некоторые правила:

🔸События - часть домена. Должны лежать рядом с агрегатами.
🔸Поддерживают семантическое версионирование. Если изменилась структура события, создаем OrderPlacedV2.
🔸Идемпотентность. Обработчики событий должны корректно обрабатывать повторные события.
🔸Событий не должно быть слишком много. Стараемся отражать только значимые изменения.

Ссылки:

https://habr.com/ru/companies/lamoda/articles/678932/ - Паттерн Outbox

https://github.com/MaksimDzhangirov/practicalDDD/blob/master/docs/domain-event.md - Событие предметной области

#DDD
50👍4🫡3👏2
Тактические паттерны DDD: Domain Service

Это объект, который содержит логику, не принадлежащую ни одному агрегату или сущности.

Используется, когда:
🔸Для выполнении операции нужны внешние зависимости. Например, репозитории или клиенты к сторонним API.
🔸Операция затрагивает несколько агрегатов.
🔸Операция отвечает ТОЛЬКО за бизнес-логику.

package user

type EmailValidator struct {
userRepo UserRepository
}

func NewEmailValidator(repo UserRepository) *EmailValidator {
return &EmailValidator{userRepo: repo}
}

func (v *EmailValidator) IsEmailUnique(email string) (bool, error) {
existing, err := v.userRepo.FindByEmail(email)
if err != nil {
return false, err
}
return existing == nil, nil
}

Для себя я четко разделяю Domain Service и Application Service (Usecase). Подробнее о различиях я напишу в следующем посте. А пока что, для меня Domain Service должен инкапсулировать бизнес-логику, относящуюся к домену, а также важные бизнес-знания или инварианты. Например, определение дубликата какой-либо записи: если это важно в ограниченном контексте, то эти знания относятся к Domain Service.

Пример 2. Пусть у нас есть бизнес-правило: "Покупатель может оформить заказ только в том случае, если все товары в заказе есть в наличии на складе". Для проверки этого правила необходимо работать с двумя сущностями: Product и Order. Поскольку нет никаких инфраструктурных зависимостей, это чистая доменная логика. Следовательно, нам нужен OrderService.

Некоторые характеристики:
🔸Domain Service не должны быть божественными, лучше несколько небольших, относящихся к определенным задачам классов.
🔸Сервис не должен содержать логику, относящуюся к агрегату. Например, изменение статуса.
🔸Доменный сервис не должен обращаться к данным соседнего ограниченного контекста.
🔸Сервис может изменять только 1 агрегат.
🔸Доменный сервис используется на уровне Application Service.

Ссылки:
https://github.com/MaksimDzhangirov/practicalDDD/blob/master/docs/domain-service.md

#DDD
👍4👏3🫡2
Domain Service vs Application Service vs Use Case

Кратко, это всё сервисы, разница лишь в том, на каком уровне они работают.

Доменный сервис принадлежит уровню домена, инкапсулирует важные знания о предметной области и не должен иметь сложные зависимости на инфраструктуру.

Например, я работал в телекоммуникационной компании, и вот какие методы были в одном из доменных сервисах:

🔸IsAlreadyCommunicated - проверка на то, была ли уже коммуникация по номеру телефона. Важное знание? - Конечно, как определить, что мы уже звонили по этому номеру?

🔸CanCommunicateNow - часто есть ограничение на время звонка. Этот метод проверяет, можем ли мы сейчас позвонить?

🔸GetNextCommunicationTime - если мы позвонили, а номер был занят, нужно определить время перезвона.

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

Часто, для простоты, Application Service приравнивают к Use Case, но иногда их различают по уровню абстракции.

Итак, Application Service находится на уровне приложения и координирует работу доменных объектов и инфраструктуры (репозиториев, внешних интеграций, отправки сообщений). Он может использовать Domain Service или соседние Application Service в монолите. Не должен содержать бизнес-логику, только координацию. На этом же уровне также находится и преобразование из DTO в сущности.

Use Case - это конкретный сценарий взаимодействия пользователя с системой. Очень похож на Application Service, но фокусируется на одной бизнес-операции, не зависит от инфраструктуры. Например, CreateTask, мне очень напоминает команды в CQRS.

Разобраться, что куда оказалось сложнее, чем я ожидал. Давайте подведем итог:

🔸Domain Service - содержит доменные знания.

🔸Use Case - это бизнес-логика для конкретного сценария. Не зависит от инфраструктуры, но может зависеть от Domain Service.

🔸Application Service - это оркестрация и взаимодействие с внешним миром. Может зависеть от одного или нескольких Use Case и Domain Service.

Еще упрощаем: берем для примера метод CreateOrder. Это может быть как отдельный Use Case, так и отдельный метод в Application Service. Как по мне, так если мы будем использовать все 3 уровня, архитектура сильно усложнится.

Пусть у нас есть PostgreSQL, Kafka и какие-то интеграции. Наличие кафки говорит нам сразу про Application Service. Вот и пускай остается только Application Service.

Или у нас Event Sourcing и CQRS: консьюмер прочитал ивент и вызвал команду. А команда реализована как отдельный Use Case.

Итого: пишем код не там где удобно, а там, где правильно, включаем голову.
👍4👏2🫡2
CAP-теорема

Это теорема о компромиссах в распределенных системах, которая гласит: Невозможно обеспечить все три свойства. Система может гарантировать только два из них.

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

🔸C (consistency) - согласованность. Все узлы системы видят одни и те же данные в один момент времени.
Пример: После записи новых данных любой последующий запрос вернет актуальное значение.

🔸A (availability) - доступность. Система всегда отвечает на запросы, даже если часть узлов недоступна.
Пример: При сбое одного сервера клиенты продолжают получать ответы от оставшихся узлов.

🔸P (partition tolerance) - устойчивость к распределению. Система продолжает работать, несмотря на разрывы связи между узлами (network partition).
Пример: Два дата-центра теряют связь, но каждый из них обслуживает запросы локально.

Сейчас редко встретишь БД без репликации (single-node instance). Поэтому Partition Tolerance неизбежна. Остается сделать выбор между CP или AP.

🔸CP-системы: жертвуют доступностью ради согласованности.
Для систем где важна точность: финансовые транзакции, банкинг, бронирование.
- PostgreSQL (с синхронной репликацией). Если мастер-узел теряет связь с репликой, запись данных блокируется до восстановления соединения
- MongoDB (с настройками Write Concern = majority и Read Concern = majority). При сетевом разделе MongoDB теряет доступность до завершения выборов новой первичной реплики.
- Redis (с Redis Sentinel и минимальной настройкой для записи). В случае разделения сети Redis может отказаться принимать записи, если недостаточное количество реплик доступно.
- ZooKeeper. Если кластер разделяется на две части, меньшая часть перестает отвечать на запросы. Большая часть продолжает работать, сохраняя согласованность.

🔸AP-системы: жертвуют согласованностью ради доступности.
Для масштабируемости и отказоустойчивости: соцсети, мессенджер, IoT. В системах этого типа можно сгладить отсутствие согласованности через паттерн Сага.
- Memcached. Может использовать репликацию для повышения доступности, репликация обычно является асинхронной и не гарантирует строгую согласованность между репликами. Поддерживает eventual consistency за счет TTL на данные.
- Cassandra. В случае разделения сети может вернуть устаревшие или противоречивые данные. Однако, в конечном итоге, все данные будут согласованы (eventual consistency).
- Riak. Разрешает конфликты данных с помощью CRDT (Conflict-Free Replicated Data Types).
👍3🫡3
Антипаттерны доменной модели

🔸Анемичная модель (Anemic Domain Model)

Сущность не содержит никакой бизнес-логики (методов). По сути, сущность используется как DTO.

Это влечет за собой нарушение инкапсуляции, логика уезжает на прикладной уровень.

🔸ORM-анемия

Это частный случай анемичной модели, когда ORM (таблица) диктует структуру доменного объекта.

type OrderModel struct {
ID string `gorm:"primaryKey"`
Status string
}

Кроме того, это нарушает SRP. Используем DTO для маппинга агрегата в репозитории.

Важно: сначала домен, потом инфраструктура.

🔸Божественный агрегат

Агрегат начинает управлять большим количеством сущностей и содержит очень много логики.

type User struct {
ID uuid.UUID
Orders []Order
Payments []Payment
Addresses []Address
}


101% нарушены границы транзакций. Исправляется разделением агрегатов на кластеры по бизнес-процессам.

🔸Божественный сервис

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

func (s *OrderService) CancelOrder(orderID uuid.UUID) error {
order, _ := s.repo.FindByID(orderID)
if order.Status == "paid" { // Валидация в сервисе
return errors.New("нельзя отменить оплаченный заказ")
}
order.Status = "cancelled"
return s.repo.Save(order)
}


Важно: вся логика должна быть в агрегатах, сущностях и VO.

🔸Отсутствие Value Objects

В сущностях, вместо VO используются примитивные типы.

type OrderModel struct {
ID string
Status string
}


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

🔸Нарушение границ контекстов

Смешивание моделей из разных контекстов.
Изменения в одном контексте ломают другой.

// Контекст "Заказы" зависит от "Каталог"
type Order struct {
Product Product // Модель из другого контекста
}

// Использем ID вместо объектов
type Order struct {
ProductID uuid.UUID
}


🔸Отсутствие тестов и изоляции в доменном слое

Про тесты это больше моё правило, домен должен быть весь покрыт тестами. А также, в домене не должно быть никаких инфраструктурных зависимостей (от БД или фреймворка).

#DDD
5🫡2
Тактические паттерны DDD: Repository

Репозиторий инкапсулирует в себе логику хранения агрегата в базе данных, а так же:
- Изолирует домен от деталей инфраструктуры.
- Упрощает разработку и тестирование. Используя моки можно отложить принятие решения о БД в самый конец.

type OrderRepository interface {
Load(context.Contex, OrderID) (*Order, error)
Save(context.Context, Order) error
Delete(context.Context, OrderID) error
}


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

🔸Главная функция репозитория - работа с одним агрегатом. Включая вложенные сущности. Т.е. соблюдение транзакционной целостности.

🔸Методы репозитория принимают и возвращают агрегаты, но при этом внутри метода должны использоваться DTO для трансляции агрегатов в поля БД.

type OrderDTO struct{}

func (o *OrderDTO) ToEntity() *model.Order {
    return &model.Order{}
}

func NewRow(order model.Order) OrderDTO {
}


🔸Ненужно стараться использовать универсальные методы в репозитории. Метод FindActiveUsers вполне подходит.

🔸Репозиторий должен возвращать ошибки, понятные в домене. Например ErrOrderNotFound, вместо sql.ErrNoRows.

#DDD
👍2🫡1
Паттерн Unit of Work

Это паттерн проектирования, который группирует несколько связанных операций с данными в одну атомарную транзакцию. Все изменения будут или сохранены или отменены как единое целое.

Задача: нужно атомарно изменить данные двух агрегатов.

🔸Реализация 1: в лоб, в методе апликейшен сервиса создаем транзакцию и используя ее выполняем действия в двух репах.

🔸Реализация 2: изолируем изменения в UoW.

Интерфейс UoW объекта относится к уровню приложения (application):
type CreateOrderUnitOfWork interface {
Begin(ctx context.Context) error
Commit() error
Rollback() error

// Репозитории, управляемые UoW
OrderRepository() OrderRepository
ProductRepository() ProductRepository
}


Пример одного из репозиториев:
type OrderRepository struct {
    tx *sql.Tx
}

func NewOrderRepository(tx *sql.Tx) *OrderRepository {
    return &OrderRepository{
        tx: tx,
    }
}

func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
    _, err := r.tx.ExecContext(ctx, "INSERT INTO orders (id, customer_id, total_amount) VALUES (?, ?, ?)", order.ID, order.CustomerID, order.TotalAmount)

    return err
}


И сам Unit of Work (относится к инфраструктурному слою):
type CreateOrderUnitOfWork struct {
    db                *sql.DB
    tx                *sql.Tx
    orderRepository   OrderRepository
    productRepository ProductRepository
}

func NewCreateOrderUnitOfWork(db *sql.DB) *CreateOrderUnitOfWork {
    return &CreateOrderUnitOfWork{
        db: db,
    }
}

func (u *CreateOrderUnitOfWork) Begin(ctx context.Context) error {
    tx, err := u.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }

    u.tx = tx
    u.orderRepository = NewOrderRepository(tx)
    u.productRepository = NewProductRepository(tx)
    return nil
}

func (u *CreateOrderUnitOfWork) Commit() error {
    return u.tx.Commit()
}

func (u *CreateOrderUnitOfWork) Rollback() error {
    return u.tx.Rollback()
}

func (u *CreateOrderUnitOfWork) OrderRepository() OrderRepository {
    return u.orderRepository
}

func (u *CreateOrderUnitOfWork) ProductRepository() ProductRepository {
    return u.productRepository
}


Ну и пример использования в методе application service:
type OrderService struct {
    createOrder CreateOrderUnitOfWork
}

func (s *OrderService) CreateOrder(ctx context.Context, productID domain.ProductID) error {
    if err := s.createOrder.Begin(ctx); err != nil {
        return err
    }
    defer s.createOrder.Rollback()

    product, err := s.createOrder.ProductRepository().FindByID(ctx, productID)
    if err != nil {
        return err
    }

    order := domain.NewOrder()
    order.AddItem(product)

    if err := s.createOrder.OrderRepository().Save(ctx, order); err != nil {
        return err
    }

    return s.createOrder.Commit()
}


🔸Несколько правил:
- UoW может работать в application или domain сервисах.
- Не должен быть глобальным, божественным UoW. Лучше ограничить каким-то контекстом операции (PaymentUnitOfWork, UserUnitOfWork, OrderTransactionManager).
- Не забывайте что долгие транзакции блокируют ресурсы.

🔸Saga используется для распределенных транзакций, тогда как UoW - для локальных в рамках одной БД.

🔸Как размазать по слоям UoW в гексагональной архитектуре:
├── domain/                  # Доменный слой
│ ├── order.go # Агрегат Order
│ └── product.go # Агрегат Product
│
├── application/ # Прикладной слой
│ ├── ports/ # Порты (интерфейсы)
│ │ └── uow.go # Интерфейс UnitOfWork
│ └── services/ # Сервисы приложения
│ └── order_service.go # Use Case: оформление заказа
│
└── adapters/ # Инфраструктурный слой
└── persistence/
├── create_order_uow.go # Реализация CreateOrderUnitOfWork
└── postgre/ # Реализации репозиториев
└── order_repository.go

#patterns
👍5🫡2
Паттерн Strategy

Я хочу признаться: я не люблю if'ы, да и switch'и тоже :)

Часто бывает, что за if'ами скрывается разделение функциональности по какому-то типу. Например, отправка SMS и отправка сообщения в какую-то CRM. Или метод коммуникации: через SMS или посредством звонка.

А сам выбор типа коммуникации зависит от каких-то настроек (приложения или конкретного пользователя).

То есть, возможны две стратегии коммуникации: SMS или CRM. Так вот, паттерн стратегия предлагает изолировать конкретные стратегии в отдельные классы (структуры), а выше них ставить класс, который будет ими управлять.

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

type CommunicationStrategy string

const (
SMS CommunicationStrategy = "SMS"
AmoCRM CommunicationStrategy = "AmoCRM"
)

type Message struct {
    From string
    To   string
    Text string
    CommunicateVIA CommunicationStrategy
}


Далее определяем интерфейс, который будет реализован стратегиями:

type Communitator interface {
    SendMessage(context.Context, Message) error
}


Реализуем этот интерфейс:
type SMS struct {
}

func (c *SMS) SendMessage(ctx context.Context, msg Message) error {
    // подключение к SMS-сервису
    // отправка SMS
    return nil
}

type AmoCRM struct {
}

func (c *AmoCRM) SendMessage(ctx context.Context, msg Message) error {
    // работа с API
    return nil
}


Ну и сам менеджер стратегий:

type CommunicationManager struct {
    strategies map[CommunicationStrategy]Communitator
}

func NewCommunicationManager() *CommunicationManager {
    return &CommunicationManager{
        strategies: map[CommunicationStrategy]Communitator{
            SMS:    &SMS{},
            AmoCRM: &AmoCRM{},
        },
    }
}

func (m *CommunicationManager) SendMessage(ctx context.Context, msg Message) error {

    if communitator, ok := m.strategies[msg.CommunicateVIA]; ok {
        return communitator.SendMessage(ctx, msg)
    }

    return fmt.Errorf("unsupported communication strategy: %s", msg.CommunicateVIA)
}

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

Взглянем немного шире: таким образом, мы поддержали Open-Close Principle.

Конечно сам выбор стратегии может быть сложнее - тогда мы просто изолируем выбор стратегии в отдельный метод менеджера.

Дальше будет еще несколько постов про самые важные, на мой взгляд, паттерны.

#patterns
👏5👍2🫡2
Паттерн Specification

Этот паттерн помогает выразить сложные бизнес-правила (критерии) через отдельные, переиспользуемые объекты.

Пример правил:
- Пользователь должен быть старше 18 лет.
- Пользователь должен быть активным.
- Пользователь должен иметь роль покупателя.

type User struct {
    Age      int
    IsActive bool
}


Решение в лоб: наделать кучу ифов.

Решение по уму: паттерн Specification, где
- Выносим каждое правило в отдельный объект.
- Легко тестировать.
- Легко комбинировать друг с другом.

Для начала определим интерфейс проверки правил:
type Specification[T any] interface {
    IsValid(candidate T) bool
}


Добавим пару спецификаций:
type AgeSpecification struct {
    minAge int
}

func NewAgeSpecification(minAge int) *AgeSpecification {
    return &AgeSpecification{minAge: minAge}
}

func (s *AgeSpecification) IsValid(candidate User) bool {
    return candidate.Age >= s.minAge
}

type ActiveSpecification struct{}

func NewActiveSpecification() *ActiveSpecification {
    return &ActiveSpecification{}
}

func (s *ActiveSpecification) IsValid(candidate User) bool {
    return candidate.IsActive
}


И их комбинацию:
type AndSpecification[T any] struct {
    specifications []Specification[T]
}

func NewAndSpecification[T any](specifications ...Specification[T]) *AndSpecification[T] {
    return &AndSpecification[T]{specifications: specifications}
}



func (s *AndSpecification[T]) IsValid(candidate T) bool {
    for _, specification := range s.specifications {
        if !specification.IsValid(candidate) {
            return false
        }
    }
    return true
}


OR-спецификация выглядит так:
func (s *OrSpecification) IsValid(candidate T) bool {
for _, specification := range s.specifications {
if specification.IsValid(product) {
return true
}
}

return false
}


Проверяем работу:
func main() {
    user := User{Age: 20, IsActive: false}

    andSpec := NewAndSpecification(
        NewAgeSpecification(18),
        NewActiveSpecification(),
    )

    fmt.Println(andSpec.IsValid(user))
}


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

#patterns
👍4🫡2
Паттерн Outbox

Паттерн обеспечивает атомарность при работе с БД и брокерами сообщений.

Пример 1: Идет транзакция изменения агрегата. И на каком-то шаге нужно отправить ивент в Kafka, отправляем ивент, а на следующем шаге транзакция падает с ошибкой. Но ивент уже отправлен.

Пример 2: Консюмер читает ивенты из Kafka и выполняет действия. Вопрос: как обеспечить идемпотентность?

Основная идея:
🔸Сохраняем событие в таблицу outbox в рамках той же транзакции, что и изменение данных агрегата.
🔸Фоновым процессом читаем эту таблицу и отправляем события в брокер.
🔸После успешной отправки или меняем статус события, или удаляем из таблицы.

Пример таблицы:
CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);

Далее добавляем сущность OrderEvent и репозиторий ее сохранения.

Фоновый процесс:
func (w *OutboxWorker) Start(ctx context.Context) {
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()

for {
select {
case <-ticker.C:
events, _ := w.outboxRepo.FetchPending(ctx, 100)
for _, event := range events {
if err := w.producer.Send(event); err != nil {
continue
}
_ = w.outboxRepo.Delete(ctx, event.ID)
}
case <-ctx.Done():
return
}
}
}

В простом случае - удаляем, но можем и менять статус. А также можно считать количество попыток, добавить метрики, подумать про лочки.

Как обработать событие со стороны консюмера?
Точно также: сначала кладем в БД, потом фоновым процессом вычитываем и обрабатываем.
Обратите внимание на EventID, это наша идемпотентность.

Итого
Паттерн отлично подходит для агрегатов и доменных событий. Хорошо ложится в CQRS и Unit of Work.

#patterns #DDD
👍5👏3🫡3
Паттерн Saga

Мы уже говорили о паттерне Unit of Work, когда у нас есть действия, которые надо выполнить атомарно в рамках одной БД.

Но, что если у нас микросервисы и нам нужно сделать последовательно несколько изменений в разных агрегатах, да еще и в разных ограниченных контекстах (читай микросервисах)?

Суть паттерна:
🔸Транзакции выполняются локально в каждом сервисе.
🔸По окончании транзакции генерируется событие для следующего сервиса.
🔸Если какая-либо транзакция заканчивается ошибкой, нужно последовательно выполнить компенсирующие действия, чтобы отменить все изменения.

Типы Saga:

🔸Оркестрация
Вся власть сосредоточена в одних руках: центральный оркестратор управляет выполнением шагов. А также умеет вызывать компенсирующие действия.
То есть по сути, оркестратор читает события и вызывает команды микросервисов.
Этот тип саги хорош тем, что в нем есть четкий контроль потока и легкая отладка, но при это имеется центральная точка отказа.

🔸Хореография
В этом случае ответственность за распределенную транзакцию ложится на каждый микросервис, участвующий в ней.
Суть та же самая: первый микросервис выполняет часть своей транзакции и отправляем ивент. Второй микросервис читает этот ивент и выполняет следующий шаг. Если возникает ошибка: микросервис выбрасывает ивент, предыдущий выполняет компенсацию и также выбрасывает ивент и т.д.
Этот тип саги хорош тем, что в нем есть высокая масштабируемость и слабая связность, но сложная отладка и отслеживание.

Связь с DDD
🔸При изменении агрегата генерируется событие, которое и запускает шаги саги.
🔸Unit of Work выполняет отдельный шаг саги. В этом ему помогают паттерны Repository и Outbox.

Сложности
🔸Отсутствие идемпотентности. Как это исправлять читаем предыдущий пост Outbox.
🔸Бесконечная компенсация. А что будет если компенсирующие действие также завершится ошибкой? Смотрим в сторону кол-ва попыток и алертов.

#patterns #DDD
🔥5🫡2