# Доменные примитивы, чтобы предотвращать ошибки из‑за строк на раннем этапе

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

## Почему простые строки приводят к реальным ошибкам

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

Из‑за этого простые ошибки выглядят нормально. Если функция ожидает user ID, а кто‑то передаёт order ID, код всё равно выполнится. Если в процессе оформления заказа ожидают цену в центах, а получают "19.99" вместо "1999", в типовой системе ничего не помешает этому.

Много багов начинается с таких значений. "user_123" и "order_123" обе выглядят как валидные строки. "19.99" может означать доллары, евро или сырую строку из формы. "paid", "Paid" и "payment_done" могут пытаться означать одно и то же. Даже "true" и "false" превращаются в кашу, когда хранятся как текст.

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

Представьте маленький поток заказа. Одна функция загружает покупателя по ID. Другая получает заказ по ID. Обе принимают строку. Разработчик копирует вызов, забывает поменять переменную и отправляет `orderId` в поиск покупателя. Тестовые данные могут скрыть ошибку, если оба ID имеют одинаковый формат.

Флаги статуса создают ту же проблему. Одна часть приложения проверяет "paid". Другая пишет "completed". Третья использует "1", потому что данные из старой таблицы. Ошибки синтаксиса нет, но заказ попадает в неверное состояние, и поддержка получает тикет.

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

## Что такое доменные примитивы

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

Это кажется мелочью, но меняет поведение кода. Обычная строка может означать что угодно: user ID, order ID, email, код страны или статус. Программа видит их как просто текст и позволяет перепутать их.

```ts
function shipOrder(userId: string, amount: number, status: string) {}
shipOrder("ord_123", 49.99, "paid")
```

В этом вызове ничто не говорит программе, что "ord_123" не тот тип ID, что 49.99 требует указания валюты, или что "paid" может не соответствовать ожидаемому статусу.

С доменными примитивами эти значения становятся `UserId`, `Money` и `OrderStatus`. Тогда значение несёт свои правила. `UserId` может проверить формат при создании. `Money` держит сумму и валюту вместе, чтобы никто случайно не суммировал доллары и евро. `OrderStatus` может позволять только те состояния, которые реально используются бизнесом, вместо любой случайной строки.

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

Именно поэтому небольшие типы быстро окупаются. Они превращают расплывчатые данные в именованные значения с границами. Код становится строже, но и читаемее. Увидев `Money(4999, "USD")` или `OrderStatus.Paid`, вы понимаете, что означает значение и что программа допускает.

## Начните с тех значений, которые ломаются чаще всего

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

Идентификаторы обычно окупаются первыми. Customer ID, Order ID, Invoice ID и Session ID могут все выглядеть как "12345". Компилятор не отличит их, если они — простые строки. Небольшой тип вроде `CustomerId` или `OrderId` предотвращает путаницу ещё до выполнения кода. Один неверный параметр может создать часы уборки, поэтому такое изменение часто быстро окупается.

Деньги стоит вынести следующим. Команды часто передают сумму и валюту раздельно или, что ещё хуже, хранят сумму как число с плавающей точкой. Так вы получаете ситуацию, когда к 10 USD прибавляют 10 EUR или 19.99 превращается в 19.989999 из‑за арифметики с плавающей точкой. Тип `Money` держит сумму и валюту вместе и даёт одно место для правил округления, сравнения и отображения.

Логические флаги тоже вызывают подозрение. Флаг вроде `isPaid` кажется простым, пока бизнес не добавит refunded, partially paid, chargeback, pending review или failed. Тогда флаг превращается в ловушку. Именованный статус вроде `PaymentStatus` делает код читаемым как бизнес‑правило, а не предположением.

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

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

## Простой пример потока заказа

Потоки заказов — место, где свободные значения начинают мешать. У вас есть customer ID, order ID, total и status. Все четыре кажутся безобидными, когда они — простые строки, числа или флаги.

```ts
createOrder(customerId: string, orderId: string, total: number, shipped: boolean)
```

Такая сигнатура приглашает к ошибкам. Разработчик может перепутать `customerId` и `orderId`, и код всё ещё выполнится.

```ts
createOrder(orderId, customerId, 19.99, false)
```

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

Деньги ломаются тише. 19.99 кажется понятным человеку, но код видит только число с плавающей точкой. Он не знает, означает ли это USD, EUR или кредит магазина. Если один сервис отправляет доллары, а другой читает евро, цена меняется без явной ошибки. Арифметика с плавающей точкой также способна превратить 19.99 в немного другое значение при расчётах налогов или скидок.

Флаги статуса дают ещё один класс ошибок. Булево `shipped: true | false` работает только когда у мира два состояния. Реальные заказы бывают оплачены, упакованы, отправлены, отменены, возвращены или возмещены. Если команда хранит только `shipped = false`, она скрывает несколько разных ситуаций в одном значении. Отменённый и возвращённый заказ выглядят одинаково.

Небольшие типы решают это, заставляя код говорить ясно:

```ts
createOrder(customerId: CustomerId, orderId: OrderId, total: Money, status: OrderStatus)
```

