DDD в Go: натягивание совы на глобус?

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

Как-то раз я сидел в баре с давним приятелем, с которым раньше мне довелось поработать на поза-поза-позапрошлой работе. Он из сомневающихся по поводу перехода на Go, ярый приверженец своего нынешнего языка. Хочу сказать, что он делает действительно классные вещи, пишет безупречный код, у него есть, чему поучиться. Но к Go у него отношение не слишком позитивное. Как он сказал: “Go — это *****код (плохой код)”. И в качестве одного из аргументов привел то, насколько, по его мнению, криво в Go реализована обработка ошибок. В чем-то он прав — в моем текущем не самом большом проекте на Go конструкция “if err != nil” встречается 1132 раза.


Этот мой приятель, не побоюсь этого слова — адепт DDD (domain driven design). Все, что не относится к DDD, — это, по его мнению, антипаттерн, ад и хаос. Когда я ему рассказал, что у меня есть довольно успешный опыт проектирования по DDD в Go-проектах, он округлил глаза. Да, ответил я, с определенной серией оговорок и компромиссов это работает, и неплохо.



Привет, меня зовут Толя и я ведущий разработчик платежного сервиса в Константе. Мой опыт разработки 15 лет, я разрабатывал на PHP, на чистом C, временами на C++, а последние 2 года разрабатываю на Go.
Мне кажется, что почти все гоферы когда-то мигрировали в мир Go из других миров. В основном это миры хардкорного ООП. Тех, кто начал свой путь в IT именно с языка Go — крайне мало, я таких даже и не встречал. Оно и понятно: язык появился сравнительно недавно, а хайпанул вообще как будто вчера.
Тем не менее, есть определенная категория разработчиков, которые подсознательно хотели бы окунуться в Go, но в силу своих собственных убеждений этого пока не делают. Или делают что-то на Go частично, какие-то небольшие кусочки логики, которую их уютные PHP/Python/etc по каким-то причинам тянут не так хорошо. В общем, “Go — не для всего”, говорят они.


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


С DDD у вас все элементы и кусочки логики строго на своих местах, при этом вы мыслите больше в терминах бизнеса, а не только технических особенностей. Вы получаете хорошо поддерживаемый, расширяемый и красиво написанный проект. Но из-за особенностей языка Go требуется повышенная дисциплина разработки. Такая дисциплина, в общем-то, и в других языках требуется. Пока еще не придумали язык DDD++, на 100% заточенный под всё, что есть в DDD, и делающий невозможным отступление от определенных правил.


Я уже молчу про то, что в Go для реализации своих domain driven амбиций местами может потребоваться отступить от официальных стайлгайдов создателей Go. Помните: творец здесь вы, а язык программирования — это всего лишь инструмент.


Пример: разделение на слои и их изоляция друг от друга. Мой опыт


В интернете уже есть статьи о том, как в Go-проектах разложить файлы по папочкам так, чтобы получилось DDD. Я хочу немного поделиться своим опытом, тем, как это получилось у меня, на нескольких примерах.


Предположим, мы делаем приложение — платежный сервис. Пусть приложение будет иметь следующие слои:


  • прикладной (application)
  • предметной области (domain)
  • инфраструктурный (infrastructure)

Раскладываем все наши объекты по одноименным Go-пакетам. А дальше дилемма: как бы нам так все организовать, чтобы детали реализации каждого слоя не торчали наружу? Чаще всего рекомендуют непубличные объекты называть с маленькой буквы. Но мне понравилась другая идея: все структуры, скрывающие детали реализации, складывать в подпакет internal. Это такой пакет, содержимое которого доступно соседним пакетам и вышестоящему пакету, но не всем остальным. В самом же пакете слоя пусть лежат интерфейсы, а также те типы объектов, которые скрывать мы не будем (например, сущности, объекты-значения и т.д.). Отсюда следует идея, что фабричные функции (которые NewFooBar()) спрятанных в internal сервисов можно вынести в свой подпакет factory.


Вот как это может выглядеть на примере слоя domain:


- domain/
  - factory/
      user_repository_factory.go
      transaction_repository_factory.go
  - entity/
      user.go
      transaction.go
  - value/
      money.go
  - internal/
      user_repository.go
      transaction_repository.go
  user_repository_interface.go
  transaction_repository_interface.go

Итак, в нашем домене 2 сущности: User и Transaction. За вытаскивание из БД и сохранение в БД (или в какой-то другой тип хранилища) отвечают соответственно UserRepository и TransactionRepository. Опишем интерфейсы этих репозиториев и положим в соответствующие файлы в корне слоя-пакета domain:


type UserRepositoryInterface interface {
    Get(id int64) (entity.User, error)
    FindByEmail(email string) (*entity.User, error)
    Save(user entity.User) error
}

