Как выглядят паттерны DDD (Domain Driven Design) в большом проекте? А самое главное, стоит ли их вообще использовать? Рассмотрим, какими инструментами можно реализовать DDD на Go и оценим, насколько это больно.
Меня зовут Илья Сергунин, я backend-сочинитель в Авито: занимаюсь тем, что передаю смартфоны в хорошие руки. В этой статье попытаюсь объяснить, как можно натянуть DDD на Go без синтаксического сахара и магии Java-подобных языков, и без больших крутых ORM c Data mapper, которые также отсутствуют в Go.
Цель
Зачем нам DDD? Мы хотим поместить бизнес-логику в отдельное место, чтобы сделать её явной, понятной и независимой. Благодаря этому мы сможем написать на неё юнит-тесты и быть чуть более уверенными в том, что новая фича ничего не сломает. Это упрощает поддержку и развитие проекта.
DDD и стратегические паттерны
Домен — это та предметная область, в которой работает бизнес. В моём случае — это Авито, доска объявлений.
Мы с бизнесом вырабатываем общую терминологию, которую называем единым языком. Например, вместо записи в БД говорим, что это объявление; вместо пользователя — продавец или покупатель; вместо CRUD-операций — конкретные бизнес-сценарии:
-
не создать, а разместить черновик;
-
не обновить, а изменить описание;
-
не удалить, а скрыть, заархивировать, забанить.
Единый язык позволяет определить ограниченные контексты — границы проблем, которые бизнес пытается решить. Это нужно для того, чтобы понимать, что и для чего мы делаем, и не копировать реальный мир в его сложности и многообразии.
Далее из множества ограниченных контекстов создаём карту, которая показывает связи и границы между ними.
Вот её пример в Авито.
-
Ограниченный контекст «Объявление» содержит:
-
Сущность «Объявление», которую можно разместить, изменить, скрыть, удалить и прочее.
-
Сущность «Пользователи», которая разделяется на «Продавца» и «Покупателя».
-
-
Ограниченный контекст «Доставка» содержит:
-
Сущность «Товар», который можно отправить, получить, отказаться и прочее.
-
И также сущность «Пользователи», в которой отправитель — «Продавец» и получатель — «Покупатель».
-
-
И другие контексты: Поиск, Профиль, Модерация и далее.

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

Можно заметить, что некоторые значения могут казаться довольно маленькими, как, например, int и string, которые переходят в цену и в заголовок. Тут нужно понимать, что даже такие мелкие значения тоже стоит перенести в код, это позволяет общаться с бизнесом на одном языке отражая его в коде. Скорее всего, бизнес скажет, что заголовок объявления может быть короче 255 символов, а разработчики вряд ли будут говорить о переменной string, которая короче 255 символов.
Слои
Доменный слой — это основа, так сказать, база нашего приложения. Независимо от того, какую архитектуру вы используете (слоистую, гексагональную, чистую), в центре всего будет находиться доменный слой. Он явно независим от внешних библиотек и сервисов.

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

