Skip to content

Latest commit

 

History

History
408 lines (341 loc) · 22.5 KB

domain-service.md

File metadata and controls

408 lines (341 loc) · 22.5 KB

DDD на практике в Golang: Сервисы предметной области

intro Фото Nathan Dumlao из Unsplash

После того как мы обсудили Сущности и Объекты-значения, я представлю в этой статье третий из группы шаблонов моделирования предметной области. Он называется Сервис.

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

Там в нём хранится бизнес-логика. Он создаёт компоненты пользовательского интерфейса, например, поля формы. Он работает с сессиями и обрабатывает HTTP запросы. Иногда он просто играет роль огромного класса с "вспомогательными функциями". Иногда содержит код, который мог бы иметь простейший объект-значение. Периодически выполняет миграции в базе данных.

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

Другие статьи из DDD цикла:

  1. DDD на практике в Golang: Объект-значение
  2. DDD на практике в Golang: Сущности

Он содержит логику работы

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

Иногда определенная логика работы требует взаимодействия с несколькими Сущностями или Объектами-значениями. В таких случаях тяжело определить к какой Сущности она относится. В таком случае следует использовать Сервис предметной области.

Сервисы предметной области не работают с сессиями или запросами. Они ничего не знают о компонентах пользовательского интерфейса. Не выполняют миграции базы данных. Не проверяет вводимые пользователем данные. Сервисы предметной области отвечают только за бизнес-логику.

type ExchangeRateService interface {
    IsConversionPossible (from domain.Currency, to domain.Currency) bool
    Convert(to domain.Currency, from value_objects.Money) (value_objects.Money, error)
}

type DefaultExchangeRateService struct {
    repository repository.ExchangeRateRepository
}

func NewExchangeRateService(repository repository.ExchangeRateRepository) ExchangeRateService {
    return &DefaultExchangeRateService{
        repository: repository,
    }
}

func (s *DefaultExchangeRateService) IsConversionPossible(from domain.Currency, to domain.Currency) bool {
    var result bool
    //
    // какой-то код
    //
    return result
}

func (s *DefaultExchangeRateService) Convert(to domain.Currency, from value_objects.Money) (value_objects.Money, error) {
    var result value_objects.Money
    //
    // какой-то код
    //
    return result, nil
}

Пример Сервиса предметной области

В приведенном выше примере рассматривается ExchangeRateService. Каждый раз когда я создаю некую структуру без состояния, которую я должен буду внедрить в другой объект, я определяю интерфейс. Это поможет позже при unit тестировании.

Этот сервис отвечает за всю бизнес-логику обмена валюты. Он содержит ExchangeRateRepository для получения всех курсов, поэтому может преобразовать сумму в любой валюте.

type CasinoService struct {
    bonusRepository repository.BonusRepository
    accountService  services.AccountService
    //
    // какие-нибудь другие поля
    //
}

func (s *CasinoService) Bet(account domain.Account, money value_objects.Money) error {
    bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
    if err != nil {
        return err
    }
    //
    // какой-то код
    //
    for _, bonus := range bonuses {
        err = bonus.Apply(&account)
        if err != nil {
            return err
        }
    }
    //
    // какой-то код
    //
    err = s.accountService.Update(account)
    if err != nil {
        return err
    }
    return nil
}

Случай со сложной логикой работы

Как я уже говорил, Сервис предметной области содержит бизнес-инварианты, которые слишком сложны для хранения в одной Сущности или Объекте-значении. В приведенном выше примере, CasinoService хранит сложную логику применения бонусов (Bonuses) всякий раз, когда с какой-то учетной записи (Account) делается новая ставка (Bet).

Вместо того, чтобы создавать связи между Сущностями Account и Bonus или, что ещё хуже, передавать требуемые репозитории или сервисы в методы Сущности, мы должны создать Сервис предметной области. Он будет хранить всю бизнес-логику применения Бонусов (Bonuses) к любой необходимой учётной записи (Account).

Он представляет собой контракт

Иногда наш ограниченный контекст зависит от других. Классическим примером может быть кластер микросервисов, где один из них обращается ко второму через REST API.

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

Мы всегда должны отделять нашу предметную область от технических деталей. Наличие внешнего API или соединения с базой данных внутри бизнес-логики — это признак кода с запашком.

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

// уровень предметной области
type AccountService interface {
    Update(account entity.Account) error
}
// инфраструктурный уровень
type AccountAPIService struct {
    client *http.Client
}

func NewAccountService(client *http.Client) services.AccountService {
    return &AccountAPIService{
        client: client,
    }
}

func (s *AccountAPIService) Update(account domain.Account) error {
    var request *http.Request
    //
    // какой-то код
    //
    response, err := s.client.Do(request)
    if err != nil {
        return err
    }
    //
    // какой-то код
    //
    fmt.Printf("Response code: %d", response.StatusCode)
    return nil
}

Сервис предметной области как контракт

В приведенном выше примере я определил интерфейс AccountService на уровне предметной области. Он представляет собой контракт, который могут использовать другие Сервисы предметной области. Интерфейс реализован в виде AccountAPIService.

AccountAPIService отправляет HTTP-запросы во внешнюю CRM-систему или нашему внутреннему микросервису, предназначенному только для работы с учётными записями (Accounts). Таким образом, используя такой подход, мы сможем создать ещё одну реализацию AccountService, которая будет работать с тестовыми учётными записями (Accounts) из файла в изолированной тестовой среде.

