В последнее время достаточно много выступлений, посвященных реализации подходов 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 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++.
Проблема на самом деле состоит не в том, что мы можем изменить какие-то поля и привести состояние сущности в неконсистентное состояние. Проблема в том, что мы можем это состояние сохранить.
И у проблемы в такой формулировке есть два дополнительных решения:
Приводить состояние сущности в консистентное перед сохранением Например, это позволяют делать обработчики событий
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
<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.
Наверняка есть предметные области, в которых описание изменений корневых сущностей сложно описать с помощью событий. Если вы с таким сталкивались, напишите, пожалуйста в комментариях.
Комментарии (32)
gybson_63
03.12.2023 21:26Честно говоря, вообще непонятно как вы умудряетесь работать с бизнес-логикой на таком очень низком уровне абстракции и без нормального ООП. По идее вы должны в слой БЛ слать только JSON, например, с сущностью. Там json конвертируется в структуру данных пригодную для алгоритмов, которую по некоторым подпискам можно поменять скриптами адаптированными под бизнес-процессы конкретные и после целиком это запишется в БД, Бог знает в каком виде, лучше никому и не знать. Т.е. вы записываете заказ. Записывает оплату. А когда запрашиваете у БЛ заказ, она сама соединяет данные и дает статус заказа. Т.е. его даже и не надо через сущность трогать, это свойство ридонли для заказа. Связанность то тоже надо контролировать. В этом плане event-sourcing прекрасно поможет.
"При росте сложности бизнес-логики" ... покупается 1С и все добро туда сваливается, потому что сочинять свой слой с БЛ очень дорого. Заказ - оплата - резерв - отгрузка - доставка - получение. Это уже необходимый минимум. А если еще и сразу резерв, до оплаты, а вы заказ меняете мимо логики.din_hacker
03.12.2023 21:26В чем плюсы пересылать JSON в слой БД?
gybson_63
03.12.2023 21:26Там написано "БЛ", Л вторая. Вы хотите Model-View-Controller собрать на процедурном языке? Очень дорого и результат не гарантирован.
itmind
03.12.2023 21:26В 1с например просто присваивают значения полю, как указано в разделе "В начале было Active record" и проблем нет. Есть правда события, например "ПередЗаписью" где мы можем проверить и дозаполнить необходимые поля. Я думаю в Go по аналогии нужно не в метод Apply передавать разные функции, изменяющие объект, а сделать метод Save(ctx) и в нем закодировать логику расчета доп полей перед помещением в БД.
GreyCheshire Автор
03.12.2023 21:26Да, это отличный вариант
Например, это позволяют делать обработчики событий BeforeSave ORM gorm и ent. Но такое поведение является непривычным для большинства разработчиков и требует отдельного упоминания на онбординге.
piton_nsk
03.12.2023 21:26+1сделать метод Save(ctx) и в нем закодировать логику расчета доп полей
При более-менее сложной логике, метод Save станет просто гигантским. Я встречал такие методы под пару тысяч строк размером.
itmind
03.12.2023 21:26Так не запрещено же из одного метода вызывать другие процедуры и функции.
piton_nsk
03.12.2023 21:26Тут есть 2 проблемы. Если довести до предела и вызывать одну функцию, которая все нам посчитает (и будет вызывать другие, если надо), то к чему вся эта канитель?
В чем принципиальная разница между этим
Save(ctx) { order.price = MegaFunction(ctx); }
и этим?
order.price = MegaFunction(ctx);
Есть другая проблема, когда логика расползается между методом Save(ctx) и всякими хелперами, утилсами и прочим.
Ravager
03.12.2023 21:26+1Кажется в го идиоматичный подход таки это мапперы и дто о которых вы упомянули . Плюсом они дают независимость представления данных в бл и бд. Да и вообще позволяют не тащить кишки наружу. Тогда исчезают все эти проблемы стейтов и можно писать обычный человеческий код как в других языках. А проблема тестируемости мапперов слишком преувеличена, юнит тесты все равно надо писать как и интеграционные. Некоторые рефлексию используют для полуавтоматизации и вполне себе успешно.
GreyCheshire Автор
03.12.2023 21:26Проблема не только в мапперах. Придется делать геттеры / сеттеры, чтобы скрыть поля.
comerc
03.12.2023 21:26Пожалуйста, покажите рыбу проекта. Я, пока не увижу код в репке, ничего не понимаю.
varanio
Мне кажется, вы зря в Go пытаетесь притащить подходы из Java.
Go - это практически C. А у вас тут ORM, DDD, 100 слоёв абстракций.
makarychev_13
В чём проблема затащить в go DDD и легковесную ORM? Как будто язык запрещает вам определить собственные доменные модели и наикнуть в них методы для бизнес-логики. А большинство ORM в го довольно легковесные и по своей сути скорее напоминают библиотеки для исполнения sql-запросов
micronull
Правильно ли я вас понял, что вы считаете что подходы чистой архитектуры не применимы к С и Go?
dmitryklerik
Правильно ли я вас понял, что вы считаете что подходы чистой архитектуры применимы только при Java-like программировании?
dmitryklerik
Ответил вопросом на вопрос потому-что ваш вопрос показался крайне токсичным и не конструктивным. Но правильно будет ответить по другому:
DDD и чистая архитектура по Мартину в своей сути предполагает чистые функции и разделение слоёв что вполне реализуется на любом C подобном языке. Автор комментария намекает на то что Go не Java и не надо в Go тянуть реализации подходов из Java. 100 слоёв абстракции и развесистые ORM это не Go-way. Для Go нужны свои решения, простые и лёгкие, более подходящие под философию языка
atygaev
но ведь на Go тоже пишут сложную логику
varanio
Имхо, ниша Go - это скорее небольшие и средние программы с упором на эффективность (обычно микросервисы или не слишком развесистые консольные утилиты). На Go редко пишут монолиты.
Упор на эффективность при относительной простоте написания кода.
То, что описано в статье - упор на чистоту кода, гибкость, переиспользуемость и т.д., т.е. немного в другую сторону. Грубо говоря, ORM хорош для чистоты кода, но ужасен для производительности (query builder еще ок, но не ORM). 100 слоёв абстракции с выделением чистейшего слоя бизнес логики хорошо для монолита, но для микросервиса с тремя таблицами - вообще ни к чему, эффективнее часть логики держать в SQL
В целом, считаю, что если надо писать код, который прям требует DDD, ORM и проч, лучше писать его на Java / PHP / C# ...
micronull
У вас будут большие проблемы с сопровождением проектов.
Мы пишем десятки микросервисов на Go, и все они поделены на слои. Это всегда облегчает покрытие кода тестами, и даёт все плюсы чистой архитектуры.
Отказываться от этого, но ради чего?
Правда я лично не совсем приветствую вводить какой либо ORM в Go, если можно легко обойтись без него.
Считаю что не стоит вводить какую либо логику на стороне БД.
Как вы будете покрывать эту логику тестами? Как вносить изменения? Что будет если поменяется тип базы данных, например на NoSQL?
БД это один из инструментов. Не стоит связывать инструменты с бизнес логикой.
Рекомендую ознакомиться с книгой "Чистая архитектура" Роберта Мартина.
varanio
Я в курсе про чистую архитектуру.
В микросервисах на Go какие-то слои нужны, конечно, тоже, но не настолько. Слой юзкейсов от слоя хранения отделять слишком дорого.
Мы пишем тоже десятки микросервисов на Go, и всё норм. Тесты пишутся часто с использованием базы.
SQL - это уже своего рода абстракция над бизнес-сущностями. Там уже описаны связи. Используя ORM вы дублируете эти описания, при этом существенно жертвуя производительностью, потому что начинается магия по подкапотному генерированию SQL через фиг пойми какую магию.
> Что будет если поменяется тип базы данных, например на NoSQL?
А что, если не поменяется? Вероятность смены pg на mongo в микросервисе около нуля (на моей практике ровно 0), а вот усилия по поддержке независимости от бд очень существенные, которые надо платить каждый день. Т.е., в среднем просто не отобъётся. Недавно писал про это как раз: https://t.me/crossjoin/243
micronull
Почему? Правильно ли понимаю что доменная сущность описанная с помощью структур и добавленный маппинг будет дорогим решением? Можете уточнить по какому именно критерию станет дорогим?
Давайте рассматривать проблему без ORM. Это отдельная тема. Представьте что у вас под капотом выполняются голые SQL запросы.
У нас был опыт перехода на mongodb просто потому что с ней было удобней работать. И в перспективе мы получаем важные для нас плюшки.
Реляционная модель БД изначально появилась из-за того что она отражала принятую в бизнесе работу с табличными данными. Поэтому кажется что вполне нормально держать высокую связанность бизнес логики с базой. КМК это заблуждение.
Если у нас БД отражает бизнес, и есть десятки сервисов которые работают с ней напрямую, то при изменении бизнес логики или сущностей, это будет затрагивать все сервисы.
varanio
Структуры окей, но не в структурах же дело. Например, надо выбрать посты для отображения топа постов, но так чтобы в топе не было двух постов от одного юзера. На SQL это делается, например, как group by user_id и having count(*) = 1
но это условие having count(*) = 1 - это бизнес-логика в SQL запросе. Которую надо, например, тестировать.
И как мы тут дешево абстрагируемся от хранилища? И так, чтобы тесты еще и реально что-то тестировали.
micronull
У меня мало опыта с такими кейсами, но я бы попробовал зайти через опции, например так:
Либо отдельный метод:
Вопросы к неймингу больше.
varanio
как вы протестируете, что ваше repo собирает правильный запрос для базы, и всё работает?
а если надо еще приджойнить что-то? Отобрать посты, где статистика (которая в другой таблице) топовая?
GreyCheshire Автор
Вы в пример приводите модели на чтение. Тут действительно ничего не нужно абстрагировать. Возвращайте просто модель на чтение и все, в ней нет никакой логики.
Давайте возьмем в пример расчет скидок, если угодно. Или каскадирование платежа в различных платежных провайдеров.
micronull
Буду тестировать через unit тесты дёргая публичные методы репы. Так как мы следуем правилу инверсии зависимостей, то у нас будет скорее всего в зависимостях некий интерфейс клиента БД и через мок сможем проверить собранный SQL запрос.
Либо зайти через интеграционный тест и поднять уже реальную базу.
Самое главное что бизнес логика останется в стороне и ни как не будет знать про БД. Тем самым мы не нарушаем принцип единственной ответственности.
micronull
У нас тоже есть тесты с базой данных, но это исключительно интеграционные тесты.
Бизнес логику мы покрываем с помощью unit тестирования. Это намного раз легче и удобней, в том числе в сопровождении (был опыт в покрытии тестами высокосвязанного кода).
makarychev_13
Ну вот вам ситуация из жизни. Есть топик кафки, который читают 15 микросервисов. Раньше там в топике был json, а потом после смены контракта там оказался protobuf. Без чистой архитектуры вам пришлось бы копаться в бизнес-логике этих сервисов. А так можно просто поправить самый верхний слой, который конвертит внешние данные в доменные модели, и спокойно жить дальше.
ORM - спорная штука, которая даже в C# мне не нравилась. Но что не так с DDD в go?
varanio
Ну тут смотря какие слои. Отделять условные контроллеры от сервисов можно, конечно, и это очень дёшево. Почему бы и нет.
А вот отделять сервисы (юзкейсы) от хранилища (бд) - это уже очень дорого, и не представляю себе ситауацию, когда надо 15 микросервисов переделать с посгреса на монгу.
GreyCheshire Автор
Отделяют обычно ради тестируемости. Интеграционное тестирование в масштабах большой кодовой базы становится очень дорогим.
Даже если у вас БД доступна из любого места в приложении вам придется решать как приводить агрегат в согласованное состояние и не забыть нужные поля изменить.
Если вы с такой проблемой не сталкивались, значит ваши бизнес-процессы позволяют вам держать бизнес-логику небольшой.
GreyCheshire Автор
Проблемы организации логики зависят от домена, а не языка. Если у вас будет сложная доменная логика, вы будете ее разбивать на составные части вне зависимости от языка. Поэтому слои абстракции будут появляться.
Вопрос на сколько они будут дырявые? Использование подходов из других языков может помочь понять в какой момент абстракция даст течь и сколько будет стоить её поддержка.