В последнее время достаточно много выступлений, посвященных реализации подходов 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
    // Сумму и статус обновить мы забыли =(
}

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

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

  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.

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

Комментарии (32)


  1. varanio
    03.12.2023 21:26
    +12

    Мне кажется, вы зря в Go пытаетесь притащить подходы из Java.

    Go - это практически C. А у вас тут ORM, DDD, 100 слоёв абстракций.


    1. makarychev_13
      03.12.2023 21:26
      +3

      В чём проблема затащить в go DDD и легковесную ORM? Как будто язык запрещает вам определить собственные доменные модели и наикнуть в них методы для бизнес-логики. А большинство ORM в го довольно легковесные и по своей сути скорее напоминают библиотеки для исполнения sql-запросов


    1. micronull
      03.12.2023 21:26
      +1

      Правильно ли я вас понял, что вы считаете что подходы чистой архитектуры не применимы к С и Go?


      1. dmitryklerik
        03.12.2023 21:26

        Правильно ли я вас понял, что вы считаете что подходы чистой архитектуры применимы только при Java-like программировании?


        1. dmitryklerik
          03.12.2023 21:26
          +2

          Ответил вопросом на вопрос потому-что ваш вопрос показался крайне токсичным и не конструктивным. Но правильно будет ответить по другому:

          DDD и чистая архитектура по Мартину в своей сути предполагает чистые функции и разделение слоёв что вполне реализуется на любом C подобном языке. Автор комментария намекает на то что Go не Java и не надо в Go тянуть реализации подходов из Java. 100 слоёв абстракции и развесистые ORM это не Go-way. Для Go нужны свои решения, простые и лёгкие, более подходящие под философию языка


          1. atygaev
            03.12.2023 21:26
            +2

            но ведь на Go тоже пишут сложную логику


      1. varanio
        03.12.2023 21:26
        +1

        Имхо, ниша Go - это скорее небольшие и средние программы с упором на эффективность (обычно микросервисы или не слишком развесистые консольные утилиты). На Go редко пишут монолиты.

        Упор на эффективность при относительной простоте написания кода.

        То, что описано в статье - упор на чистоту кода, гибкость, переиспользуемость и т.д., т.е. немного в другую сторону. Грубо говоря, ORM хорош для чистоты кода, но ужасен для производительности (query builder еще ок, но не ORM). 100 слоёв абстракции с выделением чистейшего слоя бизнес логики хорошо для монолита, но для микросервиса с тремя таблицами - вообще ни к чему, эффективнее часть логики держать в SQL

        В целом, считаю, что если надо писать код, который прям требует DDD, ORM и проч, лучше писать его на Java / PHP / C# ...


        1. micronull
          03.12.2023 21:26
          +1

          У вас будут большие проблемы с сопровождением проектов.
          Мы пишем десятки микросервисов на Go, и все они поделены на слои. Это всегда облегчает покрытие кода тестами, и даёт все плюсы чистой архитектуры.
          Отказываться от этого, но ради чего?

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

          эффективнее часть логики держать в SQL

          Считаю что не стоит вводить какую либо логику на стороне БД.
          Как вы будете покрывать эту логику тестами? Как вносить изменения? Что будет если поменяется тип базы данных, например на NoSQL?

          БД это один из инструментов. Не стоит связывать инструменты с бизнес логикой.

          Рекомендую ознакомиться с книгой "Чистая архитектура" Роберта Мартина.


          1. varanio
            03.12.2023 21:26

            Я в курсе про чистую архитектуру.

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

            Мы пишем тоже десятки микросервисов на Go, и всё норм. Тесты пишутся часто с использованием базы.

            SQL - это уже своего рода абстракция над бизнес-сущностями. Там уже описаны связи. Используя ORM вы дублируете эти описания, при этом существенно жертвуя производительностью, потому что начинается магия по подкапотному генерированию SQL через фиг пойми какую магию.

            > Что будет если поменяется тип базы данных, например на NoSQL?
            А что, если не поменяется? Вероятность смены pg на mongo в микросервисе около нуля (на моей практике ровно 0), а вот усилия по поддержке независимости от бд очень существенные, которые надо платить каждый день. Т.е., в среднем просто не отобъётся. Недавно писал про это как раз: https://t.me/crossjoin/243


            1. micronull
              03.12.2023 21:26

              Слой юзкейсов от слоя хранения отделять слишком дорого.

              Почему? Правильно ли понимаю что доменная сущность описанная с помощью структур и добавленный маппинг будет дорогим решением? Можете уточнить по какому именно критерию станет дорогим?

              Используя ORM вы дублируете эти описания, при этом существенно жертвуя производительностью, потому что начинается магия по подкапотному генерированию SQL через фиг пойми какую магию.

              Давайте рассматривать проблему без ORM. Это отдельная тема. Представьте что у вас под капотом выполняются голые SQL запросы.

              А что, если не поменяется? Вероятность смены pg на mongo в микросервисе около нуля (на моей практике ровно 0), а вот усилия по поддержке независимости от бд очень существенные, которые надо платить каждый день.

              У нас был опыт перехода на mongodb просто потому что с ней было удобней работать. И в перспективе мы получаем важные для нас плюшки.

              Реляционная модель БД изначально появилась из-за того что она отражала принятую в бизнесе работу с табличными данными. Поэтому кажется что вполне нормально держать высокую связанность бизнес логики с базой. КМК это заблуждение.

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


              1. varanio
                03.12.2023 21:26

                Давайте рассматривать проблему без ORM. Это отдельная тема. Представьте что у вас под капотом выполняются голые SQL запросы.

                Структуры окей, но не в структурах же дело. Например, надо выбрать посты для отображения топа постов, но так чтобы в топе не было двух постов от одного юзера. На SQL это делается, например, как group by user_id и having count(*) = 1

                но это условие having count(*) = 1 - это бизнес-логика в SQL запросе. Которую надо, например, тестировать.

                И как мы тут дешево абстрагируемся от хранилища? И так, чтобы тесты еще и реально что-то тестировали.


                1. micronull
                  03.12.2023 21:26

                  И как мы тут дешево абстрагируемся от хранилища?

                  У меня мало опыта с такими кейсами, но я бы попробовал зайти через опции, например так:

                  func main() {
                   _,_ = repo.Posts(repo.WithOneUser())
                  }
                  
                  // В репозитории
                  func WithOneUser() Option {
                    func(r *Repo) {
                      r.havingCount = 1
                   }
                  }
                  

                  Либо отдельный метод:

                  func main() {
                   _,_ = repo.TopPostsOneUsers()
                  }
                  

                  Вопросы к неймингу больше.


                  1. varanio
                    03.12.2023 21:26
                    +1

                    как вы протестируете, что ваше repo собирает правильный запрос для базы, и всё работает?

                    а если надо еще приджойнить что-то? Отобрать посты, где статистика (которая в другой таблице) топовая?


                    1. GreyCheshire Автор
                      03.12.2023 21:26

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

                      Давайте возьмем в пример расчет скидок, если угодно. Или каскадирование платежа в различных платежных провайдеров.


                    1. micronull
                      03.12.2023 21:26

                      как вы протестируете, что ваше repo собирает правильный запрос для базы, и всё работает?

                      Буду тестировать через unit тесты дёргая публичные методы репы. Так как мы следуем правилу инверсии зависимостей, то у нас будет скорее всего в зависимостях некий интерфейс клиента БД и через мок сможем проверить собранный SQL запрос.

                      Либо зайти через интеграционный тест и поднять уже реальную базу.

                      Самое главное что бизнес логика останется в стороне и ни как не будет знать про БД. Тем самым мы не нарушаем принцип единственной ответственности.


            1. micronull
              03.12.2023 21:26
              +1

              Тесты пишутся часто с использованием базы.

              У нас тоже есть тесты с базой данных, но это исключительно интеграционные тесты.
              Бизнес логику мы покрываем с помощью unit тестирования. Это намного раз легче и удобней, в том числе в сопровождении (был опыт в покрытии тестами высокосвязанного кода).


        1. makarychev_13
          03.12.2023 21:26

          100 слоёв абстракции с выделением чистейшего слоя бизнес логики хорошо для монолита, но для микросервиса с тремя таблицами - вообще ни к чему

          Ну вот вам ситуация из жизни. Есть топик кафки, который читают 15 микросервисов. Раньше там в топике был json, а потом после смены контракта там оказался protobuf. Без чистой архитектуры вам пришлось бы копаться в бизнес-логике этих сервисов. А так можно просто поправить самый верхний слой, который конвертит внешние данные в доменные модели, и спокойно жить дальше.

          В целом, считаю, что если надо писать код, который прям требует DDD, ORM и проч, лучше писать его на Java / PHP / C#

          ORM - спорная штука, которая даже в C# мне не нравилась. Но что не так с DDD в go?


          1. varanio
            03.12.2023 21:26

            Ну тут смотря какие слои. Отделять условные контроллеры от сервисов можно, конечно, и это очень дёшево. Почему бы и нет.

            А вот отделять сервисы (юзкейсы) от хранилища (бд) - это уже очень дорого, и не представляю себе ситауацию, когда надо 15 микросервисов переделать с посгреса на монгу.


            1. GreyCheshire Автор
              03.12.2023 21:26
              +1

              Отделяют обычно ради тестируемости. Интеграционное тестирование в масштабах большой кодовой базы становится очень дорогим.

              Даже если у вас БД доступна из любого места в приложении вам придется решать как приводить агрегат в согласованное состояние и не забыть нужные поля изменить.

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


    1. GreyCheshire Автор
      03.12.2023 21:26
      +2

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

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


  1. gybson_63
    03.12.2023 21:26

    Честно говоря, вообще непонятно как вы умудряетесь работать с бизнес-логикой на таком очень низком уровне абстракции и без нормального ООП. По идее вы должны в слой БЛ слать только JSON, например, с сущностью. Там json конвертируется в структуру данных пригодную для алгоритмов, которую по некоторым подпискам можно поменять скриптами адаптированными под бизнес-процессы конкретные и после целиком это запишется в БД, Бог знает в каком виде, лучше никому и не знать. Т.е. вы записываете заказ. Записывает оплату. А когда запрашиваете у БЛ заказ, она сама соединяет данные и дает статус заказа. Т.е. его даже и не надо через сущность трогать, это свойство ридонли для заказа. Связанность то тоже надо контролировать. В этом плане event-sourcing прекрасно поможет.

    "При росте сложности бизнес-логики" ... покупается 1С и все добро туда сваливается, потому что сочинять свой слой с БЛ очень дорого. Заказ - оплата - резерв - отгрузка - доставка - получение. Это уже необходимый минимум. А если еще и сразу резерв, до оплаты, а вы заказ меняете мимо логики.


    1. din_hacker
      03.12.2023 21:26

      В чем плюсы пересылать JSON в слой БД?


      1. gybson_63
        03.12.2023 21:26

        Там написано "БЛ", Л вторая. Вы хотите Model-View-Controller собрать на процедурном языке? Очень дорого и результат не гарантирован.


  1. itmind
    03.12.2023 21:26

    В 1с например просто присваивают значения полю, как указано в разделе "В начале было Active record" и проблем нет. Есть правда события, например "ПередЗаписью" где мы можем проверить и дозаполнить необходимые поля. Я думаю в Go по аналогии нужно не в метод Apply передавать разные функции, изменяющие объект, а сделать метод Save(ctx) и в нем закодировать логику расчета доп полей перед помещением в БД.


    1. GreyCheshire Автор
      03.12.2023 21:26

      Да, это отличный вариант

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


    1. piton_nsk
      03.12.2023 21:26
      +1

      сделать метод Save(ctx) и в нем закодировать логику расчета доп полей

      При более-менее сложной логике, метод Save станет просто гигантским. Я встречал такие методы под пару тысяч строк размером.


      1. itmind
        03.12.2023 21:26

        Так не запрещено же из одного метода вызывать другие процедуры и функции.


        1. piton_nsk
          03.12.2023 21:26

          Тут есть 2 проблемы. Если довести до предела и вызывать одну функцию, которая все нам посчитает (и будет вызывать другие, если надо), то к чему вся эта канитель?

          В чем принципиальная разница между этим

          Save(ctx)
            {
            order.price = MegaFunction(ctx);
            }

          и этим?

          order.price = MegaFunction(ctx);

          Есть другая проблема, когда логика расползается между методом Save(ctx) и всякими хелперами, утилсами и прочим.


  1. Ravager
    03.12.2023 21:26
    +1

    Кажется в го идиоматичный подход таки это мапперы и дто о которых вы упомянули . Плюсом они дают независимость представления данных в бл и бд. Да и вообще позволяют не тащить кишки наружу. Тогда исчезают все эти проблемы стейтов и можно писать обычный человеческий код как в других языках. А проблема тестируемости мапперов слишком преувеличена, юнит тесты все равно надо писать как и интеграционные. Некоторые рефлексию используют для полуавтоматизации и вполне себе успешно.


    1. GreyCheshire Автор
      03.12.2023 21:26

      Проблема не только в мапперах. Придется делать геттеры / сеттеры, чтобы скрыть поля.


  1. MyraJKee
    03.12.2023 21:26
    -1

    Так и знал что будет холивар. ))


  1. comerc
    03.12.2023 21:26

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