Саша Беспоясов
Это я.

Доменное моделирование в функциональном стиле. Часть 3

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

Во этот раз мы познакомимся с функциональной композицией, частичным применением и монадами.

Глава 8. Понятие функций

В этой главе автор рассказывает об ФП в целом и его ключевых концепциях. В частности — что такое функции и функциональная композиция.

Функции — это штуки

Штуками мы будем называть то, что можно передать как вход или параметр и отдать как результат. Мы можем это делать с функциями в F# (и TS/JS).

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

Если мы к этому добавим каррирование и частичное применение, то получим гибкий механизм для «настройки» поведения программ.

Композиция

Композиция функций — это совмещение нескольких функций в функцию сложнее, где выход первой становится входом следующей и т. д. Такое соединение функций называется пайпингом (piping). Оно работает, если тип результата первой функции совпадает с типом аргумента следующей.

Композиция функций, как «стыковка» выхода первой функции и входа второй
Композиция функций, как «стыковка» выхода первой функции и входа второй

Пример выше — то же, что и:

Результат композиции — новая функция
Результат композиции — новая функция

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

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

Функции собираем в сервисы:
[low-level operation] >> [low-level operation] >> [low-level operation] => [service]

Сервисы — в процессы:
[service] >> [service] >> [service] => [workflow]

Параллельно компонуем процессы — получаем приложение:
[workflow]
[workflow] => [application]
[workflow]

Основная проблема в композиции — это несовпадение типов аргументов и результатов. В следующих главах автор рассказывает, как эту проблему решать.

Глава 9. Композиция пайплайна

В этой и следующей главе мы реализуем процесс приёмки заказа в коде. Мы хотим получить нечто типа:

let placeOrder unvalidatedOrder =
  unvalidatedOrder
  |> validateOrder
  |> priceOrder
  |> acknowledgeOrder
  |> createEvents

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

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

Используем типы как путеводитель

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

let validateOrder : ValidateOrder =
  fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
    // ^dependency           ^dependency        ^input
      ...

Реализуем валидацию

Для простоты в этой главе мы отбросим эффекты, поэтому тип проверки адреса пока упростим:

type CheckAddressExists = UnvalidatedAddress -> CheckedAddress
// AsyncResult временно ушёл.

Тогда тип валидатора будет следующим:

type ValidateOrder =
  CheckProductCodeExists    // dependency
    -> CheckAddressExists   // dependency
    -> UnvalidatedOrder     // input
    -> ValidatedOrder       // output

Чтобы создать валидированный заказ из невалидированного, нам потребуется:

  • создать доменный тип OrderId из строки невалидированного заказа;
  • создать доменный тип CustomerInfo;
  • создать тип Address из ShippingAddress и второй такой же из BillingAddress;
  • скомпоновать составные части заказа вместе.
let validateOrder : ValidateOrder =
  fun checkProductCodeExists checkAddressExists unvalidatedOrder ->

    let orderId =
      unvalidatedOrder.OrderId
      |> OrderId.create

    let customerInfo =
      unvalidatedOrder.CustomerInfo
      |> toCustomerInfo

    let shippingAddress =
      unvalidatedOrder.ShippingAddress
      |> toAddress checkAddressExists   // Хелпер с «запомненным» аргументом-зависимостью.

    // ...И так для каждого поля невалидированного заказа.
    // Когда всё готово и проверено, возвращаем валидированный заказ:
    {
      OrderId = orderId
      CustomerInfo = customerInfo
      ShippingAddress = shippingAddress
      BillingAddress = ...
      Lines = ...
    }

Функции toCustomerInfo и toAddress — это хелперы, которые будут создавать доменные типы из неваладированных данных или выбрасывать ошибки, если данные не подходят. Внутри них мы будем пользоваться той же логикой — преобразовать невалидированные данные в домен, а если не получится — выбросить ошибку.

let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
  // Создаём свойства для CustomerInfo,
  // выбрасываем исключения, если данные невалидные.
  let firstName = customer.FirstName |> String50.create
  let lastName = customer.LastName |> String50.create
  let emailAddress = customer.EmailAddress |> EmailAddress.create

  let name : PersonalName = {
    FirstName = firstName
    LastName = lastName
  }

  let customerInfo : CustomerInfo = {
    Name = name
    EmailAddress = emailAddress
  }

  // Возвращаем результат:
  customerInfo

В случае с проверкой адреса нам также потребуется вызвать сторонний сервис (зависимость):

