Entity и Customer: identity против equality
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Entity и Customer: identity против equality
Третья ката. От
Value Objectпереходим кEntity. Внешне похоже, ловушка глубокая: у Entity есть идентичность, и сравнивать её по содержимому нельзя. Один из чаще всего нарушаемых принципов DDD.
Сцена · два Alice не одинаковы
В отеле зарегистрированы две клиентки с одинаковым именем Alice Smith и одинаковым email alice@example.com. Это одна и та же Алиса? Возможно, нет. Это могут быть мама и дочь, или однофамилицы. Бизнес-вопрос “это один клиент” решает не код через ==, а оператор ресепшен через идентификатор CustomerId, выданный при регистрации.
Этот зазор между “формально похожи” и “это один и тот же” описывается одним правилом DDD: у Entity есть идентичность, и она важнее содержимого.
Цели каты
- Понять, чем
Entityотличается отValue Object(на повторении из урока 17). - Реализовать
Customerс opaqueCustomerId, явным сравнениемsame_customer. - Уяснить, почему
update_emailвозвращает новую запись (record-update), а не мутирует.
К концу каты в examples/ddd-hotel/gleam/src/domain/customer.gleam будет 30 строк и четыре теста, которые показывают разницу identity vs equality на конкретных кейсах.
Концепт · Entity у Эванса
Эванс ввёл Entity для объектов, у которых есть жизнь во времени. У Customer она такая: он зарегистрировался год назад, поменял email два месяца назад, потом женился и взял двойную фамилию. Это один человек, разные снимки.
Критерии Entity:
- Идентичность. У него есть уникальный id, выданный системой или внешним источником (паспорт, ИНН).
- Континуальность. Сегодняшний Customer и вчерашний Customer (с тем же id) это один и тот же клиент, даже если содержимое полей разное.
- Жизненный цикл. Создание, обновление, иногда удаление (но в DDD удаление чаще мягкое, через состояние
Archived).
Value Object из урока 17 (Email) и 18 (Money) ничего из этого не имеет: Email “alice@example.com” сегодня и завтра это просто одна и та же строка. Никакого жизненного цикла, никакого id.
Концепт · identity-based equality
В коде это значит две вещи.
Первое: у Entity есть id, и он opaque (как Value Object из урока 17). Снаружи нельзя собрать CustomerId("любая_строка"), только через smart-конструктор customer_id(raw).
Второе: функция сравнения двух Entity опирается на id, не на содержимое. В Gleam стандартное == для записей сравнивает поля. Это то, чего мы не хотим для Entity. Поэтому пишем явную функцию same_customer(a, b) = a.id == b.id. В команде договариваемся: == на Entity не использовать, всегда через специальную функцию.
В строго-типизированных языках можно даже не оставлять компилятору шанс: запретить == через newtype-обёртку. В Gleam такого механизма нет, но дисциплины и явной функции хватает.
Языковые механики Gleam · record update syntax
В Gleam запись (record) иммутабельна. “Изменить email клиента” значит “создать копию с новым email”. Синтаксис называется record update:
let updated = Customer(..customer, email: new_email)
// ^^^^^^^^^^ скопировать всё
// ^^^^^^^^^^^^^^^^^ кроме указанных
Это и есть главный инструмент работы с Entity на Gleam. Никаких сеттеров, никакого customer.email = new_email. Старый customer остался прежним (где-нибудь в БД, в логе, в тесте), новый существует с одним отличием.
Задача · сигнатуры
Открой examples/ddd-hotel/gleam/src/domain/customer.gleam:
import gleam/string
import domain/email.{type Email}
import domain/errors.{type DomainError, InvalidCustomerId}
pub opaque type CustomerId {
CustomerId(value: String)
}
pub type Customer {
Customer(id: CustomerId, email: Email, name: String)
}
pub fn customer_id(raw: String) -> Result(CustomerId, DomainError)
pub fn customer_id_to_string(id: CustomerId) -> String
pub fn same_customer(left: Customer, right: Customer) -> Bool
pub fn change_email(customer: Customer, new_email: Email) -> Customer
Контракты:
customer_idвалидирует, что строка не пустая и длиной хотя бы 4 символа (послеstring.trim). На невалид возвращаетError(InvalidCustomerId(raw)).customer_id_to_stringединственный способ достать сырое представление id, например для сериализации в БД.same_customerсравнивает поid, не по содержимому. Это и есть identity-equality.change_emailиспользует record update, возвращает новую запись с тем жеidиname, но новымemail.
Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.
Подсказки
Customerэто не opaque, потому что нам нужны поля наружу для чтения. Запретить их снаружи легко через приватный конструктор, но это лишний бойлерплейт, и Email + CustomerId всё равно opaque, голую структуру нельзя собрать вне модуля.- В
change_emailсинтаксисCustomer(..customer, email: new_email)это и есть весь body. Никаких guards, никаких проверок: смена email допустима всегда (валидацию самого email сделал ужеemail.new). - На тесты три ключевых сценария: same id + same fields, same id + diff fields, diff id + same fields. Первые два должны давать
same_customer == True, последнийFalse.
Разбор · полное решение
// examples/ddd-hotel/gleam/src/domain/customer.gleam
import gleam/string
import domain/email.{type Email}
import domain/errors.{type DomainError, InvalidCustomerId}
pub opaque type CustomerId {
CustomerId(value: String)
}
pub type Customer {
Customer(id: CustomerId, email: Email, name: String)
}
pub fn customer_id(raw: String) -> Result(CustomerId, DomainError) {
let trimmed = string.trim(raw)
case string.length(trimmed) >= 4 {
True -> Ok(CustomerId(trimmed))
False -> Error(InvalidCustomerId(raw))
}
}
pub fn customer_id_to_string(id: CustomerId) -> String {
id.value
}
pub fn same_customer(left: Customer, right: Customer) -> Bool {
left.id == right.id
}
pub fn change_email(customer: Customer, new_email: Email) -> Customer {
Customer(..customer, email: new_email)
}
Тридцать строк, четыре функции, два типа. У CustomerId есть smart-конструктор. У Customer есть identity-equality через same_customer и иммутабельное обновление через change_email. Дисциплина Entity на месте.
Напоминание про границу модуля: снаружи у Email не достать .value, а собрать его можно только через smart-конструктор.
domain/emailСтена модуля. Имя типа Email проходит наружу, а конструктор и поля остаются внутри. Снаружи виден только тип, не способ его собрать.Тесты
// examples/ddd-hotel/gleam/test/customer_test.gleam
import gleeunit/should
import domain/customer
import domain/email
pub fn identity_equality_test() {
let assert Ok(id) = customer.customer_id("cust_001")
let assert Ok(mail_a) = email.new("alice@example.com")
let assert Ok(mail_b) = email.new("alice.new@example.com")
let a = customer.Customer(id: id, email: mail_a, name: "Alice")
let b = customer.Customer(id: id, email: mail_b, name: "Alice Smith")
customer.same_customer(a, b)
|> should.be_true()
}
pub fn different_ids_are_different_test() {
let assert Ok(id_a) = customer.customer_id("cust_001")
let assert Ok(id_b) = customer.customer_id("cust_002")
let assert Ok(mail) = email.new("alice@example.com")
let a = customer.Customer(id: id_a, email: mail, name: "Alice")
let b = customer.Customer(id: id_b, email: mail, name: "Alice")
customer.same_customer(a, b)
|> should.be_false()
}
pub fn change_email_preserves_identity_test() {
let assert Ok(id) = customer.customer_id("cust_001")
let assert Ok(mail_a) = email.new("alice@example.com")
let assert Ok(mail_b) = email.new("alice@new.com")
let before = customer.Customer(id: id, email: mail_a, name: "Alice")
let after = customer.change_email(before, mail_b)
customer.same_customer(before, after)
|> should.be_true()
}
pub fn rejects_short_id_test() {
customer.customer_id("abc")
|> should.be_error()
}
Четыре теста, четыре утверждения о смысле Entity:
- Один id + разные email/name = один клиент.
- Разные id + один email/name = разные клиенты.
- После
change_emailэто тот же клиент с обновлённым email. - Сборка
CustomerIdмимо валидации запрещена.
Иммутабельность · что меняется в мышлении
Если ты приходишь из мутабельного OOP (Java, Python с обычными классами), record-update сначала кажется неудобным. Хочется написать customer.email = new_email. Через неделю привыкания обнаруживаешь три приятных следствия:
- Историю править нельзя. Если в логе остался указатель на
customerверсии 1.0, его никто незаметно не “обновит до 1.1”. Полезно для аудита и для отладки гонок. - Параллельный код без блокировок. Два файбера держат указатели на один
customer. Если один создаст обновлённую копию черезchange_email, второй не получит “наполовину обновлённый” объект, он продолжит работать со старой версией. - Тестируемость. В тесте
let before = ...,let after = change_email(before, new). Оба значения доступны, ассертsame_customer(before, after)буквально читается как “это один клиент в двух снимках”.
Это не “функциональная роскошь”, это инструмент устранения целого класса багов на ровном месте.
Критика · что не покрыто
Намеренно простой Entity. Что мы не делаем:
- Список ролей. Customer может быть
Regular,Vip,Banned. В реальном проекте это отдельный тип-флаг (type Tier { Regular | Vip | Banned }) внутриCustomer. Для каты не нужно. - История изменений. ДЗ покрывает простую реализацию через
List(EmailChange). В уроке 22 (Event Sourcing) мы увидим радикальное решение: история это и есть стрим событий, отдельное поле не нужно. - Equality через
Equal-typeclass. В Gleam нет typeclass-ов, явная функцияsame_customerэто идиома языка. В Haskell-треке мы вернёмся к этому черезinstance Eqсо своей реализацией. В Effect (TS) был бы Data.struct, который тоже сравнивает по полям, и функцию сравнения по id пришлось бы дописать.
Сравнение с Effect-треком
В Effect мы делали Customer через Schema.Class. У него instanceof, методы, и Schema даёт парсер. Identity vs equality там тоже надо описывать явной функцией: Customer.equals или Data.struct + сравнение id-поля.
В Gleam нет классов, нет instanceof, нет наследования. Меньше бойлерплейта, меньше выбора. Это иногда плюс (нет соблазна положить логику в метод), иногда минус (нет полиморфизма из коробки). Для DDD-словаря Gleam-вариант чище: ты буквально не можешь спутать VO и Entity, потому что они выглядят по-разному.
Takeaway
Одна фраза:
Entity это id + жизнь во времени. Сравнение по содержимому это баг, а не оптимизация.
ДЗ
Дальше
Следующая ката · 20. Aggregate Reservation, стек инвариантов. Самая объёмная ката первого блока: бронь как ADT с пятью состояниями, переходы как pure-функции, стек guards через result.try.
Параллельно полезно перечитать:
- 04. Типы и коллекции, record-update там разбирался на примере счётчика. Здесь же он становится центральным инструментом обновления Entity.
- 16. Functional DDD intro, раздел про Value Object vs Entity. Дисциплина словаря, на которую опирается всё дальше.