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

Примитивы, 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) — введение маленького типа вместо примитива, который "притворяется" понятием домена: stringEmail, пара decimal + stringMoney. Абстракция (адрес, сумма) становится конкретным типом с инвариантами в одном месте.

Не путать с реификацией дженериков (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.