Pull to refresh
264.73
Конференции Олега Бунина (Онтико)
Конференции Олега Бунина

Domain Driven Design в Go – это почти не больно

Reading time13 min
Views22K

Как выглядят паттерны 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. В нашем проекте мы используем именно его.

go-arch-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 (Мини-типы)

Преимущество мини-типов в том, что:

  1. их просто объявить, можно в одну строчку;

  2. они дают дополнительный контекст о том, что происходит за счёт именования.

Мы видим не просто какой-то 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,
      /*...*/
   )
}

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

Итоги

→ Единый язык в коде.

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

→ Доменный слой Сильный и Независимый.

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

→ Самостоятельные данные.

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

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

Мой совет — Объект Значение почти всегда без указателя, Сущности, наоборот, используют указатели. Их создаём через фабрики вместо аистов, чтобы не получить невалидные объекты.

→ Используйте репозитории, чтобы работать с БД.

Полезные материалы

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

Tags:
Hubs:
Total votes 21: ↑20 and ↓1+20
Comments10

Articles

Information

Website
www.ontico.ru
Registered
Founded
Employees
11–30 employees
Location
Россия