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

Composition root на Wisp: где собирается граф зависимостей

senior~30 мин

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

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 по Сееманну

Марк Сееманн формулирует так: приложение должно собирать граф зависимостей в единственном месте, максимально близко к точке входа. Всё, что ниже, принимает зависимости и не создаёт их само.

Зачем единственное место:

  1. Тестируемость. В тесте composition root другой: вместо sqlite подкладываем in-memory. Домен и хендлеры те же, граф другой.
  2. Понятность. Хочешь узнать, что с чем связано? Смотришь в один файл, не охотишься по коду за new-ами.
  3. Чистота низа. Хендлер не знает, какой 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, и каждый отвечает за свой класс ошибки:

  1. decode.run упал это форма запроса плохая (422, без тела).
  2. build_reservation вернул Error это домен отверг данные (422, с телом-причиной).
  3. 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 это место, где порт встречается с реализацией.