Bounded Context: один Customer, два несовместимых типа
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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: внутри границы слово имеет ровно одно значение, на границе стоит явный перевод.
Три правила:
- Внутри границы термин однозначен.
CustomerвReservationsэто гость-постоялец. Точка. - Границы не пересекаются неявно. Нельзя взять
Customerиз одного контекста и сунуть в функцию другого. Если очень надо, переведи явно. - Между контекстами идёт контракт. События (“бронь оформлена”) или мост-функции. Не общий мутабельный тип.
Карта контекстов домена отеля и связей между ними:
Каждый 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 constructorcustomer_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,
))
}
}
Разбор по шагам:
- Достаём строку id из
Reservations-кастомера (customer_id_to_string). - Прогоняем её через smart constructor
Billing-контекста (billing_customer.customer_id). Если она невалидна для биллинга, контракт ловит это какError. - Собираем
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 по той же причине.