Раздел 21 · Haskell

Моделируем домен проката настолок

senior~35 мин

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

Моделируем домен проката настолок

Стартуем третий и последний большой проект раздела: backend на Servant для клубного проката настолок. Игры, экземпляры, участники, аренды с дедлайнами, брони, лимит активных аренд. Как и в DDD-серии, начинаем не с эндпоинтов и не с базы, а с типов. Если домен собран правильно, невозможные состояния просто не наберутся, и половина будущих багов умирает ещё до первого запроса.

Идея

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

Главная мысль урока ровно та же, что в уроке про алгебраические типы: сначала описать предметную область так, чтобы неверное состояние нельзя было выразить, и только потом писать логику. Это тот же словарь, на котором мы говорили в Decider-уроках на Gleam и на Effect, только теперь на Haskell.

Идентификаторы это не голый Int

GameId и MemberId оба под капотом числа. Если оставить их как Int, рано или поздно ты передашь идентификатор игры туда, где ждут идентификатор участника, и компилятор смолчит: для него это два одинаковых Int. Поэтому каждый идентификатор это newtype со скрытым конструктором.

module Meeple.Domain
  ( GameId, unGameId, mkGameId
  , CopyId, unCopyId, mkCopyId
  , MemberId, unMemberId, mkMemberId
  -- ... остальной экспорт
  ) where

newtype GameId = GameId Int64
  deriving stock (Eq, Ord, Show)

newtype CopyId = CopyId Int64
  deriving stock (Eq, Ord, Show)

newtype MemberId = MemberId Int64
  deriving stock (Eq, Ord, Show)

Смотри на список экспорта: наружу уходят имя типа GameId, аксессор unGameId и функция mkGameId, но конструктор не уходит. Снаружи модуля написать GameId 5 нельзя, конструктор спрятан. Единственный вход это smart constructor:

mkPositiveId :: Text -> (Int64 -> a) -> Int64 -> Either Text a
mkPositiveId label wrap n
  | n > 0 = Right (wrap n)
  | otherwise = Left (label <> " должен быть положительным, получили " <> tshow n)

mkGameId :: Int64 -> Either Text GameId
mkGameId = mkPositiveId "GameId" GameId

mkCopyId :: Int64 -> Either Text CopyId
mkCopyId = mkPositiveId "CopyId" CopyId

mkMemberId :: Int64 -> Either Text MemberId
mkMemberId = mkPositiveId "MemberId" MemberId

Один инвариант (id строго положителен) на все три типа, вынесенный в общий mkPositiveId. Возвращаем Either Text, а не бросаем исключение: вызывающий код обязан разобрать ошибку, забыть про неё не получится. Это и есть Parse, don’t validate Алексис Кинг: как только число стало GameId, оно уже проверено, и дальше по программе его проверять не нужно.

Срок возврата: проверяющий конструктор и доверенная реконструкция

Срок аренды это тоже newtype, но с интересной развилкой. Когда мы оформляем новую аренду, срок должен быть в будущем: задним числом аренду не выдают. А когда мы читаем старую аренду из базы, срок вполне может быть в прошлом (просрочка это нормальное состояние). Один тип, два пути создания.

newtype DueDate = DueDate Day
  deriving stock (Eq, Ord, Show)

unDueDate :: DueDate -> Day
unDueDate (DueDate d) = d

-- Проверяющий конструктор для нового срока: данные пришли снаружи.
mkDueDate :: Day -> Day -> Either Text DueDate
mkDueDate today due
  | due > today = Right (DueDate due)
  | otherwise = Left "срок возврата должен быть позже дня выдачи"

-- Доверенная реконструкция из хранилища: значение уже проверено,
-- проверка будущего тут была бы вредна.
dueDateFromDay :: Day -> DueDate
dueDateFromDay = DueDate

Запомни этот приём: для недоверенного входа проверяющий smart constructor (mkDueDate), для доверенного хранилища тонкая реконструкция (dueDateFromDay). Граница проходит по источнику данных, а не по типу.

Статус экземпляра: невозможное состояние невыразимо

Вот сердце домена. Физический экземпляр игры бывает свободен, в аренде или забронирован. Можно было бы сделать data CopyStatus = Available | Rented | Reserved и хранить арендатора отдельным полем. Но тогда возможно состояние “в аренде, а кто арендовал, неизвестно”, и его придётся отлавливать проверкой в рантайме.

Сделаем иначе: пусть каждый конструктор несёт ровно те данные, которые осмысленны в этом состоянии.

data CopyStatus
  = Available
  | Rented MemberId DueDate
  | Reserved MemberId
  deriving stock (Eq, Show)

Теперь Rented обязан знать арендатора и срок, Reserved обязан знать, кто поставил бронь. Состояние “арендован неизвестно кем” невыразимо: его нельзя собрать, такого значения в типе нет. Это и есть приём “сделать невозможные состояния непредставимыми”, разобранный в уроке про ADT и применённый к живому домену.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.