Теперь перепутанный ID приведёт к ошибке сразу. Тип `Money` может хранить сумму и валюту вместе, часто в центах, что избегает проблем округления. `OrderStatus` может позволять только реальные состояния, а ваш код определяет, какие переходы допустимы. Например, `canceled` не должен перескакивать в `shipped`.

Вот почему доменные примитивы быстро окупаются. Они превращают тихие ошибки в очевидные именно там, где пишется код.

## Как вводить их шаг за шагом

Сделайте быстрый обзор сырых значений, которыми приложение оперирует каждый день. Ищите всё, что имеет бизнес‑смысл, но проходит через код как простая строка, число или булево. `OrderId`, `CustomerId`, `Money`, `Currency`, `Email` и `OrderStatus` — типичные проблемные места.

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

Практичный rollout прост: запишите сырые значения, которые могут вызвать реальную бизнес‑ошибку. Выберите один тип, который легко назвать и легко валидировать. Поместите правила в конструктор или фабрику. Используйте его в самом загруженном коде сначала, оставляя старые малоопасные места на потом.

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

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

Маленький пример проще представить. Допустим, команда хранит статус заказа как строку. Один обработчик пишет "paid", другой — "Payed", третий проверяет "complete". Баг может тихо сидеть месяцами. Небольшой тип `OrderStatus` закроет эту дыру в день его добавления.

Держите адаптеры вокруг во время миграции. Если старый отчёт всё ещё ждёт простую строку, конвертируйте на краю и двигайтесь дальше. Такой постепенный подход работает хорошо в стартовых системах. На oleg.is Олег Сотников часто советует командам исправлять горячие пути сначала и чистить остальное, когда выгода станет очевидной.

## Что помещать внутрь каждого типа

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

Большинству доменных примитивов нужно лишь два компонента: сырое значение и правила, которые делают это значение валидным. `OrderId` может оборачивать строку и проверять, что она не пустая. `Money` может хранить центы и запрещать отрицательные суммы, если в вашем потоке заказов это недопустимо.

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

Последнее важнее, чем кажется. Форматирование должно находиться в типе только тогда, когда правило форматирования — часть домена, а не выбор UI. `Percentage` может безопасно рендерить `15%`. `Money` может форматировать `$12.50` только если приложение использует одну валюту и один стиль отображения. Если экраны, страны или отчёты требуют разных форматов, держите эту логику снаружи.

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

Имена помогают тоже. `UserId.parse("abc")` подсказывает, что происходит. `make`, `build` и `handle` обычно не говорят ничего.

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

## Ошибки, которые совершают команды

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

Лучшее правило простое: создавайте небольшой тип, когда значение может что‑то сломать. `OrderId` и `CustomerId` можно перепутать. `Money` может терять центы или смешивать валюты. Значения статуса могут разрастись в состояния, которые никто не планировал. Свободный текст обычно не нуждается в том же обращении.

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

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

Расплывчатые имена сильно вредят. Тип `GenericId` мало защищает, потому что разработчики будут использовать его для всего подряд. `CustomerId` и `OrderId` лучше — они блокируют случайные подмены. То же самое для неясных имён вроде `DataValue`, `StatusFlag` или `MetaField`. Если имя не говорит, что значит значение, тип мало поможет.

Небольшие типы также нуждаются в тестах. Команды пропускают их, потому что код кажется слишком простым, чтобы сломаться. Это обычно ошибка. Парсинг и проверки на равенство заслуживают прямых тестов, особенно для денег, типизированных ID и значений статусов. Если "00123" и "123" должны считаться разными, протестируйте это. Если для равенства двух денежных сумм должны совпадать и сумма, и валюта — протестируйте это тоже.

Один короткий пример говорит всё. Если сервис заказов принимает `GenericId`, разработчик может передать customer ID, и компилятор будет молчать. Если сервис принимает `OrderId`, такая ошибка остановится быстро. Тип должен делать неверный ход неудобным и правильный — лёгким.

## Быстрый чек‑лист для ревью

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

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

Небольшой мысленный эксперимент помогает. Представьте поток оформления, где заказ сначала pending, затем paid, а позже refunded. Если код может сравнивать customer ID с order ID, принимать деньги без валюты или считать "false" и unpaid, и failed — модель всё ещё скрывает смысл.

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

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

Хорошее ревью часто заканчивается одной полезной фразой: "Это поле больше не перепутаешь с тем полем." Если вы можете так сказать про ID, деньги и статусы — вы на верном пути.

## Куда двигаться дальше

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

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

После изменения следите за простыми сигналами: обращения в поддержку, связанные с неправильными значениями или смешанными ID; замечания в ревью с вопросом "что значит эта строка?"; время, потраченное на трассировку багов через контроллеры и фоновые задачи; и тесты, которые можно удалить, потому что тип теперь блокирует плохой ввод заранее. Вам не нужен отдельный дашборд — короткая заметка в спринт‑доке подойдёт.

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

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

Если команда хочет внешнюю помощь, Олег Сотников советует стартапам и небольшим компаниям практичные архитектурные изменения вроде этого. Цель обычно одна: исправить один рискованный путь, установить ясное правило и развивать дальше.

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