type TransactionRepositoryInterface interface {
    Get(id int64) (entity.Transaction, error)
    FindByUserId(userId int64) (*entity.Transaction, error)
    Save(transaction entity.Transaction) error
}

Соответственно, в factory будут такие фабрики (для простоты иллюстрации опустим проброс зависимостей и прочие вещи):


func NewUserRepository() domain.UserRepositoryInterface {
    return &internal.UserRepository{}
}

func NewTransactionRepository() domain.TransactionRepositoryInterface {
    return &internal.TransactionRepository{}
}

В internal будут лежать реализации UserRepository и TransactionRepository.
В случае, если у нас не доменный слой, а, например, сервисный, то в его internal ушли бы реализации сервисов.


Но вернёмся к доменному слою. Пытливый читатель может возразить, мол, домен не должен иметь никаких зависимостей, а значит конкретные реализации репозиториев должны находиться в инфраструктурном слое. Что ж, без проблем, подвинем две папки и получим такую структуру пакетов:


- infrastructure/
  - factory/
      user_repository_factory.go
      transaction_repository_factory.go
  - internal/
      user_repository.go
      transaction_repository.go
- domain/
  - entity/
      user.go
      transaction.go
  - value/
      money.go
  user_repository_interface.go
  transaction_repository_interface.go

Пример: поведение сущностей


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


Например, мы в нашем платежном сервисе имеем сущность под названием Transaction со следуюшими полями (оставлю только те, которые нужны в этом примере):


type Transaction struct {
    ...
    Status string
    ProcessedAt *time.Time
}

Status — текущий статус транзакции (может принимать значения Created, Processing, Success, Failed). А ProcessedAt — время проведения транзакции на стороне внешней платежной системы, может быть nil, если транзакция ещё не проведена (имеет статус, отличный от Success). Если транзакция в статусе Success, то поле ProcessedAt обязательно должно иметь какое-то значение (т.е. не nil).


Получается, если мы позволим в поля транзакции записывать значения, как хотим, то инвариант с ProcessedAt и Status может быть не соблюден в какой-то момент времени — то есть в какой-то момент времени сущность Transaction может оказаться в невалидном состоянии.


Выходит, нам всё равно придется что-то придумать, чтобы менять состояние строго через вызовы методов, в которых инкапсулируется логика проверки возможности этого изменения.
Давайте так и сделаем:


func (t *Transaction) SetSuccess(processedAt time.Time) error {
    if t.Status != "Processing" {
        return fmt.Errorf("cannot set success status after %v", t.Status)
    }

    t.Status = "Success"
    t.ProcessedAt = &processedAt

    return nil
}

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


Окей, наделали красивых методов. Но поля-то всё ещё публичные… И тут я вам предлагаю на выбор 3 варианта, что можно сделать:


  1. Сделать поля "приватными". Но в этом случае они внутри пакета всё равно будут видны из других объектов, да и придётся наплодить кучу геттеров, что, оказывается, не go way.
  2. Каждую отдельную сущность положить в свой отдельный пакет внутри entity. Получить в итоге "пакет с пакетами" и чрезмерно усложнённое дерево папок в проекте.
  3. Забить и договориться всей командой, что поля напрямую не сеттим, состояние меняем строго через методы, а нарушителей такого правопорядка на код-ревью бьём по рукам.

Декларативный стиль описания бизнес-логики


Декларативное описание бизнес-правил в Go у меня в целом получилось приемлемым, aka паттерн "спецификации", хотя в моём случае и не на 100% его книжный вариант. Здесь покажу один из возможных примеров реализации, и я уверен, что у вас получится лучше, красивее и каноничнее.


Давайте представим: в нашем платежном сервисе возникла необходимость завести небольшой и поначалу не очень сложный компонент под названием "антифрод". Этот компонент должен разрешать или запрещать разным пользователям операции пополнения баланса или вывода средств по определенным правилам. Для каждой поддерживаемой нами платежной системы этот набор правил свой; также набор правил меняется в зависимости от того, в какой юрисдикции действует наш сервис. Поначалу таких правил немного, но у бизнеса аппетит приходит во время еды, и появляются все новые и новые требования, возникает необходимость комбинировать правила между собой. И очень важно: наш код при этом не должен превращаться в лапшу.


Итак, зададим интерфейс, который должно будет реализовывать каждое правило нашего антифрода:


type AntifraudRule interface {
    IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error)
    IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error)
    And(other AntifraudRule) AntifraudRule
    Or(other AntifraudRule) AntifraudRule
    AndNot(other AntifraudRule) AntifraudRule
}

