Value Object и Email: opaque + smart-конструктор
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Value Object и Email: opaque + smart-конструктор
Первая ката серии. Берём один из самых старых паттернов DDD, value object, и собираем его на Gleam так, чтобы невалидный Email физически нельзя было сконструировать. Кода мало, идей много.
Сцена · граница системы
В предыдущем уроке мы договорились, что DDD это две дисциплины: словаря и границ. Граница начинается там, где сырая строка из формы, JSON-поля или CSV впервые превращается в типизированное значение. На этой границе мы платим один раз за валидацию, дальше типы держат гарантию.
Email самый узнаваемый пример. Снаружи это String. Внутри домена это не String, это Email: что-то, что прошло проверку, что-то, на чём можно строить дальнейшую логику. Различие маленькое в коде и огромное в дисциплине.
Цели каты
- Запомнить связку opaque + smart-конструктор.
- Понять, чем “парсить” отличается от “валидировать”.
- Прописать сигнатуру
pub fn new(raw: String) -> Result(Email, DomainError)и сесть с этой сигнатурой за тесты.
К концу каты в examples/ddd-hotel/gleam/src/domain/email.gleam будет модуль из 25 строк, и в test/email_test.gleam пять тестов, на которые опирается весь остальной курс.
Концепт · что такое Value Object
Эванс ввёл Value Object против Entity по простому критерию.
- У Entity есть идентичность. Два
Customerравны, если у них совпадаетCustomerId, даже если имена и email-ы разные. Это разберём в 19. Entity и Customer · identity против equality. - У Value Object идентичности нет. Два
Emailравны, если у них совпадает нормализованная строка. УEmailнет id, нет жизненного цикла, его не “редактируют”. Меняешь email клиента, создаёшь новое значениеEmail, кладёшь его в обновлённую копиюCustomer.
Из этой простой разницы вытекает три практических следствия:
- VO иммутабелен. Никакого
email.set_local_part(...). Меняется через копию. - VO собирается через парсер, не через сеттеры. Один способ создания, одно место, где живёт валидация.
- VO самодостаточен. Email знает, что такое валидный email. Не Customer, не EmailService.
Языковые механики Gleam · opaque type
Gleam поддерживает pub opaque type прямо на уровне языка. Это синтаксическое отличие от обычного pub type:
// Внутри модуля domain/email.gleam
pub opaque type Email {
Email(value: String)
}
Что значит на практике:
- Тип
Emailэкспортируется, его можно упомянуть в сигнатурах функций других модулей. - Конструктор
Email(value: String)не экспортируется. Из соседнего модуля написатьemail.Email("hello")нельзя, компилятор скажет: “constructor is private”. - Поле
valueтоже не доступно снаружи:e.valueза границей модуля даст ошибку.
То есть единственный способ получить Email снаружи, это вызвать публичную функцию того же модуля. И эту функцию мы напишем сами, она и будет smart-конструктором.
Подвигай ниже границу модуля и посмотри, что видно снаружи, а что только изнутри:
domain/emailСтена модуля. Имя типа Email проходит наружу, а конструктор и поля остаются внутри. Снаружи виден только тип, не способ его собрать.Концепт · parse-don’t-validate
Алексис Кинг в 2019 сформулировала разницу как короткое правило. Parse, don’t validate.
// Анти-паттерн, валидируем:
pub fn is_valid_email(s: String) -> Bool { ... }
// Применение:
case is_valid_email(input) {
True -> save_to_database(input) // input всё ещё String, проверка стёрта
False -> error("плохой email")
}
// Паттерн, парсим:
pub fn new(raw: String) -> Result(Email, DomainError) { ... }
// Применение:
case email.new(input) {
Ok(value) -> save_to_database(value) // value это Email, проверка зашита в тип
Error(reason) -> handle_error(reason)
}
Разница выглядит мелочью, а держит всю систему: после email.new никакая функция внутри домена не сомневается, валиден ли email, потому что невалидного Email просто нельзя получить.
Задача · сигнатуры
Открой examples/ddd-hotel/gleam/src/domain/email.gleam. Тебе надо написать четыре вещи:
import gleam/string
import domain/errors.{type DomainError, InvalidEmail}
pub opaque type Email {
Email(value: String)
}
pub fn new(raw: String) -> Result(Email, DomainError)
pub fn to_string(email: Email) -> String
fn is_valid(value: String) -> Bool
Контракт new:
- На вход
String, на выходResult(Email, DomainError). - Триммим пробелы перед валидацией (
" alice@example.com "ок). - Проверка минимальная: одна
@, доменная часть содержит хотя бы одну., ни локальная, ни доменная часть не пустые. - На невалидный вход возвращаем
Error(InvalidEmail(raw)). Поле сохраняет оригинальный raw, не trimmed, чтобы в логе было видно, что прислал клиент.
Контракт to_string:
- Достать сырую нормализованную строку наружу. Это единственный способ “увидеть содержимое”
Email, ни одно другое поле снаружи не доступно.
Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.
Подсказки
gleam/stringуже есть вstdlib, нужныstring.trim,string.split,string.length. Документация:tour.gleam.run/everything/#strings-strings.- Для проверки формата хватит
string.split(value, "@")и pattern matching на[local, domain]. Список из ровно двух элементов значит, что@ровно одна. - Не пиши регулярки. У Gleam их нет в stdlib, а ставить отдельный пакет ради email-валидации это перебор. Простая string-проверка покрывает 99% реальных адресов; остальное всё равно требует SMTP-рукопожатия, который домен не делает.
- На тесты, минимум пять кейсов: валидный, с пробелами, без
@, без., пустая локальная часть.
Разбор · полное решение
// examples/ddd-hotel/gleam/src/domain/email.gleam
import gleam/string
import domain/errors.{type DomainError, InvalidEmail}
pub opaque type Email {
Email(value: String)
}
pub fn new(raw: String) -> Result(Email, DomainError) {
let trimmed = string.trim(raw)
case is_valid(trimmed) {
True -> Ok(Email(trimmed))
False -> Error(InvalidEmail(raw))
}
}
pub fn to_string(email: Email) -> String {
email.value
}
fn is_valid(value: String) -> Bool {
case string.split(value, "@") {
[local, domain] -> not_empty(local) && has_dot(domain)
_ -> False
}
}
fn not_empty(value: String) -> Bool {
string.length(value) > 0
}
fn has_dot(domain: String) -> Bool {
case string.split(domain, ".") {
[head, ..rest] -> not_empty(head) && not_empty_list(rest)
_ -> False
}
}
fn not_empty_list(parts: List(String)) -> Bool {
case parts {
[] -> False
[last] -> not_empty(last)
[_, ..rest] -> not_empty_list(rest)
}
}
Заметь две вещи:
is_valid,not_empty,has_dot,not_empty_listэто приватные функции (безpub). Снаружи модуля их не видно. Это идиоматично: интерфейс минимальный (new,to_string), вся внутренняя кухня скрыта.Error(InvalidEmail(raw))сохраняет именноraw, а неtrimmed. В логе мы хотим увидеть, что прислал внешний мир, а не то, что мы выровняли по пути.
Тесты
// examples/ddd-hotel/gleam/test/email_test.gleam
import gleeunit/should
import domain/email
pub fn valid_email_test() {
let assert Ok(value) = email.new("alice@example.com")
email.to_string(value)
|> should.equal("alice@example.com")
}
pub fn trims_whitespace_test() {
let assert Ok(value) = email.new(" alice@example.com ")
email.to_string(value)
|> should.equal("alice@example.com")
}
pub fn rejects_missing_at_test() {
email.new("alice.example.com")
|> should.be_error()
}
pub fn rejects_missing_dot_test() {
email.new("alice@localhost")
|> should.be_error()
}
pub fn rejects_empty_local_test() {
email.new("@example.com")
|> should.be_error()
}
Запусти:
cd examples/ddd-hotel/gleam
gleam test
Все пять тестов зелёные за несколько миллисекунд. Никакой инфраструктуры, никакой сети.
Критика · что не покрыто
Эта реализация намеренно простая. Что мы не делаем и почему:
- Полная RFC-5322-валидация. Регулярка по официальному RFC занимает страницу и всё равно пропускает дичь. На практике email “валиден”, если SMTP-сервер принимает письмо. Внутри домена нам важно отсечь явный мусор, а не пройти ISO-сертификацию.
- Нормализация регистра.
Alice@Example.comиalice@example.comэто формально один и тот же email (по RFC локальная часть case-sensitive, доменная нет, но почти все провайдеры считают локальную case-insensitive). Если решишь нормализовать, делай это вnewчерезstring.lowercase, и тогдаequalsчерез==будет работать корректно. - DNS-проверка домена. Это уже сетевой эффект, доменный модуль про сеть не знает. В Hexagonal-архитектуре такая проверка живёт в адаптере, а не в Value Object.
Когда видишь обсуждение “правильно ли валидируется email”, помни: правильно валидируется тогда, когда граница системы соответствует готовности домена принять данные. Всё остальное это вкусовщина.
Takeaway
Одна фраза, которую забираешь из урока:
Smart-конструктор плюс opaque-тип это твоя первая дисциплина DDD на Gleam. После
email.newникто внутри домена не сомневается.
ДЗ
Дальше
Следующая ката · 18. Money с валютой как phantom-параметром. Берём ту же дисциплину opaque + smart-конструктор, накручиваем type-level гарантию: компилятор не даёт сложить USD с EUR.
Параллельно полезно перечитать:
- 07. Дисциплина типов, там же впервые появляется opaque в более сухом контексте, теперь применяем как доменный паттерн.
- 04-ts/04 · Reliability and types, parse-don’t-validate на zod. Те же идеи, другой синтаксис, полезно для сравнения.