Доменное моделирование в функциональном стиле. Часть 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
}
type FruitSalad = {
	apple: AppleVariety;
	banana: BananaVariety;
	cherries: CherryVariety;
};

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

Пример кода на:
type FruitSnack =
  | Apple of AppleVariety
  | Banana of BananaVariety
  | Cherries of CherryVariety
type FruitSnack = AppleVariety | BananaVariety | CherryVariety;

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

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

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

Пример кода на:
type CheckNumber = CheckNumber of int
type CardNumber = CardNumber of string
type CheckNumber = number;
type CardNumber = string;

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

Пример кода на:
type CardType = Visa | Mastercard
type CreditCardInfo = {
  CardType: CardType
  CardNumber: CardNumber
}
type CardType = Visa | Mastercard;
type CreditCardInfo = {
	cardType: CardType;
	cardNumber: CardNumber;
};

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

Пример кода на:
type PaymentMethod =
  | Cash
  | Check of CheckNumber
  | Card of CreditCardInfo
type PaymentMethod = Cash | CheckNumber | CreditCardInfo;

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

Пример кода на:
type PaymentAmount = PaymentAmount of decimal
type Currency = EUR | USD
type PaymentAmount = number;
type Currency = EUR | USD;

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

Пример кода на:
type Payment = {
  Amount: PaymentAmount
  Currency: Currency
  Method: PaymentMethod
}
type Payment = {
	amount: PaymentAmount;
	currency: Currency;
	method: PaymentMethod;
};

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

Пример кода на:
type PayInvoice = UnpaidInvoice -> Payment -> PaidInvoice
type ConvertPaymentCurrency = Payment -> Currency -> Payment
type PayInvoice = (invoice: UnpaidInvoice, payment: Payment) => PaidInvoice;
type ConvertPaymentCurrency = (payment: Payment, currency: Currency) => Payment;

// Притворимся, что наш код по умолчанию умеет воспринимать все функции как каррированные.
// Тогда мы сможем записать сигнатуры чуть ближе к F#:
type PayInvoice = (invoice: UnpaidInvoice) => (payment: Payment) => PaidInvoice;
type ConvertCurrency = (payment: Payment) => (currency: Currency) => Payment;

// Далее я буду приводить примеры кода на TS именно так.
// В F# же все функции каррированы по умолчанию, там дополнительно ничего делать не нужно,
// сигнатуры представляются в таком виде автоматически.

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

Для работы с необязательными значениями в 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 : // ...
}
type Order = {
  customerInfo: CustomerInfo;
  shippingAddress: ShippingAddress;
  billingAddress: BillingAddress;
  orderLines: OrderLine[];
  amountToBill: // ...
};

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

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

Пример кода на:
type Undefined = exn

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

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

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

Пример кода на:
type OrderQuantity =
  | Unit of UnitQuantity
  | Kilogram of KilogramQuantity
type OrderQuantity = Unit | Kilogram;

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

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

Пример кода на:
type ValidateOrder = UnvalidatedOrder -> ValidatedOrder
type ValidateOrder = (order: UnvalidatedOrder) => ValidatedOrder;

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

Пример кода на:
type PlaceOrderEvents = {
  AcknowledgmentSent: AcknowledgmentSent
  OrderPlaced: OrderPlaced
  BillableOrderPlaced: BillableOrderPlaced
}

type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents
type PlaceOrderEvents = {
	acknowledgmentSent: AcknowledgmentSent;
	orderPlaced: OrderPlaced;
	billableOrderPlaced: BillableOrderPlaced;
};

type PlaceOrder = (order: UnvalidatedOrder) => PlaceOrderEvents;

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

Пример кода на:
type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder

// Или:

type CalculatePricesInput = {
  OrderForm: OrderForm
  ProductCatalog: ProductCatalog
}

type CalculatePrices = CalculatePricesInput -> PricedOrder
type CalculatePrices = (form: OrderForm) => (catalog: ProductCatalog) => PricedOrder;

// Или:

type CalculatePricesInput = {
	OrderForm: OrderForm;
	ProductCatalog: ProductCatalog;
};

type CalculatePrices = (input: CalculatePricesInput) => PricedOrder;

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

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

Пример кода на:
type ValidationResponse<'a> = Async<Result<'a,ValidationError list>>
type ValidateOrder = UnvalidatedOrder -> ValidationResponse<ValidatedOrder>
// В TypeScript нет встроенного Result для работы с ошибками,
// поэтому его придётся реализовать самостоятельно.
// Можно сделать нечто вроде:
type Result<TSuccess, TFailure = Error> =
	| { ok: true; value: TSuccess }
	| { ok: false; error: TFailure };

type ValidationResponse<TResponse> = Promise<Result<TResponse, ValidationError[]>>;

type ValidateOrder = (order: 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
// ID снаружи:

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

type InvoiceId = // ...
type Invoice = {
  invoiceId : InvoiceId
  invoiceInfo : InvoiceInfo
}


// ID внутри:

type UnpaidInvoice = {
	invoiceId : InvoiceId
  // …
}

type PaidInvoice = {
  invoiceId : InvoiceId
  // …
}

type Invoice = Unpaid | Paid

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

Пример кода на:
// Сразу видно, что функция поменяет сущность Person:
type UpdateName = Person -> Name -> Person
// Сразу видно, что функция поменяет сущность Person:
type UpdateName = (person: Person) => (name: 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
  // …
}
type Order = {
	orderId: OrderId;
	customer: Customer;
	orderLines: OrderLine[];
	// …
};

// Или:

type Order = {
	orderId: OrderId;
	customerId: CustomerId;
	orderLines: OrderLine[];
	// …
};

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

Глава 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
}
type CustomerEmail = {
	emailAddress: EmailAddress;
	isVerified: boolean;
};

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

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

