Repository как порт: in-memory backend для тестов
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Repository как порт: in-memory backend для тестов
Шестая ката. Открываем второй блок первой половины серии: переходим от чистого домена к работе с инфраструктурой. Repository, это первая абстракция, которая стоит между агрегатом и БД. Реализуем её на Gleam идиоматично, без классов и интерфейсов.
Сцена · домен не знает про БД
В уроках 17-21 у нас в examples/ddd-hotel/gleam/src/domain/ лежит чистая бизнес-логика. Все функции pure, возвращают Result, не ходят в сеть, не пишут на диск. Тесты гоняются без инфраструктуры.
Это удобно для unit-тестов, но не закрывает реальный сценарий: бронь должна сохраняться. Куда? Сегодня in-memory, завтра SQLite, послезавтра, может быть, Postgres или DynamoDB. Если завести db.save(reservation) прямо в domain/reservation.gleam, мы потеряем главное свойство домена: чистоту и тестируемость.
Решение классическое из Hexagonal-архитектуры (Алистер Кокберн, 2005): между доменом и реальным хранилищем стоит порт. Адаптеры (in-memory, SQLite, Postgres) удовлетворяют порту, и домен не знает, какой именно подключён. Это и есть Repository в исполнении DDD.
Цели каты
- Запомнить, что Repository это port, не реализация.
- Освоить идиоматичный для Gleam способ описать port: запись с полями-функциями.
- Понять, зачем in-memory adapter обёрнут в OTP-actor.
- Написать контракт-тест, который гоняет одни сценарии для разных adapter-ов.
К концу каты в examples/ddd-hotel/gleam/src/ports/reservation_repo.gleam будет порт, в src/adapters/in_memory_repo.gleam adapter на OTP-актере, в test/repo_contract_test.gleam общий контракт-тест.
Концепт · Repository по Эвансу и адаптация в FP
Эванс описывает Repository в главе 6 DDD-книги. Контракт:
- Repository загружает агрегат по identity и сохраняет его целиком.
- Repository скрывает механику хранения: домен не знает про SQL, ORM, ключи кеширования.
- Repository работает только с корнем агрегата. Никаких “найти позицию заказа”, только “найти заказ, у которого есть позиция”.
В OOP-стиле Repository это интерфейс interface OrderRepository { Order load(OrderId id); void save(Order order); } с двумя-тремя реализациями (InMemoryOrderRepository, JpaOrderRepository, …). В Gleam нет интерфейсов; мы делаем то же самое через запись с полями-функциями.
Языковые механики Gleam · record of functions
pub type ReservationRepo {
ReservationRepo(
load: fn(ReservationId) -> Result(Reservation, RepoError),
save: fn(Reservation) -> Result(Nil, RepoError),
)
}
Это тип-произведение: запись с двумя полями. Оба поля, функции. Внешний код, который принимает repo: ReservationRepo, дёргает repo.load(id) и repo.save(state). Реализация лежит в этих функциях, и она может быть любой: in-memory Dict, SQLite, REST API.
Этот же паттерн в Haskell называется Handle pattern, в Rust, “dependency record”. Подход одинаково работает везде, где есть first-class функции и записи.
Сильные стороны
- Никаких неявных глобалов. Repo приходит как параметр. Никто не зовёт
Singleton.getRepo(). - Подменяется легко. В тесте передаём in-memory, в проде, sqlite. Контракт один.
- Композиция через функции. Можно навесить декоратор:
cached_repo(other_repo)возвращает новыйReservationRepoс теми же полями, ноloadсначала смотрит в кеш.
Цена
- Нет typeclass-а => нельзя сказать “любая запись с подобной формой подходит”. Нужно явно передавать.
- Boilerplate в конструкторе. Каждый adapter руками собирает запись с реализациями всех полей.
В обмен мы получаем максимальную прозрачность: глядя на сигнатуру fn handle_command(cmd: Command, repo: ReservationRepo) -> ..., ты сразу видишь все зависимости. Никакой магии.
Концепт · in-memory adapter через OTP-actor
In-memory Repository, простейший adapter. Один Dict от id к Reservation. Но если несколько HTTP-запросов одновременно дёргают save и load, у нас гонка: два потока могут перезаписать друг друга.
В BEAM правильное решение, OTP actor. Один процесс владеет Dict, любые load/save приходят как сообщения, actor обрабатывает их по одному. Гонки невозможны: сама конструкция их исключает.
Альтернативы:
- ETS-таблица. Системное in-memory хранилище ключ-значение на BEAM, читает параллельно. Быстрее actor-а, но низкоуровневее.
- gleam/concurrency/mutex. Явный мьютекс над Dict. Работает, но менее идиоматично для BEAM.
Для каты выбираем actor: он наглядно показывает, как Gleam работает с concurrency без try/lock/finally.
Задача · сигнатуры
Открой examples/ddd-hotel/gleam/src/ports/reservation_repo.gleam:
import domain/reservation.{type Reservation, type ReservationId}
pub type RepoError {
NotFound(reservation_id: String)
StorageFailure(reason: String)
}
pub type ReservationRepo {
ReservationRepo(
load: fn(ReservationId) -> Result(Reservation, RepoError),
save: fn(Reservation) -> Result(Nil, RepoError),
)
}
Контракт:
load(id)возвращает либоOk(reservation), либоError(NotFound(id))если такой нет, либоError(StorageFailure(reason))если что-то сломалось в хранилище.save(state)идемпотентен: повторный save с тем же id обновляет запись (upsert). На NotPlaced возвращает StorageFailure (нет id, нечего сохранять).
Открой examples/ddd-hotel/gleam/src/adapters/in_memory_repo.gleam:
pub type Message {
Load(reply_with: Subject(Result(Reservation, RepoError)), id: ReservationId)
Save(reply_with: Subject(Result(Nil, RepoError)), state: Reservation)
}
pub fn start() -> Result(#(ReservationRepo, Subject(Message)), actor.StartError)
start() запускает actor с пустым Dict, возвращает готовую ReservationRepo-запись (с замкнутыми на subject load/save) и сам subject (для shutdown в тестах).
Решай прямо здесь: тесты прогонятся в песочнице, а подсказки и разбор ниже открывай, только если застрял.
Подсказки
gleam/erlang/processдаётSubject(message). Это типизированный канал сообщений: actor отвечает на subject, sender ждёт ответа черезprocess.call(subject, timeout, fn).actor.new(state) |> actor.on_message(handler) |> actor.start()стартует actor. Handler принимает(state, message) -> actor.Next(state, message).actor.continue(state)это продолжить с новым state.actor.stop()останавливает actor (нам не нужно сейчас).- На стороне load/save в
ReservationRepoмы пакуем замыкание над subject:fn(id) { process.call(subject, 1000, fn(reply) { Load(reply, id) }) }. 1000 это таймаут в мс.
Разбор · полное решение
Порт смотри в examples/ddd-hotel/gleam/src/ports/reservation_repo.gleam. Adapter:
// examples/ddd-hotel/gleam/src/adapters/in_memory_repo.gleam
import gleam/dict.{type Dict}
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import domain/reservation.{type Reservation, type ReservationId}
import ports/reservation_repo.{
type RepoError, type ReservationRepo, NotFound, ReservationRepo,
StorageFailure,
}
type State = Dict(ReservationId, Reservation)
pub type Message {
Load(reply_with: Subject(Result(Reservation, RepoError)), id: ReservationId)
Save(reply_with: Subject(Result(Nil, RepoError)), state: Reservation)
}
fn handle(store: State, message: Message) -> actor.Next(State, Message) {
case message {
Load(reply, id) -> {
let result = case dict.get(store, id) {
Ok(value) -> Ok(value)
Error(Nil) ->
Error(NotFound(reservation_id: reservation.reservation_id_to_string(id)))
}
process.send(reply, result)
actor.continue(store)
}
Save(reply, state) -> {
case reservation.id_of(state) {
Error(Nil) -> {
process.send(reply, Error(StorageFailure(reason: "cannot save NotPlaced")))
actor.continue(store)
}
Ok(id) -> {
process.send(reply, Ok(Nil))
actor.continue(dict.insert(store, id, state))
}
}
}
}
}
pub fn start() -> Result(#(ReservationRepo, Subject(Message)), actor.StartError) {
case actor.new(dict.new()) |> actor.on_message(handle) |> actor.start() {
Ok(started) -> {
let subject = started.data
Ok(#(
ReservationRepo(
load: fn(id) { process.call(subject, 1000, fn(reply) { Load(reply, id) }) },
save: fn(state) { process.call(subject, 1000, fn(reply) { Save(reply, state) }) },
),
subject,
))
}
Error(error) -> Error(error)
}
}
Шестьдесят строк, и у нас полноценное in-memory хранилище с типизированными ошибками, безопасное при параллельном доступе. Никакого synchronized, никаких Mutex.lock(). Actor сериализует обращения сам.
Заметь: id_of это helper, добавленный в domain/reservation.gleam рядом с FSM-переходами. Он возвращает Result(ReservationId, Nil): у NotPlaced id нет, на остальных вариантах есть.
Контракт-тест
Главная награда от port/adapter, один тест для всех adapter-ов. В test/repo_contract_test.gleam пишем общий сценарий и прогоняем его для обоих:
fn contract_test(repo: ReservationRepo) {
let state = fixture()
let assert Ok(id) = reservation.id_of(state)
let assert Ok(Nil) = repo.save(state)
let assert Ok(loaded) = repo.load(id)
loaded |> should.equal(state)
// unknown id -> NotFound
let assert Ok(unknown) = reservation.reservation_id("res_unknown1")
case repo.load(unknown) {
Error(NotFound(_)) -> Nil
_ -> panic as "ожидался NotFound"
}
}
pub fn in_memory_repo_contract_test() {
let assert Ok(#(repo, _stop)) = in_memory_repo.start()
contract_test(repo)
}
В 25. SQLite Repository добавим вторую функцию sqlite_repo_contract_test(), которая откроет SQLite в :memory: и прогонит тот же contract_test. Если новый adapter ломает контракт, тест упадёт.
Критика · что не покрыто
- Транзакционность. In-memory adapter не знает про границы транзакций. SQLite-adapter в уроке 25 тоже сделаем без
BEGIN/COMMITдля каждого save, потому что отдельный save и так атомарен. Для cross-aggregate операций нужна явная транзакция, это разберём в R3 при event sourcing. - Кеширование. Простая декорация:
cached_repo(inner, lru) -> ReservationRepo. Дешёво написать, ДЗ. - Distributed lock на save. Если несколько экземпляров приложения параллельно меняют одну бронь, нужен optimistic concurrency (expectedVersion). Это event sourcing-тематика, R3 урок 23.
- Pagination и query. Repository здесь работает только по id. Списки и фильтры (
find_by_room,list_active) добавляются по мере роста UI, об этом ДЗ.
Takeaway
Одна фраза:
Repository это контракт, а не реализация. На Gleam, это запись с полями-функциями, которые принимает domain как обычный параметр.
ДЗ
Дальше
Следующая ката · 23. Bounded Contexts, разделение словарей. Берём один и тот же Customer из разных bounded context-ов и показываем, что это два разных типа. Bridge-функция переводит данные через границу явно.
Параллельно полезно перечитать:
- 10. Процессы и OTP, про actor и Subject. Сегодня мы взяли это как готовый инструмент, фундамент там.
- 16. Functional DDD intro, раздел про Hexagonal. Port/adapter, это инкарнация той самой идеи “чистый домен в центре”.