Саша БеспоясовФотография автора

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

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

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

Глава 4. Понимание типов

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

Понимание функций

У каждой функции есть сигнатура — описание её поведения в виде типов. В большей части случаев F# сможет определить типы самостоятельно. Например:

let add1 x = x + 1   // Сигнатура: int -> int
let add x y = x + y  // Сигнатура: int -> int -> int

Если функция может работать с разными типами, то она называется дженериком (generic function). Дженерик-типы в сигнатуре начинаются в кавычки:

// areEqual : 'a -> 'a -> bool
let areEqual x y =
  (x = y)

В TypeScript это бы выглядело так:

function areEqual<T>(a: T, b: T): boolean {
  return a === b;
}

Типы и функции

Тип — это имя для некоторого набора возможных значений.

Set of                              Set of
valid      →     Function     →     valid
inputs                              outputs

Мы можем обозначить такой тип как преобразование входных данных к выходным:

input -> output

Например, если функция принимает на вход число от -32768 до 32767, то она принимает на вход тип int16. А если возвращает какую-то строку, как например, "abc", "cool", то она возвращает string. Мы можем записать такую функцию и её сигнатуру так:

Inputs                    Function                Outputs
-32768, -32767,                                   "abc"
…, -1, 0, 1, …,     →     int -> string     →     "cool"
32766, 32767                                      "something"

Типы не обязательно должны содержать примитивы. Они могут отражать и «сложные вещи»:

Inputs             Function                  Outputs
😊 😃 😄     →     Person -> Fruit     →     🍉 🍎 🍌

Функции — тоже «вещи», поэтому мы можем использовать наборы функций, как типы тоже! В сигнатуре такой тип будет заключён в скобки:

Inputs            Function                              Outputs
"abc"                                                   😊 → 🍉
"cool"      →     string -> (Person -> Fruit)     →     😃 → 🍎
"yeah"                                                  😄 → 🍌

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

Композиция типов

Композиция — создание чего-то из чего-то поменьше. Она применима и к типам тоже. Типы можно компоновать с помощью логического «И» и логического «ИЛИ».

Например, если мы описываем тип для фруктового салата, где нам нужны бананы, яблоки и вишни, то мы напишем рекорд-тип (record type):

type FruitSalad = {
  Apple: AppleVariety
  Banana: BananaVariety
  Cherries: CherryVariety
}

А если мы описываем закуску, в которой можно выбрать яблоко, банан или вишню, то опишем его как юнион-тип (discriminated union):

type FruitSnack =
  | Apple of AppleVariety
  | Banana of BananaVariety
  | Cherries of CherryVariety

Это и есть простейшая композиция типов. Система, в которой сложные типы сложены из простых с помощью операций И и ИЛИ называется алгебраической системой типов.

Строим доменную модель с помощью композиции типов

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

type CheckNumber = CheckNumber of int
type CardNumber = CardNumber of string

Затем распишем в виде юниона виды принимаемых карт, а в виде рекорда — всю информацию о карте:

type CardType = Visa | Mastercard
type CreditCardInfo = {
  CardType: CardType
  CardNumber: CardNumber
}

Если магазин принимает несколько методов оплаты, то мы снова можем использовать юнион:

type PaymentMethod =
  | Cash
  | Check of CheckNumber
  | Card of CreditCardInfo

Сумму и валюту тоже можем описать базовыми типами:

type PaymentAmount = PaymentAmount of decimal
type Currency = EUR | USD

Ну а тип оплаты полностью может выглядеть так:

type Payment = {
  Amount: PaymentAmount
  Currency: Currency
  Method: PaymentMethod
}

Более того мы можем описать и то, как будет происходить оплата или как конвертировать валюты — в виде типов функций:

type PayInvoice = UnpaidInvoice -> Payment -> PaidInvoice
type ConvertPaymentCurrency = Payment -> Currency -> Payment

Моделирование необязательных значений, ошибок и коллекций

Для работы с необязательными значениями в F# используется Option<'a>, для работы с ошибками — Result<'Success,'Failure>, вместо void используется unit, а для работы с коллекциями несколько типов. Для обозначения коллекций автор предлагает всегда использовать list при моделировании. Реализация может отличаться, но в модели проще использовать именно его.

