Раздел 27 · Gleam на практике

Entity и Customer: identity против equality

middle~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Entity и Customer: identity против equality

Третья ката. От Value Object переходим к Entity. Внешне похоже, ловушка глубокая: у Entity есть идентичность, и сравнивать её по содержимому нельзя. Один из чаще всего нарушаемых принципов DDD.

Сцена · два Alice не одинаковы

В отеле зарегистрированы две клиентки с одинаковым именем Alice Smith и одинаковым email alice@example.com. Это одна и та же Алиса? Возможно, нет. Это могут быть мама и дочь, или однофамилицы. Бизнес-вопрос “это один клиент” решает не код через ==, а оператор ресепшен через идентификатор CustomerId, выданный при регистрации.

Этот зазор между “формально похожи” и “это один и тот же” описывается одним правилом DDD: у Entity есть идентичность, и она важнее содержимого.

Цели каты

  • Понять, чем Entity отличается от Value Object (на повторении из урока 17).
  • Реализовать Customer с opaque CustomerId, явным сравнением same_customer.
  • Уяснить, почему update_email возвращает новую запись (record-update), а не мутирует.

К концу каты в examples/ddd-hotel/gleam/src/domain/customer.gleam будет 30 строк и четыре теста, которые показывают разницу identity vs equality на конкретных кейсах.

Концепт · Entity у Эванса

Эванс ввёл Entity для объектов, у которых есть жизнь во времени. У Customer она такая: он зарегистрировался год назад, поменял email два месяца назад, потом женился и взял двойную фамилию. Это один человек, разные снимки.

Критерии Entity:

  1. Идентичность. У него есть уникальный id, выданный системой или внешним источником (паспорт, ИНН).
  2. Континуальность. Сегодняшний Customer и вчерашний Customer (с тем же id) это один и тот же клиент, даже если содержимое полей разное.
  3. Жизненный цикл. Создание, обновление, иногда удаление (но в 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-конструктор.

Opaque type под рентгеном
граница модуля domain/emailСтена модуля. Имя типа Email проходит наружу, а конструктор и поля остаются внутри. Снаружи виден только тип, не способ его собрать.
Emailчёрный ящик
value:String

Тесты

// 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:

  1. Один id + разные email/name = один клиент.
  2. Разные id + один email/name = разные клиенты.
  3. После change_email это тот же клиент с обновлённым email.
  4. Сборка CustomerId мимо валидации запрещена.

Иммутабельность · что меняется в мышлении

Если ты приходишь из мутабельного OOP (Java, Python с обычными классами), record-update сначала кажется неудобным. Хочется написать customer.email = new_email. Через неделю привыкания обнаруживаешь три приятных следствия:

  1. Историю править нельзя. Если в логе остался указатель на customer версии 1.0, его никто незаметно не “обновит до 1.1”. Полезно для аудита и для отладки гонок.
  2. Параллельный код без блокировок. Два файбера держат указатели на один customer. Если один создаст обновлённую копию через change_email, второй не получит “наполовину обновлённый” объект, он продолжит работать со старой версией.
  3. Тестируемость. В тесте 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. Дисциплина словаря, на которую опирается всё дальше.