Domain Driven Design в Go – это почти не больно
- пятница, 9 февраля 2024 г. в 00:00:20
Как выглядят паттерны DDD (Domain Driven Design) в большом проекте? А самое главное, стоит ли их вообще использовать? Рассмотрим, какими инструментами можно реализовать DDD на Go и оценим, насколько это больно.
Меня зовут Илья Сергунин, я backend-сочинитель в Авито: занимаюсь тем, что передаю смартфоны в хорошие руки. В этой статье попытаюсь объяснить, как можно натянуть DDD на Go без синтаксического сахара и магии Java-подобных языков, и без больших крутых ORM c Data mapper, которые также отсутствуют в Go.
Зачем нам 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
}
Но все знают, что бизнес неугомонный, и всегда накидывает что-то ещё: «У нас слишком много старых объявлений, давайте их архивировать». Мы снова редактируем код и вроде бы всё хорошо, но в действительности мы создаём две точки правды.
Если вы продолжите придерживаться такого правила в своём проекте, то при любом добавлении функционала, придётся менять код во многих местах. Тем самым его сложнее тестировать. В какой-то момент вы можете забыть поменять условия и тем самым получите проблему на проде.
Мы этого, естественно, не хотим. Поэтому определяем, к каким данным относится данная проверка, и переносим её туда.
Например, мы хотим архивировать объявление. Это первый звоночек, что действие относится к данным. Когда мы так сгружаем действия и прикрепляем их к данным, нам проще их менять в будущем, плюс, можно легко покрыть тестами, потому что нет внешних зависимостей. Здесь проверяем только бизнес-логику.
Сам код приложения становится более читабельным; легче определить, где какой слой.
Теперь прейдём к паттернам.
Чаще всего это свойство или атрибут. Примерами такого объекта являются ФИО, размер, цвет, количество позиций товара, 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 мы не используем указатели, только прямо в супер-критичных случаях. Если же добавим, то для сравнения нам придется писать страшный методом:
Вопрос в том, насколько вам это нужно.
Также не используйте указатели, когда передаёте аргументы в методы. Если так сделать, то мы сможем снаружи поменять значение аргумента, тем самым отобрать часть ответственности у данных, разломать эти данные и сделать их невалидными.
Также Объект Значение — неизменяемый тип, поэтому избегаем использование указателя при объявлении метода.
Имеется в виду, что любой мутирующий метод предполагает создание нового объекта и его возвращение.
Преимущество мини-типов в том, что:
их просто объявить, можно в одну строчку;
они дают дополнительный контекст о том, что происходит за счёт именования.
Мы видим не просто какой-то 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.
Меняется, оставаясь собой
Сущность в 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
Атомарен от рождения
Объявление — это сущность, но корневая, то есть агрегат.
Агрегат — это кластер объектов и единое целое, которое может включать в себя одну и более Сущностей, а также несколько Объектов Значение.
Отличительным свойством агрегата от сущности заключается в том, что он обладает глобальным 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 — это посредник между доменом и источником данных. В идеале репозиторий выглядит следующим образом:
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, но в 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,
/*...*/
)
}
Если вам надо обновить несколько сущностей в одной транзакции или провести вложенные транзакции, то можно использовать менеджер транзакций.
→ Единый язык в коде.
Говорите с бизнесом на едином языке. Переводите этот единый язык почти один к одному в ваш код, чтобы новые люди в проекте могли и слушать бизнес, и читать код, и понимать, что вообще происходит в вашем домене.
→ Доменный слой Сильный и Независимый.
Делайте доменный слой сильным и независимым для упрощения тестирования и поддерживания проекта.
→ Самостоятельные данные.
Следите за данными, они должны быть самостоятельными, то есть они должны самовалидировать себя, а вам для этого нужно контролировать их публичность: либо сделать все методы приватными, либо использовать линтеры.
Чтобы посмотреть, как все линтеры работают друг с другом в связке, советую посмотреть вот этот репозиторий.
Мой совет — Объект Значение почти всегда без указателя, Сущности, наоборот, используют указатели. Их создаём через фабрики вместо аистов, чтобы не получить невалидные объекты.
→ Используйте репозитории, чтобы работать с БД.
Предлагаю большое количество литературы и статей, которые можно посмотреть, изучить, если интересно.