Моделируем домен проката настолок
Стартуем третий и последний большой проект раздела: 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.