DDD в golang. Превозмогая трудности

—

от автора

В последнее время достаточно много выступлений, посвященных реализации подходов Domain Driven Design(DDD) в golang.

Я не буду останавливаться на value object, они в golang хорошо реализуются с помощью type defintions. А разберу работу с изменением аггрегатов.

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

В начале было Active record

Для большинства разработчиков, пишущих много обычных CRUD, интуитивным подходом является Active Record в сочетании со “слоеной” структурой приложения.

Структура приложения выглядит примерно так, узнали?

internal models / entity controllers / handlers usecases / domain repositories / persistance 

А интерфейс для сохранения сущности выглядит примерно так:

type Repository interface{ Get(context.Context, entity.ID) (entity.Payment, error) Save(context.Context, entity.Payment) (error) }  type PaymentService struct{ repo Repository }  func(s *PaymentService) Save(ctx context.Context, payment entity.Payment) error{ // realization } 

Работа с сущностью в большинстве сценариев будет выглядеть так:

 payment, err := paymentService.Get(ctx, paymentID)  if err != nil {      return err  }  payment.Status = payment.StatusPaid  paymentService.Save(ctx, payment) 

В лучшем случае так:

 payment, err := paymentService.Get(ctx, paymentID)  if err != nil {      return err  }  // убрали изменение полей сущности  payment.MarkPaid()  paymentService.Save(ctx, payment) 

Такой подход интуитивно понятный, довольно легко читается и имеет минимальные накладные расходы — нам не нужно писать специфические маршалеры / анмаршалеры и конвертеры.

Критика active records

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

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

Все равно просится написать что-то вроде:

order := orderSerivce.Get(ctx, id) for_, itemNumber := range order.Items.Find(goodID) { item := order.Item[itemNumber] //  опустим, что суммы должны быть в  decimal order.Item[itemNumber].Amount = item.Count*item.Price     // Сумму и статус обновить мы забыли =( } 

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

Причин, на мой взгляд, несколько:

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

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

  3. Разработчики не сталкивались с последствиями такого подхода

А что взамен?

В языках Java, C# при переходе к DDD предлагают поля делать приватными и начать добавлять методы к классам-сущностям.

В go такой подходя связан с рядом трудностей.

Если поле приватное, то поле перестает восприниматься библиотеками для маршалинга и работы c SQL. А писать конвертеры в представления для слоя контроллеров и работы с БД довольно накладная задача, чреватая дурацкими ошибками и требующая тестирования.

Хотя ChatGPT или copilot ускоряют подобное действие, изменение состава полей простой сущности становится довольно нетривиальной задачей, особенно в незнакомом проекте.

А какие есть варианты?

Какие варианты мне не понравились:

  1. Соблюдать правило на уровне договоренностей Не решает проблему нарушения этих самих договоренностей

  2. Запретить прямое изменение всех публичных полей с помощью линтера Это очень хорошее решение, хотя не совсем соответствует духу go. Ребята из Авито выбрали его, о чем рассказывали на последнем Highload++.

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

И у проблемы в такой формулировке есть два дополнительных решения:

  1. Приводить состояние сущности в консистентное перед сохранением Например, это позволяют делать обработчики событий BeforeSave ORM gorm и ent. Такое поведение является непривычным для большинства разработчиков и требует отдельного упоминания на онбординге.

  2. Не давать возможности сохранить сущность напрямую

Остановимся на последнем варианте и его реализации.

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

Интерфейс сервиса сущности будет выглядить так:

type ( Event func(p *Payment)   EntityService interface{ Get(contex.Context, id entityID) (Payment, error) Apply(context.Context, events …Event) error } )

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

internal   <domain name> (payments)     payment.go     payment_service.go     internal       repository (пакет не будет доступен для пакетов кроме payments)         payment.go       

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

Реализация события может выглядеть так:

func Paid() Event{ return func(p *Payment) { p.Status = StatusPaid } }  func Rejected() Event{ return func(p *Payment) { p.Status = StatusRejected } }

А работа с сущностью так:

paidEvent, err := paymentGateway.Authorize(ctx, payment) if err != nil { return err } err = paymentService.Apply(ctx, paymentID, paidEvent) if err != nil { return err }

Либо, например, для заказа:

orderService.Apply(ctx, orderId,  orders.RemoveGoods(goodFilter), orders.AppliedAbsolutDiscount(0.8), orders.AddGifts(user), )

Метод Apply может примерно выглядеть так:

func(s *OrderService) Apply(ctx context.Context, id OrderID, events …Event) error { ctx = s.repository.StartTx(ctx) defer s.repository.Rollback(ctx)  order, err := s.repository.GetOrderWithLock(ctx, id) if err != nil { return err } for _, e := range events { err = e(order) if err != nil { return err } } err = s.repository.Save(ctx, order) if err != nil { return err }  return s.repository.Commit(ctx) }

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

type Event func(di *orderService, o *Order) 

Для приложений, где большое кол-во CRUD операций можно ограничиться событием Update

type OrderChanges struct{ Status *OrderStatus Customer *Customer }  func Update(changes OrderChanges) Event { return func(o *Order) { o.ApplyChanges(changes) } }  //  На такую функцию лучше написать тест, чтобы не забыть добавить поля func(o *Order) ApplyChanges(changes OrderChages) { if changes.Status != nil { o.Status = *changes.Status } // … if changes.Customer != nil { o.Status = *changes.Customer } } 

Вместо итогов

DDD сложный подход, который требует насмотренности.

Я попытался рассмотреть подход, который заставит разработчиков думать в рамках терминов предметной области и событий, которые там происходят. Он легко ложится на приложения с CQRS и Event Sourcing.

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


ссылка на оригинал статьи https://habr.com/ru/articles/778186/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *