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 PaymntService struct{
repo Repository
}
func(s *PaymentService) Save(ctx context.Context, payment entity.Payment) error{
// realization
}
Работа с сущностью в большинстве сценариев будет выглядеть так:
payment, err := paymentService.Get(ctx, paymentID)
if != nil {
return err
}
payment.Status = payment.StatusPaid
paymentService.Save(ctx, payment)
В лучшем случае так:
payment, err := paymentService.Get(ctx, paymentID)
if != 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++.
Проблема на самом деле состоит не в том, что мы можем изменить какие-то поля и привести состояние сущности в неконсистентное состояние. Проблема в том, что мы можем это состояние сохранить.
И у проблемы в такой формулировке есть два дополнительных решения:
Приводить состояние сущности в консистентное перед сохранением Например, это позволяют делать обработчики событий
BeforeSave
ORMgorm
иent
. Такое поведение является непривычным для большинства разработчиков и требует отдельного упоминания на онбординге.Не давать возможности сохранить сущность напрямую
Остановимся на последнем варианте и его реализации.
Интерфейс репозитория остается таким же. Но вот использовать репозиторий, кроме сервиса сущности никто не должен мочь.
Интерфейс сервиса сущности будет выглядить так:
type (
Event func(p *Payment)
EntityService interface{
Get(contex.Context, id entityID) (Payment, error)
Apply(context.Context, events …Event) error
}
)
Мы можем обновить сущность напрямую, но вот сохранить состояние нет. При этом логика обработки событий может быть также внутри сущности. Чтобы гарантировать, что репозиторий будет использовать в обход обработчика событий можно воспользоваться линтером либо особенностью работу с `internal`
internal
(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.
Наверняка есть предметные области, в которых описание изменений корневых сущностей сложно описать с помощью событий. Если вы с таким сталкивались, напишите, пожалуйста в комментариях.