Доменное моделирование в функциональном стиле. Скотт Влашин

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

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

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

Прежде, чем мы начнём

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

В любом случае, если перевод вам не заходит, то вы можете:

Ну а теперь, к конспекту.

О чём книга

Во введении автор пишет:

Есть мнение, что функциональное программирование — это всегда о математических абстракциях и нечитаемом коде. …Но ФП можно использовать для моделирования домена, код которого будет понятным и лаконичным

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

Книжку стоит прочесть, говорит автор, если:

  • Вам интересно, как можно моделировать, используя только типы и функции;
  • Вам интересно, почему Domain Drive Design и ФП — идеально подходят друг для друга;
  • Вы хотели изучить ФП, но вас отталкивала куча математики;
  • Вы хотели посмотреть на F#

Автор приводит примеры кода на языке F#. В конспекте я перевёл некоторые примеры на TypeScript, чтобы большему количеству читателей они были понятны. Перевести получилось не всё, потому что в F# есть фичи, которых нет в TypeScript. Поэтому я рекомендую прочесть книгу самостоятельно или хотя бы глянуть репозиторий с кодом из этой книги.

Сама книга разделена на 3 части:

  • Понятие домена
  • Моделирование
  • Реализация

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

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

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

Глава 1. Введение в DDD

Автор начинает главу с утверждения, что команде разработки нужно некое «общее понимание» предметной области (shared model), с которой связано приложение.

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

Вместо этого автор предлагает использовать общее для всех понятие предметной области. Если понятия, которым выражаются аналитики и стейкхолдеры, выражены в коде в том же виде, то «потерь при переводе» будет меньше. Это даёт несколько преимуществ:

  • Быстрая разработка и публикация в продакшен: чем меньше расхождений в понимании задач приложения, тем быстрее появится более подходящее решение;
  • Меньше мусора: разработка потратит меньше времени на прототипирование и неудачные решения;
  • Более простая поддержка: вносить изменения станет проще и безопаснее, новые разработчики быстрее сориентируются в коде.

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

  • Фокусироваться на бизнес-событиях и процессах, а не на структурах данных;
  • Разбивать проблемные домены на домены поменьше (поддомены);
  • Разработать «общий язык» (ubiquitous language), описывающий понятия предметной области, которым будут пользоваться все, кто связан с приложением.

Понимаем домен через бизнес-события

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

Изменения данных случаются не сами, их что-то запускает. Эти триггеры автор называет доменными событиями (domain events). События — это стартовая точка всех процессов, которые мы собираемся моделировать в приложении. Автор предлагает записывать события в прошедшем времени — «Что-то случилось».

Чтобы определить доменные события, можно пользоваться событийным штурмом (event storming). В нём участвуют все, кто понимает, как работают разные части бизнеса. Во время штурма участники называют и записывают, что должно случиться, чтобы какой-то процесс начался. Далее записывают, что случается по ходу и после. Часто события будут складываться с цепочки, это нам пригодится в будущем.

В книге примером будет некая «система приёма заказов». Вот как примерно может выглядеть первый событийный штурм:

— В нашем отделе мы обрабатываем заявки.
— Что запускает подобную работу в вашем отделе?
— Мы получаем заявку от клиентов по почте.
— Значит, мы можем записать событие «Заявка получена».
— Мы выполняем эти заказы, когда они подписаны.
— В какой момент это происходит?
— Когда мы получаем заказ от отдела приёма заказов.
— Можем назвать это событие «Заказ составлен».
…и т. д.

Спустя какое-то время мы получим список доменных событий:

  • Заявка получена;
  • Заказ составлен;
  • Заказ отправлен;
  • Новый пользователь зарегистрирован…

Некоторые события «тянут» за собой рабочие процессы (workflows) типа «Разместить заказ», «Отправить заказ» и т. д. Чем больше событий мы видим, тем в более крупные процессы они начнут складываться.