О тонкостях F# лучше прочесть напрямую в книге или в руководстве по языку.

Глава 5. Моделирование домена в типах

В этой главе мы состряпаем доменную модель, используя систему типов так, что её смогут читать не только «программисты на фарше», но и доменные эксперты.

Замечать паттерны в модели

Каждая область знаний уникальна, но кое-что всё же будет повторяться от проекта к проекту. Например:

  • Простые значения — основные строительные блоки, чаще всего будут определены как примитивы в обёртках;
  • Группы значений, объединённые через «И» — в них данные тесно связаны друг с другом;
  • Группы значений, объединённые через «ИЛИ» — представляют какой-то выбор;
  • Процессы — типы с входными и выходными значениями.

Моделирование простых значений

Эксперты не думают о простых значениях, как о «строках» или «числах». Они думают о «кодах товаров», «количестве» и «ценах». Это значит две вещи:

  • в домене примитивы всегда будут как-то ограничены (у чисел будут валидные диапазоны значений, у строк — формат и т. д.)
  • разные типы значений не взаимозаменяемы — int в количестве товара отличается от int в коде товара.

В F# есть нативный способ объявить такие типы, чтобы они были разными:

type CustomerId = CustomerId of int
type OrderId = OrderId of int

Тогда передать OrderId в функцию, которая ожидает CustomerId, не получится:

let processCustomerId (id:CustomerId) = ...

// Если вызвать с OrderId будет ошибка:
processCustomerId orderId
//                ^ This expression was expected to have type
//                'CustomerId' but here has type 'OrderId'

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

Моделирование сложных значений

Часть тесно связанных данных мы можем представить в виде рекорд-типов:

type Order = {
  CustomerInfo : CustomerInfo
  ShippingAddress : ShippingAddress
  BillingAddress : BillingAddress
  OrderLines : OrderLine list
  AmountToBill : // ...
}

На первых этапах может быть непонятно, какие из типов одинаковые, а какие — нет. Это стоит уточнить у экспертов. Если они говорят о ShippingAddress и BillingAddress как о разных вещах, то лучше сделать их разными типами. Они могут развиваться в разных направлениях, и разделять типы будет сложнее, чем сложить в один.

В начале проектирования может также не хватать знаний об ограничениях или структуре каких-то типов. Это не проблема, сейчас можно заменить неизвестные типы на явно неизвестные (explicitly undefined). Это позволит продолжить проектирование, не отвлекаясь на ошибки компилятора. (Понятно, что после уточнения ограничений неизвестные структуры надо будет обновить.)

type Undefined = exn

type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type OrderLine = Undefined
type BillingAmount = Undefined

Данные, которые предоставляют выбор, мы можем представить в виде юнион-типов:

type OrderQuantity =
  | Unit of UnitQuantity
  | Kilogram of KilogramQuantity

Моделирование процессов как функций

Юнион- и рекорд-типы играют роль существительных в общем языке. Роль глаголов будут играть функциональные типы (function types). Например, процесс валидации заказа мы бы могли отобразить как:

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

Если процесс возвращает несколько событий, то мы можем использовать рекорд-тип, чтобы показать это:

type PlaceOrderEvents = {
  AcknowledgmentSent: AcknowledgmentSent
  OrderPlaced: OrderPlaced
  BillableOrderPlaced: BillableOrderPlaced
}

type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents

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

type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder

// Или:

type CalculatePricesInput = {
  OrderForm: OrderForm
  ProductCatalog: ProductCatalog
}

type CalculatePrices = CalculatePricesInput -> PricedOrder

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

Для описания эффектов мы можем пользоваться типами Result, Async и своими обёртками над ними:

type ValidationResponse<'a> = Async<Result<'a,ValidationError list>>
type ValidateOrder = UnvalidatedOrder -> ValidationResponse<ValidatedOrder>

Вопрос идентичности

В DDD принято делить вещи на «сущности» (entities) и «объекты значения» (value objects). У первых есть уникальная идентичность, вторые же — одинаковы, если у них одинаковое содержимое.

