Примитивы, value objects и маленькие типы
Статья про то, как перестать размазывать понятия предметной области по string и int, и закрепить их в маленьких типах и value object. Ниже — словарь терминов; дальше — симптомы, шаги рефакторинга и связь с каталогом Фаулера.
Термины
Примитив и примитивность
Примитив (в смысле этой статьи) — встроенный или "голый" тип языка без доменных правил — string, int, long, double, bool, иногда decimal и дата как строка. Среда выполнения знает, как их хранить и складывать; она не знает, что такое email, валюта или широта.
Примитивность — склонность описывать важные понятия только такими типами: "в голове это заказ, в коде — три int и string". Чем выше примитивность публичного API, тем чаще дублируется валидация, путаются аргументы и теряются инварианты (например, валюта при сложении сумм).
Запах и одержимость
Запах кода (code smell) — заметный симптом слабой структуры, а не обязательно баг. Запах говорит — "здесь, вероятно, не хватает абстракции или границы объекта". Термин закрепили Кент Бек и Мартин Фаулер в контексте рефакторинга: запахи лечат именованными приёмами из каталога, а не "переименованием наугад".
Одержимость (obsession) — устойчивая переоценка одного приёма. Примитивная одержимость (Primitive Obsession) — запах, когда вместо понятий предметной области в сигнатурах и полях повсюду примитивы. Лечение в индустрии — value object и рефакторинги вроде Replace Primitive with Object / Replace Data Value with Object (см. Методы рефакторинга программного кода).
Предметная область
Предметная область (domain, предметка) — часть реального мира или бизнеса, которую автоматизирует программа — заказы, платежи, маршруты, права доступа. В коде она должна читаться в именах и типах так, чтобы эксперт предметки узнал модель без перевода "с технического на человеческий".
Связь с проектированием: DDD — доменная модель. Критерий "код похож на реальность" — теория MAPPER (ниже).
Мартин Фаулер и каталог Фаулера
Мартин Фаулер — автор книги Refactoring: Improving the Design of Existing Code и сайта martinfowler.com; там же — статьи вроде Value Object.
Каталог Фаулера — набор именованных рефакторингов с пошаговыми инструкциями (в 2-м издании книги, в частности, Replace Primitive with Object). Для примитивной одержимости Фаулер предлагает именно эти приёмы и value object, а не слово "реификация" — последнее в этой вики — краткий ярлык для "примитив → тип с правилами" (см. ниже).
Теория MAPPER
MAPPER (Model: Abstract Partial and Programmable Explaining Reality) — правило: код — модель реальности, а не набор полей "как в таблице БД". Значимому понятию в предметной области по возможности соответствует один явный элемент модели (класс, record, тип).
Подробно: культура кода / 5. Нарушение MAPPER часто выглядит как примитивная одержимость или анемичность.
Реификация
Реификация (в этой статье и в культуре кода / 6) — введение маленького типа вместо примитива, который "притворяется" понятием домена: string → Email, пара decimal + string → Money. Абстракция (адрес, сумма) становится конкретным типом с инвариантами в одном месте.
Не путать с реификацией дженериков (Java: стирание типов vs C#: типы в runtime) — другой смысл того же слова, см. данные и типы.
Маленький тип, value object, анемичность
Маленький тип — узкий класс, record или struct с немногими полями и ясной ответственностью; часто неизменяемый. Не обязан быть отдельной таблицей в БД — важен в коде как граница понятия.
Value object (объект-значение, VO) — тип без собственной идентичности в домене: два экземпляра с одинаковыми полями равны; жизненный цикл — замена целиком, а не "подкрутить поле". Примеры — Money, Email, DateRange. Противопоставление — сущность (entity) с id (Order#42).
Анемичность (анемичная модель) — класс только с полями и аксессорами, без доменных операций и инвариантов; логика размазана по *Service. Маленькие типы и value object помогают обогатить модель; пустая обёртка вокруг одного string без правил — ещё одна форма анемичности. См. 7.10 / 6.
Кандидат, кластер, сигнатура
Кандидат (на value object / маленький тип) — фрагмент кода, где примитив несёт смысл домена — повторяется валидация, два одинаковых типа параметра легко перепутать, поля "ездят" парой (сумма + валюта).
Кластер — такая группа связанных примитивов, которые встречаются вместе в полях, параметрах или возвращаемых значениях. Кластер — сигнал к Extract Class, Introduce Parameter Object или полноценному value object.
Сигнатура — "лицо" метода или функции для вызывающего — имя, типы и порядок параметров, тип возврата (в ООП иногда и модификаторы). Замена SendEmail(string to, string subject) на SendEmail(Email to, Subject subject) меняет сигнатуру так, что компилятор ловит перестановку аргументов.
Примитивная одержимость
Кратко — в API и модели домена вместо Email, Money, GeoCoordinate — только string, int, double. Это запах; противоядие — реификация (в терминах вики) и приёмы из каталога Фаулера и DDD.
Симптомы
SendEmail(string to, string subject, …)— легко перепутать дваstring.decimal amount+string currencyотдельно — валюта "теряется" при сложении.Dictionary<string, object>вместо модели разрешений.latitude: double, longitude: doubleбез единиц и валидации диапазона.- Даты как
stringв трёх форматах в разных слоях.
Каждый такой случай — кандидат в value object — маленький неизменяемый тип, сравниваемый по значению, без собственной идентичности в домене.
Value object и сущность
| Value object | Сущность (Entity) | |
|---|---|---|
| Идентичность | По полям (Email = тот же адрес) | По id (Order#42) |
| Жизненный цикл | Заменяется целиком | Меняется состояние во времени |
| Пример | Money, Email, DateRange | Customer, Order |
В DDD value object часто не имеют отдельной строки в БД — они встраиваются в колонки или JSON, но в коде остаются типами.
Реификация по шагам
- Заметить кластер — одни и те же примитивы идут вместе (сумма + валюта, lat + lon).
- Ввести маленький тип с инвариантами в конструкторе или фабрике
Email.parse(raw). - Заменить сигнатуры — IDE "Change signature" + тесты.
- Перенести поведение —
money.add(other)вместоif (currency != other.currency).
public readonly record struct Money(decimal Amount, string Currency) {
public Money Add(Money other) {
if (Currency != other.Currency)
throw new InvalidOperationException("Currency mismatch");
return new Money(Amount + other.Amount, Currency);
}
}
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self):
if "@" not in self.value:
raise ValueError("invalid email")
Типичные приёмы
| Направление | Суть |
|---|---|
| Маленькие объекты | Вместо "мешков" полей |
| Реификация | Примитив → тип с инвариантами |
| Коллекции | Ассоциативные массивы → типизированные структуры |
| Строки | Не универсальный контейнер для всего |
| Даты | Instant / ZonedDateTime, не "дата строкой" |
| Интервалы | Диапазоны как объекты с правилами |
| Валидация | Email, ISBN и т.п. внутри типа |
Указатель по симптомам — 7.10 / 13.
Приёмы из каталога Фаулера
Связка с методами рефакторинга (имена по Фаулеру; в 1-м издании часть называлась иначе):
| Рефакторинг | Когда применять |
|---|---|
| Replace Primitive with Object / Replace Data Value with Object | Один примитив симулирует понятие домена |
| Replace Type Code with Class / Subclasses | Коды статуса и ветвления по числам |
| Introduce Parameter Object | Много примитивов в одной сигнатуре |
| Extract Class | Кластер полей "ездит" вместе по классам |
Parameter Object
Пять параметров createUser(name, email, phone, country, zip) → createUser(NewUserRequest req) или createUser(ContactInfo contact, Address address). Это промежуточный шаг перед полноценными value object.
ORM и транспорт
- В БД колонка
email VARCHAR— нормально; в домене —Email. - На границе HTTP — DTO с
string; адаптер сразу строитEmailи дальше только типы. - Не дублировать валидацию в контроллере, сервисе и репозитории — один тип на границе.
Когда примитив уместен
- Счётчик цикла
i, флаги низкоуровневого API. - Внутренние индексы, не уходящие в доменный контракт.
- Прототип и throwaway-скрипт — осознанно, с пометкой в задаче на рефакторинг.
Не оборачивайте каждый int в UserId в утилитарном скрипте на 40 строк. Реифицируйте, когда тип пересекает границы модулей или несёт инвариант, который уже дважды продублирован в коде.
Чек-лист
- Два
stringподряд в публичном методе — можно ли перепутать? → отдельные типы. - Валидация email/суммы/даты копируется — вынести в value object.
Mapс магическими ключами — заменить классом или record.- Тест:
new Email("bad")падает в одном месте.
Дальше: рефакторинг · MAPPER · анемичность и примитивы · декларативный стиль · условия и null.