Не хранит состояний

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

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

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

// Сущность хранит состояние
type Account struct {
    ID      uint
    Person  Person
    Wallets []Wallet
}

// Объект-значение хранит состояние
type Money struct {
    Amount   int
    Currency Currency
}

// Сервис предметной области зависит только от других не хранящих состояние конструкций, например: 
// сервисов, репозиториев, фабрик, объектов, определяющие настройки приложения
type DefaultExchangeRateService struct {
    repository      *ExchangeRateRepository
    useForceRefresh bool
}

type CasinoService struct {
    bonusRepository BonusRepository
    bonusFactory    BonusFactory
    accountService  AccountService
}

Сравнение Сервиса предметной области с Сущностью и Объектом-значением

Как видно из приведенного выше примера, Сущности и Объект-значение хранят состояние. Сущность может изменять состояние во время выполнения, а объекты-значения всегда остаются неизменными. Когда нам нужно изменить объект-значение, мы создаём новый объект.

Сервиса предметной области не содержат какие-либо объекты, хранящие состояния. Они состоят только из других структур без состояния, таких как репозиторий, другой Сервис, Фабрика, значения настроек. Он может инициализировать создание состояние или его сохранение, но не хранит его.

// неправильно - состояние хранится внутри сервиса
type TransactionService struct {
    bonusRepository repository.BonusRepository
    result          value_objects.Money // поле, которое содержит состояние
}

func (s *TransactionService) Deposit(account entity.Account, money value_objects.Money) error {
    bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
    if err != nil {
        return err
    }
    fmt.Printf("%v", bonuses)
    //
    // какой-то код
    //
    s.result, err = s.result.Add(money) // изменяем состояние сервиса
    if err != nil {
        return err
    }
    return nil
}

Неправильный подход к хранению состояния внутри Сервиса предметной области

В приведенном выше примере TransactionService содержит поле, хранящее состояние в виде объекта-значения Money. Каждый раз когда мы хотим положить деньги на счёт, мы применяем бонусы к зачисляемому значению, money, а затем прибавляем его к result, которое является полем внутри Сервиса.

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

// правильно - состояние передаётся в виде аргумента current
type TransactionService struct {
    bonusRepository repository.BonusRepository
}

func (s *TransactionService) Deposit(current value_objects.Money, account entity.Account, money value_objects.Money) (value_objects.Money, error) {
    bonuses, err := s.bonusRepository.FindAllEligibleFor(account, money)
    if err != nil {
        return value_objects.Money{}, err
    }
    fmt.Printf("%v", bonuses)
    //
    // какой-то код
    //
    return current.Add(money) // возвращаем новое значение, которое представляет новое состояние
}

func main() {
    //
    // какой-то код
    //
LOOP:
    for true {
        select {
        case deposit := <- moneyChan:
            current, err := service.Deposit(current, account, deposit)
            if err != nil {
                log.Fatal(err)
            }
        case <-quitChan:
            break LOOP
        }       	
    }
    //
    // какой-то код
    //
}

Gравильный подход - состояние возвращается из Сервиса предметной области

Новый TransactionService всегда производит вычисления с переданным аргументом вместо того, чтобы хранить его внутри. Разные пользователи не могут совместно использовать один и тот же объект в памяти, и Сервис предметной области снова ведёт себя как единый экземпляр.

Сравнение Сервисов предметной области с другими типами сервисов

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

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

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

Проблема возникает, когда нужно отличать Сервисы прикладных операций (Application) и предметной области (Domain). Как оказалось, труднее всего найти отличие между этими двумя типами.

Исходя из моего опыта, я использовал Сервисы прикладных операций только для реализации общей логики работы с сессиями или обработки запросов. Алгоритм авторизации и прав доступа тоже можно разместить на этом уровне.

type AccountSessionService struct {
    accountService AccountService
}

func (s *AccountSessionService) GetAccount(session *sessions.Session) (*Account, error) {
    value, ok := session.Values["accountID"]
    if !ok {
        return nil, errors.New("there is no account in session")		
    }
    
    id, ok := value.(string)
    if !ok {
        return nil, errors.New("invalid value for account ID in session")
    }
    
    account, err := s.accountService.ByID(id)
    if err != nil {
    	return nil, err
    }
    
    return account, nil
}

Пример Сервиса прикладных операций

Во многих случаях Сервис прикладных операций — это обертка Сервиса предметной области. Я использовал такой подход всякий раз, когда хотел что-то кешировать внутри сессии и использовать Сервис предметной области в качестве резервного источника данных. Этот подход показан в приведенном выше примере.

Здесь AccountSessionService - это Сервис прикладных операций, который обертывает AccountService из уровня предметной области. Он отвечает за извлечения значения из сессии и затем использует его для поиска Account в сервисе AccountService.

Заключение

Сервис предметной области представляет собой структуру, не хранящую состояния, реализующую бизнес-логику. Он взаимодействует со многими различными объектами, такими как Сущность (Entity) и Объект-значение (Value Object). В сервис переносится сложная логика работы из них или та логика, которую непонятно куда лучше поместить.

Сервис предметной области не имеет ничего общего с сервисами из других уровней, кроме названия. Он используется только для бизнес-логики и не должен взаимодействовать с техническими деталями, сессиями, запросами или чем-либо ещё, специфичным для приложения.

Другие статьи из DDD цикла:

  1. DDD на практике в Golang: Объект-значение
  2. DDD на практике в Golang: Сущности

Полезные ссылки на источники: