Конфиг и build_repo: финальная сборка первого блока
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Конфиг и build_repo: финальная сборка первого блока
Десятая ката. Финал первого блока серии. У нас есть домен, два adapter под одним портом, HTTP, composition root. Не хватает одного: решения, какой adapter поднять, и откуда взять это решение. Сегодня закрываем дыру через типизированный конфиг и фабрику.
Сцена · последний незаданный вопрос
В 24-composition-root-http · Composition root на Wisp server.main дёргает wiring.build_repo(cfg) и получает готовый repo. Но что внутри build_repo? И откуда берётся cfg? Это последние два кубика, без которых приложение не запустится.
Решений два, и оба про дисциплину:
- Конфиг это тип, а не словарь строк. Прочитали env, распарсили в
Config, дальше по коду ходит валидированное значение. Битый env это отказ при запуске, а неNilпосреди обработки запроса. - Выбор adapter живёт в одной фабрике.
build_repo(config)это единственное место, которое знает про существование двух конкретных реализаций. Появится Postgres, меняется только она.
Это финальный аккорд composition root: конфиг сверху, фабрика по центру, порт вниз.
Карта урока · что заберёшь через 2 часа
- Заведёшь типизированный
Configи распарсишь его из env черезenvoy, по принципу parse, don’t validate. - Соберёшь
build_repo(config): фабрику, которая поDatabaseKindподнимает нужный adapter и возвращает его вместе с ресурсом для освобождения. - Поймёшь, зачем фабрика отдаёт не только repo, но и handle на ресурс (соединение, actor).
- Закроешь первый блок: десять кат от Email до работающего сервера.
Концепт · конфиг как тип, parse don’t validate
Соблазн читать env там, где он нужен: envoy.get("PORT") посреди старта сервера. Беда в том, что тогда невалидный конфиг всплывает поздно и частями. Порт оказался не числом? Узнаешь, когда сервер уже наполовину поднялся.
Дисциплина из статьи parse, don’t validate: разбери весь env один раз при запуске в значение Config. Если что-то не так, верни Error и не запускайся. После парсинга по коду ходит Config, в котором port: Int уже валиден, а database это уже выбранный вариант, а не строка.
Это та же идея, что в 17-value-object-email · Value Object и Email с opaque-типами: после smart constructor невалидное значение собрать нельзя. Здесь smart constructor для всего приложения это from_env.
Языковые механики Gleam · envoy и Result-цепочка
envoy даёт envoy.get(name) -> Result(String, Nil). Дальше парсинг твой. Цепочка через result.try останавливается на первой ошибке:
pub fn from_env() -> Result(Config, ConfigError) {
use database <- result.try(read_database())
use port <- result.try(read_port())
Ok(Config(database: database, port: port))
}
use x <- result.try(...) это та же use-цепочка, что в декодерах урока 24, но над Result: если read_database вернул Error, from_env сразу возвращает его, до read_port дело не дойдёт. Один отказ останавливает всю сборку конфига.
Задача · сигнатуры
Конфиг в config.gleam:
pub type DatabaseKind {
InMemory
Sqlite(path: String)
}
pub type Config {
Config(database: DatabaseKind, port: Int)
}
pub type ConfigError {
MissingVar(name: String)
InvalidVar(name: String, reason: String)
WiringFailed(reason: String)
}
pub fn from_env() -> Result(Config, ConfigError)
Фабрика в wiring.gleam:
pub type Resource {
InMemoryResource(stop: Subject(in_memory_repo.Message))
SqliteResource(connection: sqlight.Connection)
}
pub fn build_repo(cfg: Config) -> Result(#(ReservationRepo, Resource), String)
pub fn shutdown(resource: Resource) -> Nil
Контракт:
from_env: нетDATABASEэто дефолтInMemory(удобно для разработки).PORTнет это8000.PORTне число или вне1..65535этоInvalidVar.build_repo: поcfg.databaseподнимает нужный adapter. Для SQLite открывает соединение и накатывает миграцию перед возвратом.build_repoвозвращает пару repo иResource.Resourceэто handle дляshutdown(закрыть соединение, остановить actor).
Подсказки
DatabaseKindэто ADT, а не строка. Это значит, чтоbuild_repoделаетcase cfg.database, и компилятор заставит покрыть все варианты. Добавишь Postgres, забудешь ветку, не скомпилируется.- Зачем
Resourceотдельно от repo: repo это контракт работы,Resourceэто владение ресурсом. Кто открыл соединение, тот его и закрывает. Repo про это знать не должен, потому он чистый интерфейс. build_repoвозвращаетResult(..., String), аfrom_envвозвращаетResult(..., ConfigError). Вserver.mainошибку wiring заворачивают вconfig.WiringFailed(reason), чтобы наверх шёл один тип ошибки.- Для SQLite не забудь
sqlite_repo.migrate(conn)междуopenиmake. Без миграции первыйloadупадёт на отсутствии таблицы.
Разбор · фабрика build_repo
// examples/ddd-hotel/gleam/src/wiring.gleam
pub fn build_repo(cfg: Config) -> Result(#(ReservationRepo, Resource), String) {
case cfg.database {
InMemory ->
case in_memory_repo.start() {
Ok(#(repo, subject)) -> Ok(#(repo, InMemoryResource(stop: subject)))
Error(_) -> Error("не удалось запустить in-memory actor")
}
Sqlite(path) ->
case sqlight.open(path) {
Error(error) -> Error("sqlite open: " <> error.message)
Ok(conn) ->
case sqlite_repo.migrate(conn) {
Error(error) -> Error("sqlite migrate: " <> error.message)
Ok(Nil) ->
Ok(#(sqlite_repo.make(conn), SqliteResource(connection: conn)))
}
}
}
}
Вся вариативность приложения сжата в один case. Это единственная функция, которая знает, что adapter-ов два и какие они. web/router.gleam, домен, тесты домена видят только ReservationRepo. Появится Postgres, добавится ветка Postgres(url) ->, и компилятор сам напомнит об этом через проверку полноты case.
shutdown симметричен: освобождает ресурс по его типу.
pub fn shutdown(resource: Resource) -> Nil {
case resource {
InMemoryResource(stop: _subject) -> Nil
// OTP actor завершится сам по уходу владельца subject.
SqliteResource(connection: conn) ->
case sqlight.close(conn) {
Ok(Nil) -> Nil
Error(error) -> {
let _ = string.inspect(error)
Nil
}
}
}
}
Разбор · парсинг конфига
// examples/ddd-hotel/gleam/src/config.gleam (фрагмент)
fn read_database() -> Result(DatabaseKind, ConfigError) {
case envoy.get("DATABASE") {
Error(Nil) -> Ok(InMemory)
Ok(":memory:") -> Ok(InMemory)
Ok("memory") -> Ok(InMemory)
Ok(path) -> Ok(Sqlite(path: path))
}
}
fn read_port() -> Result(Int, ConfigError) {
case envoy.get("PORT") {
Error(Nil) -> Ok(8000)
Ok(value) ->
case int.parse(value) {
Ok(port) ->
case port > 0 && port < 65_536 {
True -> Ok(port)
False -> Error(InvalidVar("PORT", "ожидается 1-65535"))
}
Error(Nil) -> Error(InvalidVar("PORT", "ожидается целое число"))
}
}
}
read_database превращает строку env в вариант ADT: пусто или memory это InMemory, любой другой путь это Sqlite(path). read_port парсит число и проверяет диапазон. После from_env по коду гуляет Config, в котором эти инварианты уже выполнены и проверять их заново не нужно.
Как это собирается вместе
Полная цепочка composition root, которую мы строили четыре урока:
env-переменные
| config.from_env() (урок 26: parse, don't validate)
v
Config(database, port)
| wiring.build_repo(cfg) (урок 26: выбор adapter)
v
#(ReservationRepo, Resource)
| router.make_app(repo) (урок 24: инъекция в хендлер)
v
fn(Request) -> Response
| mist.start(...) (урок 24: точка входа)
v
работающий HTTP-сервер
Каждая стрелка это переход на уровень ниже, и на каждом конкретика растворяется: env это строки, Config это типы, build_repo знает про adapter-ы, make_app видит уже только порт, домен под ним не видит вообще ничего инфраструктурного. Это онион в одном вертикальном разрезе.
Тест · build_repo собирает правильный adapter
// examples/ddd-hotel/gleam/test/wiring_test.gleam
pub fn build_sqlite_in_memory_works_test() {
let cfg = Config(database: Sqlite(path: ":memory:"), port: 8000)
let assert Ok(#(repo, resource)) = wiring.build_repo(cfg)
let #(id, state) = fixture()
let assert Ok(Nil) = repo.save(state)
let assert Ok(loaded) = repo.load(id)
loaded |> should.equal(state)
wiring.shutdown(resource)
}
Тест собирает граф из Config, прогоняет save-load, освобождает ресурс. Симметричный тест с InMemory проверяет вторую ветку. Сам конфиг (from_env) тоже стоит покрыть, подменяя env, это ДЗ.
Критика · что не покрыто
- secret_key_base захардкожен в
server.gleam. Должен ехать из конфига и падать при запуске, если не задан в проде (ДЗ). - shutdown возвращает Nil. Ошибку закрытия соединения он глотает. В проде это
Result, чтобы main мог залогировать и выйти с кодом (ДЗ). - Нет Postgres. Архитектура к нему готова: новый вариант
DatabaseKind, новая веткаbuild_repo, новый adapter под тот же порт. Остальное не шевелится (ДЗ, заглушкой). - Конфиг плоский. Реальное приложение делит env по подсистемам (db, http, mail). Структуру конфига дробят, как только полей становится больше десятка.
Takeaway · и итог первого блока
Одна фраза:
Конфиг это тип, распарсенный из env один раз при запуске. Выбор adapter живёт в одной фабрике
build_repo, и это единственное место, которое знает про конкретные реализации. Всё остальное видит только порт.
Десять кат позади. На руках: Value Object (Email, Money), Entity (Customer), Aggregate (Reservation как FSM), доменные события, Repository как порт, два adapter под ним, bounded context с границей по типу, HTTP через composition root, реальный SQLite и типизированный конфиг. Это полный тактический DDD на чистом ФП, без единого класса. Второй блок (event modeling, Decider, event sourcing, CQRS, saga) начинается с урока 27.
ДЗ
Дальше
Первый блок серии закрыт. Следующая ката открывает второй · 27. Event Modeling воркшоп. Отложим код и научимся проектировать систему на бумаге: свимлейн с командами, событиями, read-моделями и экранами. Это словарь, без которого Decider в уроке 28 бьёт в пустоту.
Параллельно полезно перечитать:
- 16. Functional DDD intro, раздел про onion и North Star. Сегодня мы собрали онион целиком: env снаружи, домен в центре.
- 24. Composition root на Wisp, там начало цепочки сборки, которую сегодня мы довели до env.