В последнее время достаточно много выступлений, посвященных реализации подходов 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 // Сумму и статус обновить мы забыли =( }
Почему это происходит? Ведь в каждой книжке сказано про инкапсуляцию и эти книжки читали действительно очень многие? Или хотя бы слышали о том, что изменять поля напрямую не стоит.
Причин, на мой взгляд, несколько:
Выразить лаконично методы взаимодействия с сущностью не всегда получается Очень много параметров, а мы только изменяем поля без каких-то действий Сложность с выбором названия методов
Геттеры, Сеттеры в go считаются антипаттерном и нет инструментов для быстрого их написания в отличии от других языков
Разработчики не сталкивались с последствиями такого подхода
А что взамен?
В языках Java, C# при переходе к DDD предлагают поля делать приватными и начать добавлять методы к классам-сущностям.
В go такой подходя связан с рядом трудностей.
Если поле приватное, то поле перестает восприниматься библиотеками для маршалинга и работы c SQL. А писать конвертеры в представления для слоя контроллеров и работы с БД довольно накладная задача, чреватая дурацкими ошибками и требующая тестирования.
Хотя ChatGPT или copilot ускоряют подобное действие, изменение состава полей простой сущности становится довольно нетривиальной задачей, особенно в незнакомом проекте.
А какие есть варианты?
Какие варианты мне не понравились:
Соблюдать правило на уровне договоренностей Не решает проблему нарушения этих самих договоренностей
Запретить прямое изменение всех публичных полей с помощью линтера Это очень хорошее решение, хотя не совсем соответствует духу go. Ребята из Авито выбрали его, о чем рассказывали на последнем Highload++.
Проблема на самом деле состоит не в том, что мы можем изменить какие-то поля и привести состояние сущности в неконсистентное состояние. Проблема в том, что мы можем это состояние сохранить.
И у проблемы в такой формулировке есть два дополнительных решения:
Приводить состояние сущности в консистентное перед сохранением Например, это позволяют делать обработчики событий
BeforeSaveORMgormиent. Такое поведение является непривычным для большинства разработчиков и требует отдельного упоминания на онбординге.Не давать возможности сохранить сущность напрямую
Остановимся на последнем варианте и его реализации.
Интерфейс репозитория остается таким же. Но вот использовать репозиторий, кроме сервиса сущности никто не должен мочь.
Интерфейс сервиса сущности будет выглядить так:
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.
Наверняка есть предметные области, в которых описание изменений корневых сущностей сложно описать с помощью событий. Если вы с таким сталкивались, напишите, пожалуйста в комментариях.