Такой штурм хорош тем, что:

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

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

Когда события собраны, участники думают, какие действия к ним привели. Эти действия автор называет командами (commands). В книге они записываются в виде повелительного глагола: «Сделай это». Как правило, результат команды — это событие:

  • Отправь заказ → Заказ отправлен;
  • Зарегистрируй пользователя → Пользователь зарегистрирован.

Команда запускает процесс, который приводит к новым событиям. Эти события могут запускать другие команды

В итоге о процессе мы думаем как о функции с входными и выходными данными.

Разделяем домен на поддомены

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

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

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

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

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

Используем ограниченные контексты

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

Тут появляется различие между «проблемой» (problem space) и «моделью» (solution space). Модель содержит только то, что нам необходимо:

При преобразовании реального мира в модель детали могут потеряться, это нормально
При преобразовании реального мира в модель детали могут потеряться, это нормально

В модели мы отображаем домены и поддомены как ограниченные контексты (bounded contexts) — части общей модели, каждая из которых моделирует один поддомен.

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

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

Не всегда домены и контексты соотносятся 1 к 1. Бывает, что домен разбивают на несколько контекстов или несколько доменов моделируют через один контекст. Зависит от задачи. Но важно, что у каждого контекста есть лишь одна чёткая ответственность.

Выделить контексты непросто, несколько советов для этого:

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

После выделения контекстов используйте карты контекстов — схемы того, как эти контексты взаимодействуют. Здесь важно не указать все детали, а создать верхнеуровневое представление их отношений. Например, модель из примера выше можно отобразить как такую карту:

Карта контекстов в процессе обработки заказов
Карта контекстов в процессе обработки заказов

Некоторые домены более важны для бизнеса и, собственно, приносят деньги — это корневые (core) домены. Те, что помогают работе корневых, называются поддерживающими (supportive). Неуникальные для бизнеса, которые можно зааутсорсить — общие (generic).

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

Создаём общий язык

Общий или повсеместный язык (ubiquitous language) — то, что поможет команде оперировать одинаковыми словами, говоря об одинаковых понятиях.

При проектировании мы создаём и используем именно его. В таком языке мы описываем понятия теми терминами, которые используют доменные эксперты. Если кто-то говорит «Заказ» (Order), то именно так мы должны назвать понятие, о котором идёт речь. И наоборот, в дизайне не должно быть слов, которые не знакомы доменным экспертам: OrderFactory, OrderMapProcessor и прочее.

Глава 2. Изучение домена

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

  • что к нему приводит;
  • какие данные нужны;
  • какие ограниченные контексты будут вовлечены и т. д.

Интервьюируем доменного эксперта

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

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

Также определите, что в рабочем процессе — входные данные. Например, в процессе «Приём заказа» (Place Order) входными данными будет «Форма заказа» (Order Form). Выходными данными всегда будут некоторые события, например, в процессе «Приёма заказа» таким событием может быть событие «Заказ принят» (Order Placed).

Боремся с желанием проектировать под базу данных

Если вы много работали с базами данных, то на этом этапе можете почувствовать желание начать проектировать таблички. Domain Drive Design (DDD) же предлагает принцип второстепенности хранилища (persistence ignorance).

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

Боремся с желанием проектировать классы

Если у вас много опыта в ООП, то вы тоже можете попасть в ловушку, если начнёте проектировать классы в голове. Например, для приложения из примера могло получиться нечто вроде:

Диаграмма классов, которую можно (но не нужно) построить
Диаграмма классов, которую можно (но не нужно) построить

Здесь выделены две похожие сущности Order и Quote, но на схеме есть некий искусственный OrderBase. Проблема в том, что этот OrderBase не существует в реальности. Если хотите проверить — спросите доменного эксперта, что такое OrderBase.

Мораль здесь в том, что:

Нам следует внимательно собирать информацию о домене и не следует примешивать в него технические идеи и детали

Документируем домен

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

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