Пример кода на:
type VerifiedEmailAddress = private VerifiedEmailAddress of EmailAddress
type CustomerEmail =
  | Unverified of EmailAddress
  | Verified of VerifiedEmailAddress
type EmailAddress = string;
type VerifiedEmailAddress = EmailAddress;
type CustomerEmail = EmailAddress | VerifiedEmailAddress;

function createCustomerEmail(raw: string): CustomerEmail {
	// ...Валидация, проверки, приведение строки к нужному виду.
	return email as EmailAddress;
}

Теперь видно, что «просто так» создать подтверждённый адрес почты — нельзя. Если мы создаём новый адрес, то он будет по умолчанию неподтверждённый. Такая система типов может заменить часть рантайм юнит-тестов. (Ну… по крайней мере в 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
/// Передаём 3 параметра:
/// * верхнеуровневый заказ;
/// * ID пункта заказа, который надо поменять;
/// * новая цена.
function changeOrderLinePrice(order: Order, orderLineId: OrderLineId, newPrice: Price): Order {
	// Найти нужный пункт среди order.OrderLines с помощью orderLineId:
	const orderLine = findOrderLine(order.orderLines, orderLineId);

	// Сделать копию OrderLine с изменённой ценой:
	const newOrderLine = { ...orderLine, price: newPrice };

	// Создать новый список пунктов, заменив старый пункт новым
	const newOrderLines = replaceOrderLine(order.orderLines, orderLineId, newOrderLine);

	// Пересчитать AmountToBill
	const newAmountToBill = newOrderLines.reduce((total, line) => total + line.price, 0);

	// Создать копию заказа с обновлёнными данными
	const newOrder = {
		...order,
		orderLines: newOrderLines,
		amountToBill: newAmountToBill
	};

	// Вернуть новый заказ:
	return 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
}
type MoneyTransfer = {
	id: MoneyTransferId;
	toAccount: AccountId;
	fromAccount: AccountId;
	amount: Money;
};

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

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

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

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

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

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

Пример кода на:
type UnvalidatedOrder = {
  OrderId: string
  CustomerInfo: UnvalidatedCustomerInfo
  ShippingAddress: UnvalidatedAddress
  // …
}
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 Command<TPayload> = {
	Data: TPayload;
	Timestamp: DateTime;
	UserId: string;
	// …
};

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

Пример кода на:
type PlaceOrder = Command<UnvalidatedOrder>
type PlaceOrder = Command<UnvalidatedOrder>;

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

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

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

Пример кода на:
type Order = {
  OrderId: OrderId
  // …
  IsValidated: bool
  IsPriced: bool
}
type Order = {
	orderId: OrderId;
	// …
	isValidated: boolean;
	isPriced: boolean;
};

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

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

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

Пример кода на:
type Order =
  | Unvalidated of UnvalidatedOrder
  | Validated of ValidatedOrder
  | Priced of PricedOrder
  // …
type Order = Unvalidated | Validated | Priced;

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

Верхнеуровневый тип 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 CheckProductCodeExists = (code: ProductCode) => boolean;

type CheckedAddress = Address;
type AddressValidationError = string;

type CheckAddressExists = (
	address: UnvalidatedAddress
) => Result<CheckedAddress, AddressValidationError>;

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

Пример кода на:
type ValidateOrder =
  CheckProductCodeExists    // dependency
    -> CheckAddressExists   // dependency
    -> UnvalidatedOrder     // input
    -> Result<ValidatedOrder,ValidationError>  // output
type ValidateOrder = (
	codeChecker: CheckProductCodeExists // dependency
) => (
	addressChecker: CheckAddressExists // dependency
) => (
	order: UnvalidatedOrder // input
) => Result<ValidatedOrder, ValidationError>; // output

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

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

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

Пример кода на:
type OrderPlaced = PricedOrder
type BillableOrderPlaced = {
  OrderId: OrderId
  BillingAddress: Address
  AmountToBill: BillingAmount
}
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 PlaceOrderEvent = OrderPlaced | BillableOrderPlaced | AcknowledgmentSent;

type CreateEvents = (order: PricedOrder) => PlaceOrderEvent[];

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

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

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

Пример кода на:
type AsyncResult<'success,'failure> = Async<Result<'success,'failure>>
type CheckAddressExists =
  UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
type AsyncResult<TSuccess, TFailure> = Promise<Result<TSuccess, TFailure>>;
type CheckAddressExists = (
	address: UnvalidatedAddress
) => AsyncResult<CheckedAddress, AddressValidationError>;

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

Пример кода на:
type ValidateOrder =
  CheckProductCodeExists    // dependency
    -> CheckAddressExists   // AsyncResult dependency
    -> UnvalidatedOrder     // input
    -> AsyncResult<ValidatedOrder,ValidationError list>  // output
type ValidateOrder = (
	codeChecker: CheckProductCodeExists // dependency
) => (
	addressChecker: CheckAddressExists // AsyncResult dependency
) => (
	order: UnvalidatedOrder // input
) => AsyncResult<ValidatedOrder, ValidationError[]>; // output

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

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

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

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

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

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

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

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

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

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

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

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