Первые два метода должны выполнять проверку, стоит ли разрешить пользователю user пополнить/вывести amount денег на/с кошелька wallet во внешней платежной системе. А методы And(), Or(), AndNot() — это методы-операторы, благодаря которым мы можем выстраивать наши правила в уникальные комбинации.


Вот пример реализации одного из правил. Допустим, мы хотим разрешать всем делать пополнения только в растущую луну, а выплаты в полнолуние. Тогда напишем следующий код:


type MoonRule struct {}

func NewMoonRule() AntifraudRule {
    return &MoonRule{}
}

func (r MoonRule) IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error) {
    if IsRisingMoon() {
        return true, nil
    }
    return false, nil
}

func (r MoonRule) IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error) {
    if IsFullMoon() {
        return true, nil
    }
    return false, nil
}

func (r MoonRule) And(other AntifraudRule) AntifraudRule {
    return NewAndRule(r, other)
}

func (r MoonRule) Or(other AntifraudRule) AntifraudRule {
    return NewOrRule(r, other)
}

func (r MoonRule) AndNot(other AntifraudRule) AntifraudRule {
    return NewAndNotRule(r, other)
}

В последних трёх методах видим создание экземпляров AndRule, OrRule и AndNotRule. Вот как выглядит, например, реализация AndRule:


type AndRule struct {
    left AntifraudRule
    right AntifraudRule
}

func NewAndRule(left AntifraudRule, right AntifraudRule) AntifraudRule {
    return &AndRule{
        left: left,
        right: right,
    }
}

func (r AndRule) IsDepositAllowed(user User, wallet Wallet, amount Money) (bool, error) {
    leftResult, err := r.left.IsDepositAllowed(user, wallet, money)
    if err != nil {
        return false, err
    }
    rightResult, err := r.right.IsDepositAllowed(user, wallet, money)
    if err != nil {
        return false, err
    }

    return leftResult && rightResult, nil
}

func (r AndRule) IsPayoutAllowed(user User, wallet Wallet, amount Money) (bool, error) {
    leftResult, err := r.left.IsPayoutAllowed(user, wallet, money)
    if err != nil {
        return false, err
    }
    rightResult, err := r.right.IsPayoutAllowed(user, wallet, money)
    if err != nil {
        return false, err
    }

    return leftResult && rightResult, nil
}

func (r AndRule) And(other AntifraudRule) AntifraudRule {
    return NewAndRule(r, other)
}

func (r AndRule) Or(other AntifraudRule) AntifraudRule {
    return NewOrRule(r, other)
}

func (r AndRule) AndNot(other AntifraudRule) AntifraudRule {
    return NewAndNotRule(r, other)
}

Аналогичным образом реализуются OrRule и AndNotRule.


И наконец о том, как всем этим пользоваться. Допустим, у нас 3 правила: MoonRule, SunRule и RetrogradeMercuryRule. Определимся, что в текущей версии нашего сервиса мы хотим разрешать платежи людям тогда, когда нам благоприятствуют: Луна И Солнце ИЛИ Ретроградный Меркурий. Давайте напишем сборку нашего антифрода с этими условиями:


func NewAntifraud() AntifraudRule {
    moon := NewMoonRule()
    sun := NewSunRule()
    retrogradeMercury := NewRetrogradeMercuryRule()

    return moon.And(sun).Or(retrogradeMercury)
}

Как видим, вроде получилось. И вроде даже Go нам палки в колеса особо не ставил, и даже назойливые if err != nil почти не путались под ногами. А если всё это дело додумать, причесать, ух… Как говорится, нет предела совершенству.


Вместо заключения


Несмотря на то, что DDD в Go внедряется с определённым количеством компромиссов, всё же я увидел от такого внедрения больше плюсов, чем минусов. Да, где-то придётся на что-то закрыть глаза, где-то извернуться. Но даже в таком виде оно того стоит. И уж точно "натягиванием совы на глобус" я бы это не назвал.

Источник: https://habr.com/ru/company/constanta/blog/675408/


Интересные статьи

Интересные статьи

Для всех кто хочет познакомиться с книгой Космофизические факторы в случайных процессах Симона Эльевича Шноля оставляю ссылку
С каждым днём голосовые технологии внедряются в нашу жизнь всё больше и больше. В течение нескольких десятков лет развитие голосовых технологий не выходило за рамки научных исследований, однако уже се...
Я давно знаком с Битрикс24, ещё дольше с 1С-Битрикс и, конечно же, неоднократно имел дела с интернет-магазинами которые работают на нём. Да, конечно это дорого, долго, местами неуклюже...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
Люди с техническим складом ума во всем стремятся найти систему. При изучении английского, столь востребованного в IT, многие программисты сталкиваются с тем, что не могут понять, как устроен эт...