В коде это выглядит так:
package ad type UserRepo interface{ GetByID(context.Context, UserID) } func NewAdService(userRepo) (*adServ) func (s *adServ) CreateAdDraft(ctx, userID, /*...*/) { user, err := s.userRepo.GetByID(ctx, userID) if !user.IsBanned() { return nil, nil } /*...*/ }
Линтеры
Чтобы сделать свой доменный слой сильным и независимым, можно использовать линтеры, которые проверяют корректность зависимости слоев.
→ Depguard
Depguard проверяет корректность зависимостей по тем правилам, которые вы задали. Он встроен в golangci-lint. В нашем проекте мы используем именно его.
Если не хватает Depguard, можно использовать альтернативу — go-arch-lint, который, помимо проверки зависимостей напрямую отлавливает даже инверсию зависимостей, чтобы стопудово защититься в своём коде. Также можно визуализировать граф зависимостей.
Самовалидируемость данных
Как объединять данные, сгрузить в них как можно больше ответственности и сделать самовалидирующимися?
Посмотрим на пример. У нас есть довольно простая архивация объявления:
func Archive(ctx, adID int) error { ad, err := db.Get(ctx, adID) // Архивируем ad.ArchivedAt = time.Now() err = db.Save(ctx, ad) return err }
Мы получили данные из БД, потом прописали в поле, которое отвечает за архивацию, и сохранили.
Дальше бизнес говорит, что всё архивировать не имеет смысла. Например, к таким относятся черновики и забанненые объявления. Тогда мы добавляем немножко бизнес-логики и радуемся.
func Archive(ctx, adID int) error { ad, err := db.Get(ctx, adID) // Архивируем if !a.Status.Can(Archived) { return ErrArchive } a.Status = Archived ad.ArchivedAt = time.Now() err = db.Save(ctx, ad) return nil }
Но все знают, что бизнес неугомонный, и всегда накидывает что-то ещё: «У нас слишком много старых объявлений, давайте их архивировать». Мы снова редактируем код и вроде бы всё хорошо, но в действительности мы создаём две точки правды.

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

Например, мы хотим архивировать объявление. Это первый звоночек, что действие относится к данным. Когда мы так сгружаем действия и прикрепляем их к данным, нам проще их менять в будущем, плюс, можно легко покрыть тестами, потому что нет внешних зависимостей. Здесь проверяем только бизнес-логику.
Сам код приложения становится более читабельным; легче определить, где какой слой.

Теперь прейдём к паттернам.
Объект Значения aka Value Object
Чаще всего это свойство или атрибут. Примерами такого объекта являются ФИО, размер, цвет, количество позиций товара, ID и прочее.
Количество позиций
Например, у нас есть сущность объявления.

Вроде бы прикрепили поведение к самим данным объявления. Но в действительности видно, что available и reserved можно попытаться выделить отдельно. Бизнес с нами говорит всё время о количествах позиций. Мы можем какое-то время его игнорировать, но чем больше бизнес-сценариев появляется, тем более явно мы видим, что это отдельные данные.

Здесь есть метод Buy, который хоть и называется «покупка», но в нём также происходит дополнительное действие — резервирование позиций. То же самое происходит в методе Delivered, Refund, Merge в которых есть работа с объявлением и вложенным в него значением резервированием позиций.

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

В данном случае мы выделяем в Объект Значение количество позиций или Quantity.
Свойства Объекта Значения
Первое и, наверное, самое отличительное свойство — это то, что у Объекта Значение нет ID.

Следующее свойство — Объекты Значение сравниваются по значению. В Go это удобно реализуется через оператор сравнения.
// Quantity{1, 0} == Quantity{1, 0} println(quantity1 == quantity2) // true // Quantity{1, 0} == Quantity{2, 0} println(quantity1 == quantity3) // false quantity1, _ := NewQuantity(1) // Quantity{1, 0} quantity2, _ := NewQuantity(1) // Quantity{1, 0} quantity3, _ := NewQuantity(2) // Quantity{2, 0}
Но это можно легко потерять, если вы решите добавить указатели.

Поэтому в Объекте Значения в Go мы не используем указатели, только прямо в супер-критичных случаях. Если же добавим, то для сравнения нам придется писать страшный методом:

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

Также Объект Значение — неизменяемый тип, поэтому избегаем использование указателя при объявлении метода.

Имеется в виду, что любой мутирующий метод предполагает создание нового объекта и его возвращение.
Tiny/Power/Micro Types (Мини-типы)
Преимущество мини-типов в том, что:
-
их просто объявить, можно в одну строчку;
-
они дают дополнительный контекст о том, что происходит за счёт именования.

Мы видим не просто какой-то int, а конкретную концепцию из домена.
Также мини-типы дают проверку типов. Нельзя сказать, что это суперкруто, но полезно, так как экономит время: не придётся в рантайме отлавливать логическую ошибку.
Следующим и более нужным свойством мини-типов является то, что мы к ним можем привязать поведение через фабрику. Например, можно проверять, если ID больше 0.
type AdID int func NewAdID(id int) (AdID, error) { /*...*/ } const ( Draft Status = iota + 1 Active Archived ) type Status int
Также к мини-типам можно отнести перечисления Enum, к которым мы также можем привязать бизнес-логику. Например, мы привязали к значению информацию о переходах из одного статуса в другой.
func (s Status) CanBe() bool { switch s { case Draft: return []Status{Active} // Чтобы учесть все варианты Status // линтер - exhaustive } //... } func (s Status) Can(to Status) bool { return slices.Contains(s.CanBe(), to) }
Благодаря тому, что Status это отдельный выделенный тип, мы можем использовать дополнительный линтер exhaustive, который проверяет, все ли значения этого типа рассмотрены. Это полезно, чтобы не пропустить какой-то из переховод при их добавлении и изменении. Поэтому мини-типы стоит использовать, особенно если к концепту относиться какая-то бизнес-логика.
Публичные или приватные поля
Думаю, многие в проекте описывают данные с помощью публичных полей. Это допустимо, хотя в идеале они должны быть приватными, чтобы мы не нарушить валидацию.
В нашем проекте используются публичные поля. И чтобы уменьшить вероятность ошибки, мы не используем структурные тэги.

Обычно структурные тэги применяются, когда нужно быстро сделать какое-то представление, например, JSON или XML. Вроде бы звучит хорошо — мы подставляем данные, которые хочет пользователь. Но пользователи разные: продавцу нужно видеть количество доступных позиций, а покупателю — нет. Если мы используем структурные тэги, то можем допустить ошибку, например, вывести не те данные, которые ожидали, или вывести больше, чем нужно. Плюс, публичные поля и структурные тэги позволяют создавать объекты без валидации через тот же самый unmarshalling любого энкодера.

Чтобы запретить использовать структурные тэги, есть связка в виде musttag и самописного правила на основе go-ruleguard.
Также ещё одной, наверное, более важной проблемой является то, что публичные поля можно менять как угодно.

Как видно из кода, у нас есть одна позиция (количество равно 1). Затем мы резервируем любое значение по желанию. Чтобы каждый раз не смотреть в pull request и не искать такие интересные места, я написал линтер gopublicfield.
Сущность aka Entity
Меняется, оставаясь собой
Сущность в DDD представляет собой объект, который идентифицируется не за счёт данных, а за счёт ID. То есть у нас есть объявление, и мы понимаем, что если поменяем его заголовок, то само объявление останется самим собой. Это свойство достигается за счёт того, что у объявления есть ID.

Наверное, это одно из самых главных отличий Объекта Значение от Сущности. Далее, благодаря тому, что есть отдельный ID, мы даём Сущности следующее свойство — изменчивость.
На протяжении жизненного цикла, мы идентифицируем Сущность одинаково, просто меняем поля. Достичь этого в Go несложно, если использовать указатели при создании структуры и при объявлении методов.
type Ad struct {} // Возвращаем указатель на Ad func NewAd(/*...*/) (*Ad, error) {/*...*/} // Указатель в объявлении метода func (a *Ad) Publish(now time.Time) error { /*...*/ } // Изменяем Ad ad, err := NewAd(/*...*/) err = ad.Publish(time.Now()) err = ad.Archive(time.Now()) fmt.Println(ad.Status()) // Archived
Агрегат aka Корневая Сущность
Атомарен от рождения
Объявление — это сущность, но корневая, то есть агрегат.

Агрегат — это кластер объектов и единое целое, которое может включать в себя одну и более Сущностей, а также несколько Объектов Значение.
Отличительным свойством агрегата от сущности заключается в том, что он обладает глобальным ID.
Подразумевается, что с агрегатами могут работать внешние системы или другие агрегаты. Продавец или внешняя система могут вносить в агрегат изменения, но если внешняя система хочет достучаться до внутренних частей агрегата, она не сможет получить к нему доступ, потому что внутренние сущности не обладают глобальным ID. Если же нам все-таки нужно поработать с изображением из внешней системы, мы обращаемся через ID агрегата и работаем с его методами. То есть работаем через методы агрегата.

Также надо понимать, как проводить границы агрегата. Я бы сказал, что по немедленной согласованности. Представим, что есть бизнес-правила, они могут вызываться часто или редко и иметь разную стоимость в работе с БД. Если делать агрегат большой, то стоимость сохранения и получения как единого целого из БД будет высокой. В данном случае мы видим, что есть правило: в объявлении может быть максимум 10 картинок. Предполагаем, что данное бизнес-правило выполняется и проверяется всегда при редактирование объявления. Поэтому можно сказать, что именно здесь нужно провести границу.
Однако, у нас есть и другое бизнес-правило, что пользователь не может создать больше 15 бесплатных объявлений. Тут возникает вопрос: а нужно ли делать супер-большой агрегат, который будет включать в себя продавца и все его объявления?
Ответ — нет. Дело в том, что у пользователя может быть бесконечное множество объявлений, и если мы захотим сохранить эти данные единым куском, то сильно нагрузим БД. Чтобы соблюсти правило, что продавец не может создать больше 15 объявлений, лучше хранить их отдельно и не делать огромный агрегат.

Но если же все-таки есть правило, которое должно быть немедленно согласовано и атомарно выполнено, используйте доменные сервисы и доменные события, обёрнутые в транзакцию. Если вам этого не хватает, то тогда ваш путь направлен к Eventual Consistency.
Фабрика
Аист НЕ приносит доменные объекты
Когда мы создаём объекты, то хотим, чтобы они были валидными.
Например, у нас есть мини-тип с ценой, который задаем напрямую в поле.
type Price int // > 0 func (ad *Ad) ChangePrice(price int) error { // Приведение типов ad.Price := Price(-999) return nil }
По факту это присвоение типов, и никакой валидации здесь внутри нет, что является ошибкой. Чтобы валидация добавилась, можно использовать фабрику, в которую мы храним валидацию создания объекта, тем самым исключаем ошибку.
type Price int // > 0 func NewPrice(in int) (Price, error) func (ad *Ad) ChangePrice(price int) error { p, err := NewPrice(price) ad.Price := p return nil }
Также фабрика позволяет нам задавать значения по умолчанию.
type Status int const Draft Status = 1 ad := &Ad{ Status: Draft, Attrs: make(map[AttrID]AttrValue), }
В Go нет этого из коробки, мы не можем прописать значение по умолчанию к свойству структуры, но это может быть довольно полезно, особенно если у вас вложенные свойства являются, например, маппой.
type Status int const Draft Status = 1 ad := &Ad{ Status: Draft, Attrs: nil, } // panic: assignment to entry in nil map ad.Attrs["key"] = "value"
Если свойства не задать по умолчанию, то при попытке обратиться к ним по ключу, мы получим панику в рантайме. Поэтому фабрики с их свойством создавать валидные объекты и заполнять значения по умолчанию, позволяют это избежать.
Плюс, фабрики могут улучшить читаемость кода за счёт удобных названий. Например, мы регистрируем пользователя или даём ответ на какое-то сообщение, создавая это сообщение. Для того, чтобы запретить в создавать переменные без фабрик можно использовать линтер gofactory.
Repository
По факту Repository — это посредник между доменом и источником данных. В идеале репозиторий выглядит следующим образом:
package domain type AdRepo interface { GetByID(ctx, ID AdID) (*Ad, error) Create(ctx, ad *Ad) error // Необязательный Save(ctx, ad *Ad) error Delete(ctx, ad *Ad) error }
У нас есть метод на получение данных (или несколько методов), и методы для сохранения этих данных в БД. Вот пример использования:
func CreateAdDraft(ctx, sellerID, catID) (*Ad, error){ user, err := sellerRepo.GetByID(ctx, sellerID) category, err := categoryRepo.GetByID(ctx, catID) ad, err := NewAd(userID, catID, ...) err = adRepo.Save(ctx, ad) return ad, nil }
В данном случае при создании объявления получаем seller, проверяем, что он существует либо он не забанен. Потом получаем также категорию и напрямую, используя конкретную реализацию базы данных, кидаем это в БД.
Репозитории удобны, когда нужно накинуть кэши: мы можем скрыть наши кэши под интерфейсом репозитория.
Наверное, более применимые примеры, когда удобно использовать репозиторий — это то, что мы можем менять БД, структуру, поля, не меняя домен.
Рассмотрим конкретный пример. Есть Объект «Значения с контактами» (телефон, Telegram, Viber), и мы решили прописать, что они хранятся в JSON. Потом мы поняли, что, кажется, их можно хранить не просто текстом, а в виде реального поля JSON в БД. Но пришёл бизнес и сказал, что для модерации нужно искать конкретные телефоны пользователей или объявления по телефону.
Если бы мы не отделили доменные объекты и данные БД с помощью репозитория, то пришлось бы заводить в БД поле, и далее менять доменный код, добавив ещё одно поле в объявление и начав дополнительно дублировать эти данные в доменном объекте. Фактически репозиторий позволяет этого избежать за счёт удобного интерфейса, в котором мы скрываем, куда какие данные кладём.

Подытожим плюсы репозитория. Он позволяет:
-
Изменять типы поля без изменения доменных объектов.
-
Изменять имена полей без изменения доменных объектов.
-
Переводить Сущность в Объект Значение, почти не затрагивая доменных объектов.
-
Проводить нормализацию и денормализацию. При этом не нужно затрагивать бизнес-логику.
А как вообще реализовать репозиторий в Go и какие есть особенности?
В начале убираем связь данных в домене с БД, добавляем маппинг данных из БД в домен и обратно, и пишем запросы в целом. Небольшое пример кода по ссылке.
Домен без Слоя Данных
В доменных объектах мы не используем структурные тэги.

Мы не использовали их как для представления, так и для работы с БД. Также не используем реализации интерфейсов, которые позволяют сохранить данные в БД, например, Scan и Value для SQL, UnmarshalJSON и MarshalJSON для чего-либо другого.
Data mapper
Нам нужно явно маппить данные из одного формата в другой. Это довольно скучно, и было бы круто иметь какой-то Data mapper, но в Go принято делать большую часть действий явно.
func toRow(m *domain.Ad) (adRow, []imageRow) { adRow := adRow{ ID: m.ID, Status: m.Status, Quantity: m.Quantity /*...*/} var images []imageRow /*...*/ return adRow, images, nil }
Чтобы не пропустить ни одно поле из в данных БД, можно использовать линтер go-exhaustruct. Он проверяет, что все поля у структуры заданы.
То же самое происходит в обратную сторону, когда мы получаем данные из БД.
func toModel(adRow, imageRows) (*domain.Ad, error) { for _, image := range imageRows { image = append(images, NewImageDB( /*...*/ )) } return domain.NewAdFromDB( adRow.ID, adRow.Status, /*...*/ ) }
Если вам надо обновить несколько сущностей в одной транзакции или провести вложенные транзакции, то можно использовать менеджер транзакций.
Итоги
→ Единый язык в коде.
Говорите с бизнесом на едином языке. Переводите этот единый язык почти один к одному в ваш код, чтобы новые люди в проекте могли и слушать бизнес, и читать код, и понимать, что вообще происходит в вашем домене.
→ Доменный слой Сильный и Независимый.
Делайте доменный слой сильным и независимым для упрощения тестирования и поддерживания проекта.
→ Самостоятельные данные.
Следите за данными, они должны быть самостоятельными, то есть они должны самовалидировать себя, а вам для этого нужно контролировать их публичность: либо сделать все методы приватными, либо использовать линтеры.
Чтобы посмотреть, как все линтеры работают друг с другом в связке, советую посмотреть вот этот репозиторий.
Мой совет — Объект Значение почти всегда без указателя, Сущности, наоборот, используют указатели. Их создаём через фабрики вместо аистов, чтобы не получить невалидные объекты.
→ Используйте репозитории, чтобы работать с БД.
Полезные материалы
Предлагаю большое количество литературы и статей, которые можно посмотреть, изучить, если интересно.
ссылка на оригинал статьи https://habr.com/ru/articles/791420/
Добавить комментарий