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

Примитивы, value objects и маленькие типы

Разработчику Архитектору

Примитивная одержимость (Primitive Obsession) — запах, когда вместо понятий предметной области в API повсюду string, int, double, bool. Каталог Фаулера и практика доменного моделирования предлагают реификацию: ввести маленький тип с правилами. Теория MAPPER — в культуре кода / 5; анемичные оболочки вокруг полей — 7.10 / 6.


Симптомы

  • SendEmail(string to, string subject, …) — легко перепутать два string.
  • decimal amount + string currency отдельно — валюта «теряется» при сложении.
  • Dictionary<string, object> вместо модели разрешений.
  • latitude: double, longitude: double без единиц и валидации диапазона.
  • Даты как string в трёх форматах в разных слоях.

Каждый такой случай — кандидат в value object (объект-значение): маленький неизменяемый тип, сравниваемый по значению, без собственной идентичности в домене.


Value object vs сущность

Value objectСущность (Entity)
ИдентичностьПо полям (Email = тот же адрес)По id (Order#42)
Жизненный циклЗаменяется целикомМеняется состояние во времени
ПримерMoney, Email, DateRangeCustomer, Order

В DDD value objects часто не имеют отдельной строки в БД — они встраиваются в колонки или 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.


Приёмы Фаулера

Связка с методами рефакторинга:

  • 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 objects.


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") падает в одном месте.

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").