Часть 1. Моделируем домен

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

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

Доменная модель

Чтобы написать приложение, нам первым делом надо понять, что оно будет делать — то есть описать набор пользовательских сценариев, задач, которые приложение будет решать, а также различных правил и ограничений его предметной области. В методологии DDD (Domain-Driven Design) такой набор знаний называют доменной моделью или доменом.

Далее мы попробуем спроектировать конвертер и описать его модель в виде типов и функций. Конечный результат и исходники всех примеров из этого поста вы сможете найти на Гитхабе.

Польза моделирования

Главная цель доменной модели — помочь нам справляться со сложностью и хаотичностью настоящего мира. Модель — это такой ограниченный «слепок реальности», который мы можем поместить в голове и выразить в коде.

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

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

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

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

  • Между какими валютами мы будем проводить конвертацию?
  • Какую точность подсчёта мы хотим видеть?
  • Откуда мы берём котировки? Как часто они меняются?
  • Можно ли пользователю менять валюты расчёта?
  • Как мы хотим работать с «отрицательными значениями»?
  • …И так далее.

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

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

Ограниченность и детали

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

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

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

Нюансы моделирования

Стоит отметить, что не у каждого приложения в принципе может быть богатая доменная модель. Если мы пишем простенький CRUD-app, то сложной бизнес-логики в нём не будет. Функциональность такого приложения ограничится запросами к серверу и отображением полученных данных на экране, и выделять модель может быть не нужно.

Ядро приложения

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

Приложение в окружении внешнего мира
Приложение в окружении внешнего мира

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

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

Процессы в виде типов

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

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

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

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

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

Чтобы найти нужный курс, нам надо знать коды базовой и квот-валюты, а также котировки, которые содержат эту пару
Чтобы найти нужный курс, нам надо знать коды базовой и квот-валюты, а также котировки, которые содержат эту пару

Второй процесс — расчёт значения квот-валюты на основе значения базовой валюты. Для него нам нужен курс выбранной пары валют и значение базовой:

Когда мы знаем курс между выбранными валютами, мы можем посчитать значение квот-валюты относительно базовой по этому курсу
Когда мы знаем курс между выбранными валютами, мы можем посчитать значение квот-валюты относительно базовой по этому курсу

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

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

Данные в виде типов

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

// domain/types.ts

type LookupRate = (rate: ExchangeRates, base: CurrencyCode, quote: CurrencyCode) => ExchangeRate;

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

Дело в том, что эти термины мы услышали от продукт-оунера, когда проводили исследование предметной области. Так мы поняли, что именно этими терминами «люди из бизнеса» описывают систему. Язык, который содержит эти термины, в DDD называется повсеместным (ubiquitous), и мы используем в коде именно его.

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

Так как типов ExchangeRates, ExchangeRate и CurrencyCode у нас ещё нет, TypeScript на нас ругнётся. Мы залатаем эту дыру, объявив тайп-алиасы для этих типов:

// domain/types.ts

type CurrencyCode = unknown;
type ExchangeRate = unknown;
type ExchangeRates = unknown;

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

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

// domain/types.ts

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

type CurrencyCode = 'RPC' | 'IMC' | 'WPU' | 'DRG' | 'ZKL';

Исследуя предметную область дальше, мы могли узнать, что курс — это отношение между двумя числовыми значениями, и оно не имеет какой-либо единицы измерения. Тогда мы можем уточнить (сузить) тип ExchangeRate, обозначив, что это дробное число:

// domain/types.ts

type ExchangeRate = Fractional;

// Тип Fractional может быть просто числом, а может быть особым типом,
// который самостоятельно валидирует значения при создании.
// Пока что выразим его просто как число, и вернёмся позже, если потребуется:
type Fractional = number;

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

// domain/types.ts

// Набор курсов между текущей базовой валютой
// и всеми квот-валютами:

type ExchangeRates = Record<CurrencyCode, ExchangeRate>;

Каждое уточнение типов фиксирует больше информации о предметной области в сигнатурах. Это даёт несколько преимуществ:

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

Ограничения домена в виде типов

Задача модели — описать работу предметной области, и важная часть такого описания — это её ограничения. Допустим, мы узнали, что в нашем конвертере базовая валюта будет всегда одна и та же — республиканский кредит (RPC).

Нынешняя модель этого ограничения не учитывает, например, функция LookupRate принимает любую валюту как базовую:

// domain/types.ts

type LookupRate = (
	rate: ExchangeRates,

	// Можно передать любую из 5 доступных валют:
	base: CurrencyCode,
	quote: CurrencyCode
) => ExchangeRate;

С одной стороны — это «обобщение» и «задел на случай», если мы вдруг разрешим выбирать базовую валюту расчёта. Но с другой — так мы теряем важную разницу между базовой и квот-валютой.

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

