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

Value Object и Email: opaque + smart-конструктор

middle~35 мин

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

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.

Из этой простой разницы вытекает три практических следствия:

  1. VO иммутабелен. Никакого email.set_local_part(...). Меняется через копию.
  2. VO собирается через парсер, не через сеттеры. Один способ создания, одно место, где живёт валидация.
  3. 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-конструктором.

Подвигай ниже границу модуля и посмотри, что видно снаружи, а что только изнутри:

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

Концепт · 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. Те же идеи, другой синтаксис, полезно для сравнения.