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

Bounded Context: один Customer, два несовместимых типа

middle-senior~30 мин

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

Bounded Context: один Customer, два несовместимых типа

Седьмая ката. До сих пор у нас был один домен и один Customer. Сегодня берём слово “клиент” и показываем, что в разных частях системы это разные сущности. Граница между ними проходит по типу, и компилятор её сторожит.

Сцена · слово “клиент” врёт

Открой examples/ddd-hotel/gleam. У нас один тип Customer живёт в domain/customer.gleam, и его дёргают и бронирование, и оплата. Пока система маленькая, это не мешает. Но начни задавать вопросы.

Что отель знает о госте на ресепшен? Email для уведомлений, имя для бэйджа, уровень лояльности для апгрейда номера. А что отелю нужно от того же человека в бухгалтерии? Налоговый номер, способ оплаты, юридическое имя плательщика. Email бухгалтерию не интересует, она писем не шлёт. Уровень лояльности бессмысленен для счёта.

Если запихать всё в один Customer, получится тип с десятком полей, половина из которых в каждом конкретном месте Nil. А любой if customer.tax_id != Nil это сигнал, что тип неверен (вспомни North Star из 16. Functional DDD на Gleam: невозможные состояния невыразимы).

Правильный ответ из стратегического DDD: это два разных Customer-а. Один живёт в контексте Reservations, другой в Billing. У каждого свой словарь, свои поля, своя граница. Эта граница называется bounded context.

Карта урока · что заберёшь через 2.5 часа

  • Поймёшь, что bounded context это граница языка, а не папка с кодом.
  • Заведёшь два независимых Customer-типа в contexts/reservations/ и contexts/billing/, и убедишься, что компилятор не даст их перепутать.
  • Напишешь bridge-функцию: явный переводчик данных через границу, который заставляет добавить недостающие поля.
  • Поймёшь, почему shared kernel (общий тип на два контекста) обычно ловушка.

Концепт · ubiquitous language и его граница

Эванс вводит два связанных понятия.

Ubiquitous language это единый словарь, на котором эксперт домена и программист описывают одну область. Когда менеджер ресепшен говорит “гость”, и в коде Customer из Reservations, у слова одно значение.

Но один словарь не натягивается на всю систему. Бухгалтер тоже говорит “клиент”, и имеет в виду другое. Натянуть один словарь на оба отдела значит породить тип, который не радует ни одного из них. Поэтому язык делят на bounded context: внутри границы слово имеет ровно одно значение, на границе стоит явный перевод.

Три правила:

  1. Внутри границы термин однозначен. Customer в Reservations это гость-постоялец. Точка.
  2. Границы не пересекаются неявно. Нельзя взять Customer из одного контекста и сунуть в функцию другого. Если очень надо, переведи явно.
  3. Между контекстами идёт контракт. События (“бронь оформлена”) или мост-функции. Не общий мутабельный тип.

Карта контекстов домена отеля и связей между ними:

Bounded contexts домена отелянаведи или нажми на стрелку, чтобы увидеть, что пересекает границу
ReservationsDecider · reservationcmd: place, cancelread: daily occupancyFront DeskDecider · staycmd: check in, check outread: in-house guestsBillingDecider · foliocmd: add charge, settle folioread: open foliosSaga · charge-on-checkout
событие наружу

Каждый context, это один Decider, одна или две read-модели и максимум одна сага на границе. Контексты не зовут друг друга напрямую: один публикует событие, другой принимает команду. Это и есть граница, через которую течёт ubiquitous language.

Языковые механики Gleam · модули как границы

В Gleam модуль это просто файл, а путь импорта повторяет путь от src/. Это даёт нам бесплатную границу: contexts/reservations/customer.gleam и contexts/billing/customer.gleam это два разных модуля, и два их типа Customer физически разные, даже если называются одинаково.

Импорт с алиасом разводит одинаковые имена:

import contexts/billing/customer as billing
import contexts/reservations/customer as reservations

Теперь reservations.Customer и billing.Customer это разные типы в одном файле, и компилятор не даст их перепутать. Это и есть граница bounded context, проведённая средствами языка.

Задача · сигнатуры

Заведи два модуля. Сначала контекст бронирования, contexts/reservations/customer.gleam:

import domain/email.{type Email}

pub type Tier {
  Regular
  Silver
  Gold
  Platinum
}

pub opaque type CustomerId {
  CustomerId(value: String)
}

pub type Customer {
  Customer(id: CustomerId, email: Email, name: String, tier: Tier)
}

Затем контекст оплаты, contexts/billing/customer.gleam. Заметь: нет email, нет tier. Зато есть tax_id и payment_method:

pub type PaymentMethod {
  CreditCard(last_four: String)
  CorporateAccount(tax_id: String)
  Cash
}

pub opaque type CustomerId {
  CustomerId(value: String)
}

pub type Customer {
  Customer(
    id: CustomerId,
    legal_name: String,
    tax_id: String,
    payment_method: PaymentMethod,
  )
}