Различие в возможных значениях говорит нам, что перед нами на самом деле 2 отдельных типа. Мы можем выразить эту разницу, разделив тип CurrencyCode на два отдельных:

// domain/types.ts

type BaseCurrencyCode = 'RPC';
type QuoteCurrencyCode = 'RPC' | 'IMC' | 'WPU' | 'DRG' | 'ZKL';

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

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

// domain/types.ts

type LookupRate = (rate: ExchangeRates, quote: QuoteCurrencyCode) => ExchangeRate;

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

Другие процессы

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

// domain/types.ts

export type CalculateQuote = (base: BaseValue, rate: ExchangeRate) => QuoteValue;

А проверка полученного от пользователя значения и преобразование его в значение базовой валюты может быть выражено так:

// domain/types.ts

export type ToBaseValue = (raw: ValueCandidate) => BaseValue;
type ValueCandidate = number | string;

…И так далее до того момента, когда мы получим более-менее полное описание предметной области.

Валидация и сверка модели

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

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

  1. Получить коды валют и значение базовой из реального мира.
  2. Определить текущий курс выбранной пары.
  3. Рассчитать значение квот валюты по текущему курсу.

Если выразить этот алгоритм в сигнатурах и типах, то мы получим нечто вроде:

// 1. Получить данные из реального мира:
type GetInputData = () => ExchangeRates & QuoteCurrencyCode & BaseValue;

// 2. Определить курс:
type LookupRate = (r: ExchangeRates, q: QuoteCurrencyCode) => ExchangeRate;

// 3. Рассчитать значение:
type CalculateQuote = (b: BaseValue, r: ExchangeRate) => QuoteValue;

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

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

Функциональная реализация модели

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

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

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

Чистые функции

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

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

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

export const createBaseValue: ToBaseValue = (raw) => {
	const candidate = Number(raw);
	return Number.isNaN(candidate) ? 0 : Math.abs(candidate);
};

// Строго говоря, на результат ещё влияют функции `isNaN` и `abs`,
// но для простоты мы подразумеваем, что они тоже чистые.

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

Это даёт нам возможность строить более сложные процессы, используя эти функции как составные части таких процессов:

const rawValue = '42';
const baseValue = createBaseValue(rawValue);
const quoteValue = calculateQuoteValue(currentRate, baseValue);

// Данные проходят через несколько этапов:
// RawValue -> BaseValue -> QuoteValue
// "42"     -> 42        -> 1.46

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

const baseValue = 42;
const quoteValue = calculateQuoteValue(currentRate, baseValue);

// Получим тот же результат, потому что функция `createBaseValue` чистая
// и мы можем заменить её вызов на результат.

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

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

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

Согласованность данных

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

Например, функция createBaseValue гарантирует, что значение на выходе всегда будет числом, большем или равном 0. Это свойство выходного значения — инвариант.

Функция говорит нам, что значения типа BaseValue могут быть только числами в определённом диапазоне [0, Infinity), и все значения «из внешнего мира» должны пройти через этот «фильтр» прежде, чем попасть в доменную модель. Инварианты помогают держать данные внутри модели согласованными, то есть внутренне непротиворечивыми.

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

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

Инкапсуляция

Другим примером контракта и обрамления правил может быть функция lookupRate. Эта функция содержит правило поиска курса между парой выбранных валют:

export const lookupRate: LookupRate = (rates, against) => rates[against];

Эта функция освобождает остальные от необходимости знать, откуда берётся значение курса выбранной пары валют. Остальные функции могут положиться на гарантии (контракт), что результат вызова lookupRate всегда будет правильным и валидным курсом.

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

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

type ExchangeRates = Record<CurrencyCode, ExchangeRate>;
type UpdatedExchangeRate = List<[CurrencyCode, ExchangeRate]>;

// До изменения мы доставали данные одним образом:
const lookupRate = (rates, against) => rates[against];

// После — несколько иначе:
const lookupRate = (rates, against) => rates.find(([code, rate]) => code === against).at(1);

// Но весь код, который полагался на вызов `lookupRate`,
// остался неизменным, потому что изменения ограничены функцией `lookupRate`:
const currentRate = lookupRate(exchangeRates, 'IMC');

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

Естественная тестируемость

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

Например, для проверки работы createBaseValue мы можем написать вот такой тест:

describe('when given a number-like value', () => {
	const expected = 42;
	const cases = [42, -42, '42'];

	it.each(cases)('returns the domain base value', (value) => {
		const result = createBaseValue(value);
		expect(result).toEqual(expected);
	});
});

Этот тест просто понять, легко написать, и он отлично встраивается в парадигму AAA (Arrange, Act, Assert):

describe('when given a number-like value', () => {
	// Arrange:
	const expected = 42;
	const cases = [42, -42, '42'];

	it.each(cases)('returns the domain base value', (value) => {
		// Act:
		const result = createBaseValue(value);

		// Assert:
		expect(result).toEqual(expected);
	});
});

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

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

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

