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

Почему простые строки приводят к реальным ошибкам
Обычная строка кажется безобидной, потому что она подходит почти везде. В этом и проблема. Один и тот же тип может хранить идентификатор пользователя, адрес электронной почты, цену, статус заказа, код страны или название купона, а компилятор считает их одинаково валидными.
Из‑за этого простые ошибки выглядят нормально. Если функция ожидает 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, код страны или статус. Программа видит их как просто текст и позволяет перепутать их.
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. Все четыре кажутся безобидными, когда они — простые строки, числа или флаги.
createOrder(customerId: string, orderId: string, total: number, shipped: boolean)
Такая сигнатура приглашает к ошибкам. Разработчик может перепутать customerId и orderId, и код всё ещё выполнится.
createOrder(orderId, customerId, 19.99, false)
Вызов ничем не указывает на ошибку. Оба ID — строки, компилятор принимает их. Баг проявится позже, когда поддержке будет сложно найти заказ и вместо него они увидят запись клиента, или когда возврат уйдёт не на тот счёт.
Деньги ломаются тише. 19.99 кажется понятным человеку, но код видит только число с плавающей точкой. Он не знает, означает ли это USD, EUR или кредит магазина. Если один сервис отправляет доллары, а другой читает евро, цена меняется без явной ошибки. Арифметика с плавающей точкой также способна превратить 19.99 в немного другое значение при расчётах налогов или скидок.
Флаги статуса дают ещё один класс ошибок. Булево shipped: true | false работает только когда у мира два состояния. Реальные заказы бывают оплачены, упакованы, отправлены, отменены, возвращены или возмещены. Если команда хранит только shipped = false, она скрывает несколько разных ситуаций в одном значении. Отменённый и возвращённый заказ выглядят одинаково.
Небольшие типы решают это, заставляя код говорить ясно:
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, деньги, статусы и похожие поля получают собственный небольшой тип, как только появляются в нескольких местах.
Это особенно важно для быстрых команд. Когда ревью становятся короче, а баг‑репорты скучнее, люди обычно перестают спорить о паттерне и начинают применять его сами.
Если команда хочет внешнюю помощь, Олег Сотников советует стартапам и небольшим компаниям практичные архитектурные изменения вроде этого. Цель обычно одна: исправить один рискованный путь, установить ясное правило и развивать дальше.
Это лучшее следующее действие, чем открывать большой тикет на чистку. Маленькие типы завоёвывают доверие, когда они устраняют один реальный баг за раз.
Часто задаваемые вопросы
Что такое доменный примитив?
Доменный примитив — это небольшой тип для одного бизнес‑значения, например CustomerId, Money или OrderStatus. Он оборачивает исходную строку или число и один раз проверяет правила, чтобы остальной код работал со значением, которому можно доверять.
С чего начать в первую очередь?
Начните с тех значений, которые уже вызывают исправления, возвраты или обращения в поддержку. Идентификаторы, деньги и поля статуса обычно окупаются первыми, потому что их часто путают, а баги отнимают реальное время.
Нужен ли отдельный тип для каждой строки?
Нет. Оборачивайте только те значения, которые несут бизнес‑смысл или ломаются при перепутывании. Свободный текст обычно не требует отдельного типа, а OrderId и Money чаще всего — да.
Какие поля выигрывают от этого больше всего?
CustomerId, OrderId, InvoiceId, Money, Currency, Email и поля статуса — обычные первые кандидаты. Они часто имеют одинаковый внешний вид, поэтому простые строки делают ошибки выглядящими валидными.
Как это помогает в TypeScript?
Это позволяет компилятору поймать очевидные перепутывания ещё до выполнения. Если функция ожидает CustomerId, передача OrderId сразу вызовет ошибку, вместо того чтобы превращаться в баг позже.
Стоят ли доменные примитивы усилий в динамическом языке?
Даже в динамическом языке это полезно. Конструктор или парсер может отвергнуть плохие входные данные на границе системы, и тесты упадут близко к источнику вместо того, чтобы пропустить ошибку дальше.
Хранить деньги в формате float?
Нет. Храните деньги как целое количество (например, в центах) и держите вместе валюту. Это избегает проблем с округлением и не даёт складывать 10 USD и 10 EUR как одно и то же.
Как добавить доменные примитивы без большого переписывания?
Делайте раскат аккуратно. Добавьте один тип в один загруженный поток, конвертируйте старые значения на краях, выпустите и переходите дальше. Так ревью остаются простыми, и вы быстро увидите результат.
Что положить внутрь каждого типа?
Один сырой value и правила, которые делают его валидным. Ясный способ создать или распарсить значение, прочитать его, сравнить и, возможно, отформатировать, если форматирование — часть домена. Не добавляйте в тип вызовы БД, цвета UI или логику, не относящуюся к значению.
Каких ошибок стоит избегать?
Оберегайтесь трех ошибок: оборачивать всё подряд, давать расплывчатые имена вроде GenericId, и оставлять в API функции, которые всё ещё принимают и возвращают простые строки. Также не размазывайте валидацию по формам, API и базе — держите правило в типе.