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

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