Наконец, чистые функции удобно тестировать не только на happy path, но и на отказные случаи. Например, для createBaseValue мы можем написать такой набор отказных тестов:

describe('when given a non-number value', () => {
	const cases = ['string', '42n', NaN, [], null, undefined, {}];

	it.each(cases)('returns 0 base value', (value) => {
		const result = createBaseValue(value);
		expect(result).toEqual(0);
	});
});

Разработка через тестирование

Ссылочная прозрачность, естественная тестируемость, контракты на поведение и типы данных и процессов предметной области позволяют использовать при моделировании ещё один инструмент — разработку через тестирование, TDD (Test-Driven Development).

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

Я понимаю, что не всем разработчикам нравится работать по TDD, и это нормально; я не собираюсь «продавать» кому-либо эту методологию. Моя цель в этом посте — показать, как использовать TDD в качестве инструмента для проектирования домена, чтобы у вас было больше сведений для принятия решения, использовать ли его.

Попробуем для наглядности написать функцию, реализующую тип CalculateQuote, по TDD. Для начала мы можем описать границы функциональности, которую собираемся реализовать, с помощью .todo():

// Опишем, какую функциональность хотим получить в результате:

describe('when given a base value and the current rate', () => {
	it.todo('returns the correct quote value');
	it.todo('should have a precision of two decimal places');
});

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

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

Когда мы определились с тем, как должна себя вести функция calculateQuote, мы можем написать первый тест. Опишем поведение функции при корректных входных данных:

type TestCase = {
	base: BaseValue;
	rate: ExchangeRate;
	expected: QuoteValue;
};

// ...

it.each<TestCase>([{ base: 10, rate: 1, expected: 10 }])(
	'returns the correct quote value',
	({ base, rate, expected }) => {
		const result = calculateQuote(base, rate);
		expect(result).toEqual(expected);
	}
);

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

Затем мы должны проверить причину, по которой падает тест. Сейчас тест падает, потому что функция calculateQuote не определена. Это совсем не то, чего мы ожидаем в тесте. Поэтому добавим функцию и убедимся, что тест падает по нужной нам причине:

const calculateQuote: CalculateQuote = (base, rate) => 42;

Получим такой отчёт:

19|   ])("returns the correct quote value", ({ base, rate, expected }) => {
20|     const result = calculateQuote(base, rate);
21|     expect(result).toEqual(expected);
  |                    ^
22|   });
23|

- Expected   "10"
+ Received   "42"

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

Далее можем написать настоящую реализацию:

const calculateQuote: CalculateQuote = (base, rate) => base * rate;

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

it.each<TestCase>([
	{ base: 10, rate: 1, expected: 10 },
	{ base: 10, rate: 2, expected: 20 },
	{ base: 10, rate: 4.2, expected: 42 }
]); // ...

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

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

it.each<TestCase>([
	{ base: 10, rate: 1.12, expected: 11.2 },
	{ base: 10, rate: 0.72, expected: 7.2 }
])('should have a precision of two decimal places', ({ base, rate, expected }) =>
	expect(calculateQuote(base, rate)).toEqual(expected)
);

Убеждаемся, что тесты падают, потому что округления нет. После чего пишем реализацию:

const calculateQuote: CalculateQuote = (base, rate) => Number((base * rate).toFixed(2));

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

Композиция функций

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

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

// Данные:
type BaseCurrencyCode = 'RPC';
type QuoteCurrencyCode = 'RPC' | 'IMC' | 'WPU' | 'DRG' | 'ZKL';
type ExchangeRates = Record<QuoteCurrencyCode, ExchangeRate>;

// Преобразования данных:
type LookupRate = (r: ExchangeRates, q: QuoteCurrencyCode) => ExchangeRate;
type CalculateQuote = (b: BaseValue, r: ExchangeRate) => QuoteValue;

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

Доменные процессы становятся последовательными наборами действий, а их результаты мы можем визуализировать (или даже «проиграть в голове») и предсказать, какой вид примут данные в конце:

const rawValue = '42';
const baseValue = createBaseValue(rawValue);
const quoteValue = calculateQuoteValue(currentRate, baseValue);

// RawValue -> BaseValue -> QuoteValue

Такие процессы легко собирать в более сложные функции:

function workflow(rawValue, exchangeRates) {
	const baseValue = createBaseValue(rawValue);
	const currentRate = lookupRate(exchangeRates);
	const quoteValue = calculateQuoteValue(currentRate, baseValue);
	return quoteValue;
}

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

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

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

В следующий раз

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

Ссылки

Все ссылки на книги, статьи и другие материалы, упомянутые в статье.

Книги

Доменное моделирование

Архитектура, контракты, паттерны

TypeScript и статическая типизация

Функциональное программирование

Тестирование

Процессы, методологии и прочее

Другие части серии