Процесс «Принять заказ»
	Вызван событием «Получена форма заявки»
	Главные входные данные: Форма заказа
	Неявные входные данные: Каталог товаров
	Выходные данные — событие «Заказ принят»
	Побочные эффекты: Отправлено уведомление о приёмке.

Данные же мы бы описали примерно так:

Заказ =
	Информация о клиенте
	**И** Адрес отправки
	**И** Адрес биллинга
	**И** Пункты заказа
	**И** Сумма

Пункт заказа =
	Товар
	**И** Количество
	**И** Цена

Адрес отправки = пока неизвестно…
Адрес биллинга = пока неизвестно…

Или на английском:

data Order =
  CustomerInfo
  AND ShippingAddress
  AND BillingAddress
  AND list of OrderLines
  AND AmountToBill

ShippingAddress = ???
BillingAddress = ???

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

Погружаемся в процесс приёма заказа

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

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

Или мы можем узнать, что не все товары продаются в штуках, некоторые могут продаваться в килограммах. А значит «Количество товара» (OrderQuantity) должно быть частью общего языка, чтобы под этим понятием все подразумевали одно и то же.

«Это зависит от чего-то…» Это те слова, после которых вам должно стать понятно — всё будет сложно

Отражаем сложность в модели

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

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

context: Order-Taking

data WidgetCode = string starting with "W" then 4 digits
data GizmoCode = string starting with "G" then 3 digits
data ProductCode = WidgetCode OR GizmoCode

Мы используем термины ProductCode, WidgetCode и GizmoCode, потому что их использовали доменные эксперты в разговоре. Но не слишком ли это конкретное описание? Не появятся ли проблемы в будущем из-за недостаточности абстракций?

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

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

data OrderQuantity = UnitQuantity OR KilogramQuantity

data UnitQuantity = integer between 1 and 1000
data KilogramQuantity = decimal between 0.05 and 100.00

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

data UnvalidatedOrder =
  UnvalidatedCustomerInfo
  AND UnvalidatedShippingAddress
  AND UnvalidatedBillingAddress
  AND list of UnvalidatedOrderLine

data UnvalidatedOrderLine =
  UnvalidatedProductCode
  AND UnvalidatedOrderQuantity”

Для провалидированных:

data ValidatedOrder =
  ValidatedCustomerInfo
  AND ValidatedShippingAddress
  AND ValidatedBillingAddress
  AND list of ValidatedOrderLine

data ValidatedOrderLine =
  ValidatedProductCode
  AND ValidatedOrderQuantity”

Для тех, которым посчитали цену:

data PricedOrder =
  ValidatedCustomerInfo
  AND ValidatedShippingAddress
  AND ValidatedBillingAddress
  AND list of PricedOrderLine  // different from ValidatedOrderLine
  AND AmountToBill             // new

data PricedOrderLine =
  ValidatedOrderLine
  AND LinePrice

И результат процесса:

data PlacedOrderAcknowledgment =
  PricedOrder
  AND AcknowledgmentLetter

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

После того, как описаны данные, мы опишем и сам процесс:

workflow "Place Order" =
  input: OrderForm
  output:
    OrderPlaced event
    OR InvalidOrder

  // step 1
  do ValidateOrder
  If order is invalid then:
  stop

  // step 2
  do PriceOrder

  // step 3
  do SendAcknowledgmentToCustomer

  // step 4
  return OrderPlaced event (if no errors)

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

substep "ValidateOrder" =
  input: UnvalidatedOrder
  output: ValidatedOrder OR ValidationError
  dependencies: CheckProductCodeExists, CheckAddressExists

  validate the customer name
  check that the shipping and billing address exist
  for each line:
    check product code syntax
    check that product code exists in ProductCatalog

  if everything is OK, then:
    return ValidatedOrder
  else:
    return ValidationError

И так далее для каждого шага этого процесса и других процессов.

Глава 3. Функциональная архитектура