Например, если мы говорим об именах людей, то два разных человека могут иметь одинаковые имена. Имя тогда может представлять собой value object. В F# два рекорд-значения одного типа одинаковы, если значения их полей одинаковы. Это называется структурной идентичностью (structural identity):

let name1 = {FirstName="Alex"; LastName="Adams"}
let name2 = {FirstName="Alex"; LastName="Adams"}
printfn "%b" (name1 = name2)  // "true"

Но иногда нам надо смоделировать вещи, которые остаются теми же, даже если их содержимое меняется. Например, человек, переехав на новый адрес, всё ещё остаётся тем же человеком. Такие вещи называются сущностями.

Сущностям нужен идентификатор, который позволит отличать одну сущности от другой. Иногда такие идентификаторы предоставляет предметная область (серийный номер, номер соц. страхования и т. д.), иногда их приходится создавать самостоятельно (GUID, UUID).

Хранить идентификаторы удобнее «внутри» самой сущности, чем «снаружи», потому что это делает удобнее паттерн-матчинг.

// ID снаружи:

type UnpaidInvoiceInfo = ...
type PaidInvoiceInfo = ...
type InvoiceInfo =
  | Unpaid of UnpaidInvoiceInfo
  | Paid of PaidInvoiceInfo

type InvoiceId = ...
type Invoice = {
  InvoiceId : InvoiceId
  InvoiceInfo : InvoiceInfo
}


// ID внутри:

type UnpaidInvoice = {
	InvoiceId : InvoiceId
  // …
}

type PaidInvoice = {
  InvoiceId : InvoiceId
  // …
}

type Invoice =
  | Unpaid of UnpaidInvoice
  | Paid of PaidInvoice

Для value objects обязательна иммутабельность — потому что при изменении какого-то поля, объект становится другим, то есть он не может «просто поменяться». Сущности меняться могут, но не будем их просто «менять», вместо этого мы будем создавать копии с изменениями, сохраняя идентификатор. Это делает все изменения в сущности явными:

// Сразу видно, что функция поменяет сущность Person:
type UpdateName = Person -> Name -> Person

Совокупности (агрегаты)

Подумаем над таким вопросом: если пользователь поменяет пункт заказа — должен ли поменяться весь заказ? Ответ: да, должен, по трём причинам:

  • чтобы сохранить консистентность данных;
  • чтобы не нарушать инвариантность;
  • иммутабельность делает это необходимым ¯\_(ツ)_/¯

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

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

Неизменяемость на самом деле запускает цепочку изменений от внутренних структур до «корневой», то есть от товарного пункта до заказа, которому он принадлежит. В терминах DDD список товаров будет называться совокупностью (агрегатом, aggregate), а заказ — корнем (aggregate root).

Агрегаты — это отдельные, независимые единицы хранения информации (units of persistence). Нам стоит проводить границы так, чтобы иммутабельность не запускала лишних обновлений структур. Например, если в заказе есть ссылка на пользователя, который сделал этот заказ, как лучше сослаться на него: используя всего пользователя или только его ID?

type Order = {
  OrderId: OrderId
  Customer: Customer
  OrderLines: OrderLine list
  // …
}

// Или:

type Order = {
  OrderId: OrderId
  CustomerId: CustomerId
  OrderLines: OrderLine list
  // …
}

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

Глава 6. Целостность и согласованность домена

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

Целостность простых значений

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

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

type UnitQuantity = private UnitQuantity of int

module UnitQuantity =
  let create qty =
    if qty < 1 then
      Error "UnitQuantity can not be negative"
    else if qty > 1000 then
      Error "UnitQuantity can not be more than 1000"
    else
      Ok (UnitQuantity qty)

	// Чтобы можно было паттерн-матчить:
	let value (UnitQuantity qty) = qty

// А использовать так:
let unitQtyResult = UnitQuantity.create 1

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

type UnitQuantity = number;

function createQuantity(raw: number): UnitQuantity {
  if (raw < 1) throw new Error("UnitQuantity can not be negative");
  if (raw > 1000) throw new Error("UnitQuantity can not be more than 1000");
  return raw as UnitQuantity;
}

Единицы измерения

