Composition root на Wisp: где собирается граф зависимостей
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Composition root на Wisp: где собирается граф зависимостей
Восьмая ката. У нас есть чистый домен (17-21), порт
ReservationRepo(22) и контексты (23). Сегодня надеваем на это HTTP. Главный вопрос не “как роутить”, а “где собрать конкретные зависимости так, чтобы домен про них не знал”.
Сцена · вопрос “откуда взять repo”
Хендлер должен сохранить бронь. Значит, ему нужен ReservationRepo. Откуда он его берёт?
Плохой ответ из мира фреймворков: глобальный синглтон. Repo.instance().save(state) где-то посреди обработчика. Тогда тест не подменит хранилище, не зная про скрытый глобал, а две конфигурации (in-memory для теста, sqlite для прода) начинают драться за один синглтон.
Хороший ответ из 22 · Repository как порт: repo приходит параметром. Но тогда параметр надо откуда-то передать. Где в HTTP-приложении точка, в которой мы говорим “вот этот конкретный adapter”? Эта точка называется composition root, и сегодня мы её построим.
Карта урока · что заберёшь через 3 часа
- Поймёшь, что такое composition root и почему он один на приложение.
- Соберёшь Wisp-приложение через
make_app(repo): фабрику хендлера, замкнутую на repo. - Напишешь два эндпоинта (создать бронь, прочитать бронь) с тонкими хендлерами: парсинг, вызов домена, формирование ответа.
- Прогонишь HTTP-тесты без сети через
wisp/simulate, подложив in-memory adapter.
Концепт · composition root по Сееманну
Марк Сееманн формулирует так: приложение должно собирать граф зависимостей в единственном месте, максимально близко к точке входа. Всё, что ниже, принимает зависимости и не создаёт их само.
Зачем единственное место:
- Тестируемость. В тесте composition root другой: вместо sqlite подкладываем in-memory. Домен и хендлеры те же, граф другой.
- Понятность. Хочешь узнать, что с чем связано? Смотришь в один файл, не охотишься по коду за
new-ами. - Чистота низа. Хендлер не знает, какой repo под ним. Он работает с портом. Значит, его легко читать и тестировать.
В Gleam это работает без DI-контейнеров: частичное применение. make_app(repo) возвращает хендлер fn(Request) -> Response, в котором repo уже замкнут. Это и есть инъекция зависимости, без рефлексии и контейнеров.
Языковые механики Gleam · Wisp и фабрика хендлера
Wisp устроен без магии: хендлер это функция из Request в Response, маршрутизация это case по сегментам пути. Никаких декораторов и аннотаций.
Фабрика, замыкающая repo:
pub fn make_app(repo: ReservationRepo) -> fn(Request) -> Response {
fn(req: Request) {
case wisp.path_segments(req) {
["reservations"] -> handle_collection(req, repo)
["reservations", id] -> handle_one(req, repo, id)
_ -> wisp.not_found()
}
}
}
make_app это и есть мини composition root уровня веба. Ему передали repo, он раздаёт его хендлерам. Кто именно repo (in-memory или sqlite), make_app не знает и знать не хочет.
Задача · сигнатуры
Собери web/router.gleam с двумя эндпоинтами:
POST /reservations -> 201 (создать), 422 (битый JSON или домен отверг)
GET /reservations/:id -> 200 (нашли), 404 (нет такой)
Сигнатуры:
pub fn make_app(repo: ReservationRepo) -> fn(Request) -> Response
fn handle_collection(req: Request, repo: ReservationRepo) -> Response
fn handle_one(req: Request, repo: ReservationRepo, raw_id: String) -> Response
fn build_reservation(body: CreateBody) -> Result(Reservation, String)
Контракт хендлеров (это важная часть DDD: хендлер тонкий):
- Хендлер парсит запрос в данные, зовёт домен, переводит результат в HTTP. Бизнес-логики в нём нет.
- Невалидный JSON это
422. Домен отверг команду (неверный переход, плохой id) это тоже422, но с телом-ошибкой. StorageFailureиз repo это500. Это не вина клиента.
Подсказки
wisp.require_method(req, http.Post)это guard черезuse <-: если метод не тот, дальше код не идёт.wisp.require_json(req)достаёт тело и парсит вdynamic. Дальше декодируешь черезgleam/dynamic/decode.- Декодер собирается через
use field <- decode.field("name", decode.string)и завершаетсяdecode.success(...). Это та жеuse-цепочка, что в домене. - Собери
Reservationчерез те же smart constructors из 20 · Aggregate Reservation:reservation_id,customer_id,room_number,date_range, потомplace(initial(), ...). ЛюбойErrorна этом пути это422. - Не дёргай domain напрямую из
make_app.make_appтолько роутит. Логика входа вhandle_collection.
Разбор · полное решение
Хендлер создания, сердце урока:
// examples/ddd-hotel/gleam/src/web/router.gleam
fn handle_collection(req: Request, repo: ReservationRepo) -> Response {
use <- wisp.require_method(req, http.Post)
use raw <- wisp.require_json(req)
let decoder = {
use id <- decode.field("id", decode.string)
use guest <- decode.field("guest", decode.string)
use room <- decode.field("room", decode.string)
use check_in <- decode.field("check_in", decode.int)
use check_out <- decode.field("check_out", decode.int)
decode.success(CreateBody(id, guest, room, check_in, check_out))
}
case decode.run(raw, decoder) {
Error(_) -> wisp.unprocessable_content()
Ok(body) ->
case build_reservation(body) {
Error(message) ->
wisp.unprocessable_content()
|> wisp.json_body(error_json(message))
Ok(state) ->
case repo.save(state) {
Ok(Nil) -> wisp.json_response(reservation_json(state), 201)
Error(StorageFailure(reason)) ->
wisp.json_response(error_json(reason), 500)
Error(NotFound(_)) ->
wisp.json_response(error_json("repo returned NotFound on save"), 500)
}
}
}
}
Три уровня case, и каждый отвечает за свой класс ошибки:
decode.runупал это форма запроса плохая (422, без тела).build_reservationвернулErrorэто домен отверг данные (422, с телом-причиной).repo.saveвернулErrorэто инфраструктура (500).
Заметь, что вся доменная сборка вынесена в build_reservation, и она возвращает Result, а не лезет в HTTP. Хендлер только переводит её исход в статус. Это и есть “тонкий хендлер”.
Чтение брони ещё короче:
fn handle_one(req: Request, repo: ReservationRepo, raw_id: String) -> Response {
use <- wisp.require_method(req, http.Get)
case reservation.reservation_id(raw_id) {
Error(_) -> wisp.not_found()
Ok(id) ->
case repo.load(id) {
Ok(state) -> wisp.ok() |> wisp.json_body(reservation_json(state))
Error(NotFound(_)) -> wisp.not_found()
Error(StorageFailure(reason)) ->
wisp.internal_server_error() |> wisp.json_body(error_json(reason))
}
}
}
Точка входа · второй уровень composition root
make_app собирает граф для веба, но кто-то должен дать ему repo и поднять сервер. Это web/server.gleam, настоящий корень приложения:
// examples/ddd-hotel/gleam/src/web/server.gleam (фрагмент)
pub fn main() -> Nil {
let result = {
use cfg <- result.try(config.from_env())
use #(repo, _stop) <- result.try(
wiring.build_repo(cfg)
|> result.map_error(fn(reason) { config.WiringFailed(reason) }),
)
let app = router.make_app(repo)
let assert Ok(_) =
wisp_mist.handler(app, secret_key_base)
|> mist.new()
|> mist.port(cfg.port)
|> mist.start()
Ok(Nil)
}
// ...обработка result, логи, sleep_forever
}
Здесь видно всю цепочку: читаем конфиг, собираем repo (это 26 · Конфиг и build_repo), отдаём его в make_app, поднимаем mist. Конкретный adapter выбирается тут, один раз, на старте процесса. Ниже по стеку только порт.
Тест без сети · wisp/simulate
Награда за инъекцию repo параметром: HTTP-тест не поднимает сервер и не ходит в сеть. wisp/simulate.browser_request собирает Request-значение, а хендлер это обычная функция, её зовём напрямую:
// examples/ddd-hotel/gleam/test/router_test.gleam
pub fn post_reservation_returns_201_on_valid_body_test() {
let assert Ok(#(repo, _stop)) = in_memory_repo.start()
let app = router.make_app(repo)
let body =
json.object([
#("id", json.string("res_apitest1")),
#("guest", json.string("cust_alice")),
#("room", json.string("101")),
#("check_in", json.int(20_260_101)),
#("check_out", json.int(20_260_103)),
])
let response =
simulate.browser_request(http.Post, "/reservations")
|> simulate.json_body(body)
|> app()
response.status |> should.equal(201)
}
Здесь composition root теста подкладывает in_memory_repo. Тот же make_app, что в проде, но граф другой. Битый JSON даёт 422, неизвестный id даёт 404, неизвестный путь даёт 404. Четыре теста гоняются за миллисекунды, потому что сети нет.
Критика · что не покрыто
- Только два эндпоинта. Нет check-in, check-out, cancel. Это команды над уже существующей бронью: загрузи, дёрни доменный переход, сохрани. Шаблон один, добавить легко (ДЗ).
- Маппинг ошибок руками. Сейчас перевод domain error в HTTP status размазан по
case-ам. В большой системе это одна функцияerror_to_response(ДЗ). Невалидный переход стоило бы отдавать как409 Conflict, а не422. - Нет middleware. Логирование, rate-limit, request id. Wisp кладёт их поверх
make_appбез правки хендлеров (ДЗ). - secret_key_base захардкожен. В
server.gleamэто строка-заглушка. В проде она приедет из конфига урока 26.
Takeaway
Одна фраза:
Composition root это единственное место, где конкретные зависимости собираются в граф. В Gleam это
make_app(repo): фабрика замыкает repo в хендлер, и ниже по стеку домен видит только порт.
ДЗ
Дальше
Следующая ката · 25. SQLite-репозиторий через sqlight. Меняем in-memory adapter на настоящий SQLite, и не трогаем ни домен, ни хендлеры, ни тесты домена. Тот самый контракт-тест из урока 22 прогоним для нового adapter без единой правки.
Параллельно полезно перечитать:
- 12. Web с Wisp, фундамент по Wisp. Сегодня мы строили на нём composition root.
- 22. Repository как порт, про порт-запись. Composition root это место, где порт встречается с реализацией.