Перейти к основному содержимому

Примитивы, 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, DateRangeCustomer, Order

В DDD value object часто не имеют отдельной строки в БД — они встраиваются в колонки или JSON, но в коде остаются типами.


Реификация по шагам​

  1. Заметить кластер — одни и те же примитивы идут вместе (сумма + валюта, lat + lon).
  2. Ввести маленький тип с инвариантами в конструкторе или фабрике Email.parse(raw).
  3. Заменить сигнатуры — IDE "Change signature" + тесты.
  4. Перенести поведение — 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 строк. Реифицируйте, когда тип пересекает границы модулей или несёт инвариант, который уже дважды продублирован в коде.


Чек-лист​

  1. Два string подряд в публичном методе — можно ли перепутать? → отдельные типы.
  2. Валидация email/суммы/даты копируется — вынести в value object.
  3. Map с магическими ключами — заменить классом или record.
  4. Тест: new Email("bad") падает в одном месте.

Дальше: рефакторинг · MAPPER · анемичность и примитивы · декларативный стиль · условия и null.