В F# можно использовать единицы измерения, чтобы тегать типы:

[<Measure>]
type kg

[<Measure>]
type m

type KilogramQuantity = KilogramQuantity of decimal<kg>

В TypeScript тоже при желании можно научиться тегать типы, но опять же это будет не так надёжно и удобно.

Отображаем бизнес-требования через систему типов

Одно из главных правил моделирования домена звучит как:

Сделайте невалидные состояния данных невозможными, непредставимыми

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

Проще всего понять это на примере. Допустим, мы хотим написать модуль для подтверждения и восстановления аккаунтов через почту. У нас есть два варианта пользовательских почт:

  • подтверждённые — на такие адреса уже высылали ссылку для подтверждения, и пользователи перешли по ней;
  • и неподтверждённые.

На подтверждённые адреса мы хотим отправлять ссылки на восстановление аккаунта и не хотим отправлять ссылки на подтверждение почты. И наоборот — мы не хотим отправлять ссылки на восстановление на неподтверждённые адреса, а только — ссылки на подтверждение почты.

Одним из вариантов решения мог бы быть такой тип:

type CustomerEmail = {
  EmailAddress: EmailAddress
  IsVerified: bool
}

Но флаг IsVerified — это очень ненадёжное решение. Главным образом потому что из описания неясно, когда он должен быть включён, а когда — выключен. Но также потому что правила для его переключения будут находиться в рантайме, а значит, их можно пропустить, забыть, не заметить и написать код, который приведёт данные в невалидное состояние.

Вместо этого лучше описать ограничение прямо в типе:

type VerifiedEmailAddress = private VerifiedEmailAddress of EmailAddress
type CustomerEmail =
  | Unverified of EmailAddress
  | Verified of VerifiedEmailAddress

Теперь видно, что «просто так» создать подтверждённый адрес почты — нельзя. Если мы создаём новый адрес, то он будет по умолчанию неподтверждённый. Такая система типов может заменить часть рантайм юнит-тестов. (Ну... по крайней мере в F#.)

Согласованность

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

Согласованность внутри одного агрегата обеспечить проще всего, достаточно посчитать все зависимые данные из исходного источника. (Как в примере с общей суммой — достаточно посчитать её из списка товаров.) Если дополнительные данные надо сохранять, то тогда, конечно, перед сохранением следует дополнительно убедиться, что данные согласованы.

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

/// Передаём 3 параметра:
/// * верхнеуровневый заказ;
/// * ID пункта заказа, который надо поменять;
/// * новая цена.
let changeOrderLinePrice order orderLineId newPrice =

  // Найти нужный пункт среди order.OrderLines с помощью orderLineId:
  let orderLine = order.OrderLines |> findOrderLine orderLineId

  // Сделать копию OrderLine с изменённой ценой:
  let newOrderLine = {orderLine with Price = newPrice}

  // Создать новый список пунктов, заменив старый пункт новым
  let newOrderLines =
    order.OrderLines |> replaceOrderLine orderLineId newOrderLine

  // Пересчитать AmountToBill
  let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price)

  // Создать копию заказа с обновлёнными данными
  let newOrder = {
      order with
        OrderLines = newOrderLines
        AmountToBill = newAmountToBill
      }

  // Вернуть новый заказ:
  newOrder

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

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

Если сообщение теряется, то есть три стула:

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

Если мгновенная согласованность — это требование, то можно посмотреть в сторону 2-фазного комита (2 Phase Commit) и прочих занимательных вещей.

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

Иногда, правда, нужно обновить два агрегата в одной транзакции — например, перевести деньги с одного банковского аккаунта на другой. Но чаще всего это можно передизайнить, чтобы из:

Start transaction
  Add X amount to accountA
  Remove X amount from accountB
  Commit transaction

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

type MoneyTransfer = {
  Id: MoneyTransferId
  ToAccount: AccountId
  FromAccount: AccountId
  Amount: Money
}

Глава 7. Моделируем процессы как пайплайны

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

  • Валидация
  • Оценка
  • Подтверждение
  • Генерация выходных событий

Мы представим эти шаги как части большого пайплайна — процесса в целом. Каждый шаг будет как-то трансформировать входные данные. Мы постараемся сделать каждый шаг без состояния и побочных эффектов (stateless).