let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
  // Вызываем сервис-зависимость:
  let checkedAddress = checkAddressExists unvalidatedAddress

  // Паттерн-матчим, чтобы получить значение:
  let (CheckedAddress checkedAddress) = checkedAddress

  let addressLine1 = checkedAddress.AddressLine1 |> String50.create
  let addressLine2 = checkedAddress.AddressLine2 |> String50.createOption
  let city = checkedAddress.City |> String50.create
  let zipCode = checkedAddress.ZipCode |> ZipCode.create

  // Создаём адрес:
  let address : Address = {
    AddressLine1 = addressLine1
    AddressLine2 = addressLine2
    City = city
    ZipCode = zipCode
  }

  // Возвращаем
  address

Для создания пунктов заказа мы пройдёмся по каждому невалидированному пункту с List.map и проделаем то же самое. Но код создания пунктов заказа, а также реализацию остальных шагов и создание событий я предлагаю посмотреть в оригинале :—)

Внедряем зависимости

В функциональном программировании мы не используем DI-контейнеры, а наоборот держим все зависимости явными. Книжка вводная, говорит автор, поэтому мы не будем затрагивать такие вещи, как Reader Monad и Free Monad. Будем просто «внедрять зависимости» через верхнеуровневую функцию.

Посмотрим на пример хелперов, которые мы писали ранее:

let toAddress checkAddressExists unvalidatedAddress =  ...
let toProductCode checkProductCodeExists productCode = ...

Функции checkAddressExists и checkProductCodeExists — это зависимости. Когда мы используем их в других функциях, мы должны указать зависимости и там:

let toValidatedOrderLine checkProductExists unvalidatedOrderLine =
//                       ^ Нужно для toProductCode ниже.

  let orderLineId = ...
  let productCode =
    unvalidatedOrderLine.ProductCode
    |> toProductCode checkProductExists // Используем зависимость.

  ...

И так до тех пор, пока мы не доберёмся до «корня композиции» — верхнеуровневой функции, которая будет настраивать все эти зависимости. Такие функции удобнее тестировать, потому что все зависимости легко подменить, а сами функции не содержат состояния.

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

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

Глава 10. Работа с ошибками

Мы хотим создать согласованную и прозрачную схему работы с ошибками. В этой главе мы рассмотрим функциональный подход к обработке ошибок и узнаем, как отделять доменные ошибки (domain errors) от остальных.

Используем Result, чтобы сделать ошибки явными

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

type CheckAddressExists = UnvalidatedAddress -> CheckedAddress

У нас могут возникнуть ошибки, и мы хотим это отобразить прямо в типе:

type CheckAddressExists =
  UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>

and AddressValidationError =
  | InvalidFormat of string
  | AddressNotFound of string

Работаем с доменными ошибками

Потенциальные ошибки мы можем поделить на 3 группы:

  • доменные ошибки — ожидаемые в самих бизнес-процессах, типа как непринятый заказ или нехватка товаров на складе;
  • исключения (panics) — приводят систему в невосстановимое состояние, как выход за пределы массива или недостаток памяти;
  • инфраструктурные — ожидаемые с технической точки зрения, но не бизнесом, типа как неудачная аутентификация или проблемы с сетью.

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

Мы можем типизировать каждую ошибку, а затем для каждого процесса собрать юнион возможных ошибок для этого процесса:

type PlaceOrderError =
  | ValidationError of string
  | ProductOutOfStock of ProductCode
  | RemoteServiceError of RemoteServiceError
  ...

Это будет не только документацией, но и сделает модель ошибок расширяемой. Мы не можем сразу определить все ошибки, которые могут возникнуть, поэтому нам надо подумать о добавлении новых в будущем. Юнион отлично для этого подходит.

Пишем цепочки из функций, возвращающих Result

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

Функция как бы раздваивает путь выполнения программы
Функция как бы раздваивает путь выполнения программы

Хочется сделать так, чтобы если «поезд свернул» на путь с ошибкой, то дальше он только и шёл по этому пути:

Хочется, чтобы был «проезд» по второму пути
Хочется, чтобы был «проезд» по второму пути

Но сопоставить две Result-функции просто так мы не сможем, потому что у них отличаются формы входов и выходов:

Выходы и входы двух таких функций не совпадают
Выходы и входы двух таких функций не совпадают

Нам хочется получить такие адаптеры, которые бы корректировали «форму входов» Result-функций, чтобы их можно было соединять в цепочки:

В любой момент управление программы может «свернуть» на путь с ошибкой и будет идти до конца пайплайна уже по нему
В любой момент управление программы может «свернуть» на путь с ошибкой и будет идти до конца пайплайна уже по нему

Один из таких адаптеров — это bind:

let bind switchFn twoTrackInput =
  match twoTrackInput with
  | Ok success -> switchFn success
  | Error failure -> Error failure

Далее для работы с результатом мы можем использовать map. Он будет принимать функцию, которую выполнит на результате, если ошибки не было. В случае ошибки просто вернёт саму ошибку, не применяя функцию:

let map f aResult =
  match aResult with
  | Ok success -> Ok (f success)
  | Error failure -> Error failure

Я оставил за скобками пару разделов, которые показывают на примерах, как использовать bind, map и mapError, а также конструкции из F# типа let!, result {…}, чтобы писать более читабельный код. Думаю, их лучше прочесть самостоятельно, поглядывая в репозиторий с кодом, который идёт вместе с книжкой.

Монады и всякое такое прочее

Монада — это паттерн, который позволяет соединять монадические функции в цепочки. А монадическая функция — это функция, которая возвращает некое «усовершенствованное» значение.

Технически «монада» — это термин для сущности, у которой есть:

  • структура данных;
  • некоторые связанные с ней функции;
  • правила работы этих функций.

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

Глава 11. Сериализация

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

Хранение и сериализация

Хранение (persistence) — это способность состояния переживать по времени процесс, который его породил. Сериализация (serialization) — это процесс превращения специфических домену структур в формат, который легко хранить (JSON, XML и т. д.).

Проектирование под сериализацию

Чтобы сериализация была безболезненной, нам надо конвертировать доменные объекты в DTO, а уже их — сериализовать. При десериализации — делать наоборот.

При сериализации мы преобразуем доменный объект в DTO, а потом преобразуем в другой формат
При сериализации мы преобразуем доменный объект в DTO, а потом преобразуем в другой формат
При десериализации мы получаем данные извне контекста, валидируем их и создаём доменный объект с уже проверенными данными
При десериализации мы получаем данные извне контекста, валидируем их и создаём доменный объект с уже проверенными данными

Пример сериализации

Допустим, мы хотим научиться сериализовывать тип Person:

module Domain =
  // Допустим, ограничение на 50 символов:
  type String50 = String50 of string

  // Допустим, ограничение снизу на 1/1/1900 и сверху на сегодняшнюю дату:
  type Birthdate = Birthdate of DateTime

  // Описание доменного типа:
  type Person = {
    First: String50
    Last: String50
    Birthdate : Birthdate
  }

Далее объявим тип для DTO и функции, которые будут конвертировать домен в DTO и обратно:

// Тип DTO с примитивами, без ограничений:
module Dto =
  type Person = {
    First: string
    Last: string
    Birthdate : DateTime
  }

// Модуль для конвертации между DTO и доменным объектом:
module Person =
  let fromDomain (person:Domain.Person) :Dto.Person =
      // Получаем примитивы из доменного типа:
    let first = person.First |> String50.value
    let last = person.Last |> String50.value
    let birthdate = person.Birthdate |> Birthdate.value

    // Составляем DTO:
    {First = first; Last = last; Birthdate = birthdate}

  let toDomain (dto:Dto.Person) :Result<Domain.Person,string> =
    result {
      // Валидируем и получаем типизированные значения:
      let! first = dto.First |> String50.create "First"
      let! last = dto.Last |> String50.create "Last"
      let! birthdate = dto.Birthdate |> Birthdate.create

      // Создаём доменный объект:
      return {
        First = first
        Last = last
        Birthdate = birthdate
      }
    }

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

Как правильно переводить типы в DTO

Есть несколько рекомендаций по «переводу» типов в DTO:

  • Простые типы и алиасы можно сохранять в виде примитивов, которые они представляют;
  • Необязательные значения можно заменять на null, если их нет;
  • Коллекции — как массивы, мапы и другие сложные структуры — как ключ-значение;
  • Рекорды — как объекты, рекурсивно применяя эти правила к каждому полю;
  • Юнионы, которые использованы как enum — в виде чисел-значений этих enum;
  • Кортежей в домене лучше избегать, но если они есть, то лучше сделать для них специальный рекорд.

Глава 12. Хранение