В этой главе мы посмотрим на архитектуру программы с функционально ориентированным доменом. Мы будем считать, что она состоит из 4 «уровней»:

  • системный контекст (system context) — вся система, которую мы моделируем;
  • он состоит из нескольких контейнеров (containers) — отдельные юниты, которые можно отдельно деплоить;
  • каждый контейнер состоит из компонентов (components) — структурные строительные блоки кода;
  • каждый компонент состоит из модулей (modules) с низкоуровневыми функциями и операциями.

Ограниченные контексты как автономные программные компоненты

Каждый контекст в идеале — это автономная подсистема с чёткими границами. В начале проектирования, однако, нам не важно, как именно мы будем деплоить проект: микросервисно или как монолит. Главное — убедиться, что мы держим контексты расцепленными (decoupled).

Общение между контекстами

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

Данные, с которыми мы работаем внутри контекстов, будем называть доменными объектами (domain objects). Данные, которые мы передаём между контекстами хоть и могут быть похожими на доменные объекты, но ими не являются. Это будут специальные объекты передачи данных — data transfer objects, DTOs. Такие объекты, как правило, будут содержать ту же информацию, но структурированную так, чтобы объект было удобно сериализовать.

Границы каждого контекста будут играть роль «ворот» (gates). Всё, что приходит в контекст снаружи — это DTO, его надо проверить и валидировать. После валидации мы будем получать доменные объекты, с которыми можем работать, как с безопасными данными. Валидацией будет заниматься вход (input gate):

Всё, что приходит снаружи — надо провалидировать, данные внутри считаем безопасными
Всё, что приходит снаружи — надо провалидировать, данные внутри считаем безопасными

Выходные ворота (output gate) будут проверять, чтобы в выходных данных случайно не оказалось лишней или чувствительной информации. Это улучшит безопасность и предотвратит зацепление между контекстами.

Контракты между контекстами

Какими бы расцепленными контексты ни были, общение между ними всё равно создаёт какое-то зацепление. Чтобы это не приносило боли, контекстам стоит выбрать формат сообщений, который они будут использовать при общении — выработать контракт.

Контракты и их выбор бывают разными:

  • shared kernel — два контекста решают использовать некий общий формат сообщений;
  • consumer driven — когда потребитель решает, какой формат сообщения ему требуется, а отправитель подстраивается под этот формат;
  • conformist — когда отправитель выбирает формат, а потребитель подстраивается под него.

При общении с внешними системами можно использовать анти-коррозионные слои (anti-corruption layers, ACL). В них данные преобразуются в такие, которые понятны и требуются нашей системе. Мы как бы подстраиваем внешний мир под себя, а не наоборот.

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

Отношение между разными контекстами может отражать, как взаимодействуют отделы в компании
Отношение между разными контекстами может отражать, как взаимодействуют отделы в компании

Процессы внутри контекстов

В функциональной архитектуре каждый из бизнес-процессов (workflows) — это функция, у которой вход — это команда, а выход — одно или несколько событий. Такие процессы всегда находятся внутри одного контекста и никогда не реализуют End-to-End процессов.

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

data BillableOrderPlaced =
  OrderId
  AND BillingAddress
  AND AmountToBill

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

Структура кода внутри контекста

Центральной частью контекста будет домен, всё остальное будет расположено вокруг него. Зависимости будут направлены внутрь, то есть к домену. Этот подход известен как луковичная архитектура (onion architecture):

Домен находится по центру, а ввод-вывод — по краям
Домен находится по центру, а ввод-вывод — по краям

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

Весь ввод и вывод мы расположим по краям луковицы — в начале и конце процесса. Тогда корневая доменная логика будет отделена от хранилища, API и инфраструктуры. Так мы достигнем второстепенности хранилища (persistence ignorance).

В следующих частях

В этот раз мы поговорили о том, что такое домен и зачем он нужен.

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

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

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

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

DDD и связанные с ним понятия

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

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