И мост в contexts/bridge.gleam:

pub fn reservation_to_billing(
  reservation_customer: reservations.Customer,
  tax_id: String,
  payment_method: billing.PaymentMethod,
) -> Result(billing.Customer, String)

Контракт моста:

  • На вход идёт Customer из Reservations плюс поля, которых в нём нет (tax_id, payment_method).
  • На выход Customer из Billing, собранный явно.
  • Если id не переносится (пустая строка после конвертации), возвращаем Error(reason).

Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.

Подсказки

  • Оба CustomerId пусть будут opaque, как в уроке 17. Внешний код не достанет .value, идёт через smart constructor customer_id(raw).
  • Мост не может выдумать tax_id и payment_method сам, их неоткуда взять в Reservations. Поэтому они аргументы функции. Это не неудобство, это и есть суть: данные через границу не телепортируются, их кто-то должен принести (форма при первом счёте, ответ другого сервиса).
  • Id в обоих контекстах хранит одну и ту же строку-литерал (это один человек), но типы разные. Перенос id идёт через to_string в одном контексте и smart constructor в другом.
  • same_customer(left, right) сравнивай по id, не по всем полям (это identity-сравнение из 19. Entity и Customer).

Разбор · полное решение

Модули reservations/customer.gleam и billing/customer.gleam смотри в репозитории; они почти зеркальны по форме, но различны по полям. Самое интересное это мост:

// examples/ddd-hotel/gleam/src/contexts/bridge.gleam

import contexts/billing/customer as billing_customer
import contexts/reservations/customer as reservations_customer

pub fn reservation_to_billing(
  reservation_customer: reservations_customer.Customer,
  tax_id: String,
  payment_method: billing_customer.PaymentMethod,
) -> Result(billing_customer.Customer, String) {
  case
    billing_customer.customer_id(reservations_customer.customer_id_to_string(
      reservation_customer.id,
    ))
  {
    Error(reason) -> Error(reason)
    Ok(billing_id) ->
      Ok(billing_customer.Customer(
        id: billing_id,
        legal_name: reservation_customer.name,
        tax_id: tax_id,
        payment_method: payment_method,
      ))
  }
}

Разбор по шагам:

  1. Достаём строку id из Reservations-кастомера (customer_id_to_string).
  2. Прогоняем её через smart constructor Billing-контекста (billing_customer.customer_id). Если она невалидна для биллинга, контракт ловит это как Error.
  3. Собираем Billing.Customer, перекладывая name -> legal_name и докладывая tax_id, payment_method, которые пришли аргументами.

Главное здесь не код, а то, чего код не делает: он не берёт email и не берёт tier. Эти поля остались в Reservations и в биллинг не поехали. Граница работает.

Проверка границы компилятором

В test/bounded_context_test.gleam мы заводим оба кастомера с одним и тем же строковым id, и показываем, что это разные типы:

reservations.customer_id_to_string(res_customer.id)
|> should.equal(billing.customer_id_to_string(bill_customer.id))
// Тот же id-литерал, но это разные типы. Заметка:
// should.equal(res_customer, bill_customer) НЕ скомпилируется,
// потому что они структурно разные. Это и есть compile-time
// изоляция bounded context-ов.

Попробуй сам: раскомментируй сравнение двух кастомеров целиком и запусти gleam test. Компилятор откажется: типы не совпадают. Это и есть граница, и сторожит её не код-ревью, а тип-чекер.

Критика · что не покрыто

  • Shared kernel. Иногда два контекста честно делят кусок модели (например Money из урока 18). Это допустимо, но опасно: любое изменение бьёт по двум командам сразу. Делим только то, что неизменно по смыслу в обоих контекстах.
  • Anti-corruption layer. Сейчас billing ничего не знает о reservations, а мост знает оба. В большой системе мост прячут за портом, чтобы billing не зависел от формы чужого контекста (ДЗ).
  • События как контракт. В R3 (event sourcing) мост перестанет принимать Customer, а будет читать событие ReservationPlaced. Это честнее: контексты общаются фактами, а не передачей объектов из рук в руки.
  • Context map. Мы нарисовали границы, но не подписали тип связи (Customer/Supplier, Conformist, Partnership). Для трёх контекстов это ещё держится в голове, для тридцати нужна карта (ДЗ).

Takeaway

Одна фраза:

Bounded context это граница языка: внутри слово однозначно, на границе стоит явный переводчик. В Gleam граница проходит по модулю, а сторожит её компилятор.

ДЗ

Дальше

Следующая ката · 24. Composition root на Wisp. Соберём первое HTTP-приложение: тонкий роутер, который принимает ReservationRepo параметром, и единственное место в коде, где конкретный adapter подкладывается под порт. Домен по-прежнему не знает, что под ним.

Параллельно полезно перечитать:

  • 22. Repository как порт, про порт-запись. Сегодня мы добавили второй и третий тип за границей, теперь у домена несколько портов.
  • 17. Value Object: Email, про opaque type. Оба CustomerId сегодня opaque по той же причине.