Мы спроектировали приложение таким, чтобы ему было неважно, как его данные будут хранить (persistence ignorance). Но хранить их всё-таки придётся, поэтому поговорим и об этом.

Двигаем хранение к краям процесса

Мы хотим, чтобы доменная логика была чистой, поэтому помещаем её в центре процесса, а всё, что имеет побочные эффекты — по краям. Допустим, мы хотим реализовать логику для оплаты инвойса, где нам надо:

  • загрузить инвойс из базы данных;
  • провести оплату;
  • если оплата прошла, пометить инвойс как оплаченный в БД;
  • если нет, то отметить это.

Функцию оплаты лучше сделать чистой, а всё, что связано с чтением и записью в БД — отделить.

--- I/O---
Load invoice from DB

--- Pure ---
Do payment logic

--- I/O ---
Pattern match on output choice type:
  if "FullyPaid" -> Mark invoice as paid in DB
  if "PartiallyPaid" -> Save updated invoice to DB

--- I/O ---
Load all amounts from unpaid invoices in DB

--- Pure ---
Add the amounts up and decide if amount is too large

--- I/O ---
Pattern match on output choice type:
  If "OverdueWarningNeeded" -> Send message to customer
  If "NoActionNeeded" -> do nothing

Разделение на команды и запросы

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

Хранилище возвращает копию себя после каждого «изменения»
Хранилище возвращает копию себя после каждого «изменения»

С типах мы бы могли выразить это так:

type InsertData = DataStoreState -> Data -> NewDataStoreState
type ReadData = DataStoreState -> Query -> Data
type UpdateData = DataStoreState -> Data -> NewDataStoreState
type DeleteData = DataStoreState -> Key -> NewDataStoreState

Здесь видно, что одна из сигнатур отличается: ReadData возвращает данные, а все остальные — новое состояние хранилища. То есть ReadData состояние не изменяет.

На этом строится принцип разделения на команды и запросы — CQS, Command-Query Separation:

  • функции, которые возвращают данные не должны иметь побочных эффектов;
  • а функции с побочными эффектами не должны возвращать данных.

Этот принцип приводит к следующему — CQRS, Command-Query Responsibility Segregation, который говорит, что модели для записи и чтения данных лучше хранить отдельно. Дело в том, что объект, например, пользователя, который требуется для записи может (и скорее всего будет) отличаться от объекта, который возвращается при чтении. Поэтому лучше разделить эти модели в разные модули, чтобы они могли эволюционировать независимо.

Делаем хранилища для разных контекстов независимыми

Есть ещё пара рекомендаций, как облегчить хранение:

  • контексты должны иметь возможность менять свои схемы данных, не затрагивая других контекстов;
  • никто снаружи не должен иметь возможности прочесть данные из хранилища какого-либо контекста.

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

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

Остальные главы

В 13-й главе автор рассказывает и приводит примеры того, как держать код чистым при развитии приложения. Что делать, если появляются новые требования, как быть, когда старый дизайн надо поменять и т. д.

Я не стал конспектировать её, потому что пришлось бы скопировать всю книгу 😃
Рекомендую прочитать эту главу (да и книгу полностью) самим.

Заключение

Мне было забавно, что часть принципов из этой книги я когда-то придумал для себя сам. Частичным применением для «управления зависимостями» и проектированием в типах с отдельными типами для разных этапов жизненного цикла данных я пользовался, когда переписывал Тяжеловато.

Функциональный пайплайн и разделение по фичам я использовал на прошлой работе даже несмотря на то, что проект был построен в парадигме ООП. Непредставимость невалидных данных, конечно, спроектировать в TS сложнее, потому что JS-рантайм дышит в спину, но всё же, идея как-то сидела в голове. Какими-то идеями я даже пользовался, когда последний раз блог переписывал.

Идеи сами по себе доказали свою работоспособность, Но теперь у меня есть ещё и авторитетный источник, на который я могу ссылаться при случае 😃

Новое в книге для меня было в основном связано с DDD и начальными этапами проектирования. Было неочевидно, что слишком абстрактные типы могут навредить в начале работы. Ну и читать код на F# тоже было интересно.

В общем, книжка отличная — рекомендую.

Ссылочки-ссылочки-ссылочки

Понятия из ФП и CS

Языки программирования

CQS, CQRS и разница между ними

Другие понятия из CS

Из блогов классных разработчиков

Из моего блога

Предыдущий пост: Доменное моделирование в функциональном стиле. Часть 2