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

Repository как порт: in-memory backend для тестов

middle-senior~35 мин

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

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-книги. Контракт:

  1. Repository загружает агрегат по identity и сохраняет его целиком.
  2. Repository скрывает механику хранения: домен не знает про SQL, ORM, ключи кеширования.
  3. 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, это инкарнация той самой идеи “чистый домен в центре”.