Входные данные процесса

Входными данными должен быть доменный объект. Мы посчитаем, что такие объекты мы достаём из десериализованных DTO и будем просто держать это в уме.

type UnvalidatedOrder = {
  OrderId: string
  CustomerInfo: UnvalidatedCustomerInfo
  ShippingAddress: UnvalidatedAddress
  // …
}

Но мы помним, что по-настоящему процесс запускает не сам объект, а команда. Как правило, в команды мы часто добавляем дополнительные данные типа пользователя или таймштампа. В нашем случае мы можем представить это так:

type PlaceOrder = {
  OrderForm: UnvalidatedOrder
  Timestamp: DateTime
  UserId: string
  // …
}

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

type Command<'data> = {
  Data: 'data
  Timestamp: DateTime
  UserId: string
  // …
}

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

type PlaceOrder = Command<UnvalidatedOrder>

Моделирование заказа как набора состояний

Из интервью с экспертами нам становится понятно, что «Заказ» (Order) — это не статический документ, а скорее набор данных, которые проходят через разные трансформации, то есть пребывают в разных состояниях.

Смоделировать все эти состояния можно разными путями. Наивный путь — это просто создать кучу флагов для разных состояний:

type Order = {
  OrderId: OrderId
  // …
  IsValidated: bool
  IsPriced: bool
}

Но у него куча минусов:

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

Другой способ — использовать отдельные типы для каждого состояния.

type Order =
  | Unvalidated of UnvalidatedOrder
  | Validated of ValidatedOrder
  | Priced of PricedOrder
  // …

Плюс такого подхода в том, что можно добавить ещё одно состояние без особых усилий. Достаточно добавить новый тип в юнион.

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

Автоматы (State Machines)

Юнион из примера похож на автоматы — модель переходов между разными состояниями. Автоматное программирование на самом деле вещь хорошая, потому что оно побуждает:

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

Моделирование шагов процесса с помощью типов

Первым делом опишем валидацию. Мы помним, что она принимает невалидированный заказ, а также ссылается на две «зависимости». Эти зависимости мы тоже можем сперва описать типами, которые станут «интерфейсом» для реализации позже. Тогда получится нечто вроде:

type CheckProductCodeExists = ProductCode -> bool

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

Весь шаг валидации тогда мы опишем, как такую сигнатуру:

type ValidateOrder =
  CheckProductCodeExists    // dependency
    -> CheckAddressExists   // dependency
    -> UnvalidatedOrder     // input
    -> Result<ValidatedOrder,ValidationError>  // output

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

Описание остальных шагов я предлагаю вам прочесть в оригинале, здесь же мы перейдём сразу к возвращаемым событиям :–)

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

type OrderPlaced = PricedOrder
type BillableOrderPlaced = {
  OrderId: OrderId
  BillingAddress: Address
  AmountToBill: BillingAmount
}

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

type PlaceOrderEvent =
  | OrderPlaced of OrderPlaced
  | BillableOrderPlaced of BillableOrderPlaced
  | AcknowledgmentSent  of OrderAcknowledgmentSent

type CreateEvents = PricedOrder -> PlaceOrderEvent list

Так мы сделаем код расширяемым, при появлении нового события нам не придётся менять весь код процесса.

Документируем эффекты

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

type AsyncResult<'success,'failure> = Async<Result<'success,'failure>>
type CheckAddressExists =
  UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>

Async, как и Result, заражает то, к чему касается, поэтому тип валидатора нам тоже придётся поменять:

type ValidateOrder =
  CheckProductCodeExists    // dependency
    -> CheckAddressExists   // AsyncResult dependency
    -> UnvalidatedOrder     // input
    -> AsyncResult<ValidatedOrder,ValidationError list>  // output

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

Может возникнуть вопрос, а надо ли показывать зависимости в типах? Правильного ответа нет, но автор предлагает следовать таким правилам:

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

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

В других частях

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

Список литературы

Другие части конспекта

Виды и системы типов

Техники программирования и костыли для TypeScript

Терминология из Википедии

Кое-что из моего блога по теме

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