Проект Мариотт - это демопроект Эргономичного подхода на примере сервиса бронирования номеров в отелях.
В этом разделе собранны "Points of Interest" кодовой базы — ссылки на код, иллюстрирующий применение Эргономичного подхода или просто штуки, которые я считаю малоизвестными и полезными.
-
Код, иллюстрирующий Эргономичный подход
-
Тестирование
-
Тесты на поведение
-
Эффективный запуск PostgreSQL для тестов
-
Доменно-специфичный матчер
-
Генерация случайных тестовых данных
-
ObjectMothers
-
Клиенты приложений и API фич
-
-
Неизменяемая модель данных
-
Модель на базе диаграммы эффектов
-
Простая операция
-
Сложная операция (аля ROP на guard clause-ах, структурный дизайн)
-
Простой ресурс
-
Сложный ресурс
-
-
Кодирование
-
Обход проблемы интеграции Spring Transactions с Kotlin Result
В идеальном мире, операции должны явно возвращать результат в случае ошибок. Однако Spring-овые транзакции поддерживают Кotlin
Result
из стандартной библиотеки (а тащить ради этого Java-вый vavr кажется оверкиллом).Для того чтобы обойти эту проблему, я сейчас использую схему, когда операции выбрасывают ошибки исключениями (чтобы откатить транзакцию), а клиенты операций оборачивают их в
Result
с помощьюrunCatching
. -
"Очевидизация" вариантов ответов эндпоинта
Я придерживаюсь мнения, что явное перечисление всех возможных исходов выполнения операции способствует пониманию кода и снижает вероятность внесения регрессий при его модификации. Для этого я, с одной стороны, оборачиваю исключения в Result, а с другой стороны явно разбираю его на ожидаемые варианты в одном
when
-выражении.
-
-
-
Прочие "Points of interest"
-
Верификация Json-схем
Для того чтобы переиспользовать продовые модели запросов и ответов и при этом обезопасится от поломки обратной совместимости API, я использую Json-схемы.
Для этого API фич в сигнатурах методов используют те же модели, что и продовые контроллеры, но внутри самостоятельно (де)сериализуют их в JSON-строки и верифицируют их на соответствие схемам.
-
Problem Details В проекте используется тело ответа по стандарту Problem Details for HTTP APIs. Но в стандарт почему-то входит момент времени возникновения ошибки, что, на мой взгляд, является очень полезной информацией.
Поэтому я завёл собственную обёртку, которая добавляет время в ответ - ErrorResponse, и Spring-овый обработчик исключений - UnhandledExceptionsHandler, который собирает тела ответа этого типа.
-
PostgreSQL generate_series
SQL-запросы можно выполнять не только к таблицам и представлениям, но и к результатам выполнения некоторых функций, например generate_series.
-
Пессимистичные блокировки в Spring Data JDBC
SQL позволяет обеспечивать синхронизацию потоков приложения с помощью явных блокировок отдельных строк и таблиц. Злоупотреблять этим не стоит, потому как это может привести к дедлокам и проблемам с производительностью и масштабируемостью, но в ограниченных количествах это бывает полезно.
И Spring Data JDBC есть ограниченная поддержка этой возможности.
-
Kotlin value-класс
В Kotlin есть value classes - легковесные обёртки вокруг других типов, которые могут выступать отличными доменно-специфичными типами.
И на удивление они почти хорошо поддерживаются и Spring Data Jdbc, и Jackson.
-
RepeatedTest
В JUnit есть возможность повторять тест-кейсы несколько раз, что может быть полезно для повышения надёжности недетерминированных тестов (тестов, включающих многопоточную работу).
-
-
Сервис должен обеспечивать бронирования номеров в разных отелях;
-
Сервис должен поддерживать типы номеров, определённых "международным стандартом" ISO 404:404 - люкс и полулюкс;
-
Бронирование определяет только тип номера, но не конкретный номер. Конкретный номер выбирает администратор при заселении гостя;
-
Сервис должен исключать овербукинг — создание большего количества броней номеров определённого типа в одном отеле за определённую дату, чем есть в целом номеров данного типа в отеле;
-
Сервис должен не допускать бронирования, начинающиеся ранее следующих суток относительно момента времени запроса на бронирование.
ℹ️
|
Этот раздел написан в моём самодельном легковесном и слабо формализованном формате описания HTTP-эндпоинтов. Маппинг ошибок на коды статусов выполнен в соответствии с гайдлайном эргономичного подхода. |
{
"hotelId": <number>
"roomType": <number>
"email": <string:email>
"from": <string:iso-8601 date>
"period": <string:iso-8601 duration>
}
{
"hotel": <number>
"roomType": <number>
"email": <string:email>
"from": <string:iso-8601 date>
"period": <string:iso-8601 duration>
}
Соответствует спецификации Problem Details for HTTP APIs, всегда содержит дополнительное свойство timestamp.
{
"timestamp": <string:iso-8601 timestmap>,
"instance": <string:uri-reference>,
"status": <number:200..600>,
"type": <string:uri-reference>
"title": <string>
"details": <string>
}
Метод бронирования номера в отеле на период.
Предусловия:
-
Передан идентификатор отеля, существующий в БД;
-
Передан корректный тип номера;
-
В заданном отеле есть номера заданного типа;
-
Переданная дата "от" находится в будущем, не менее чем на один день от момента поступления запроса;
-
Длительность периода бронирования составляет не менее одного дня;
-
В запрошенном отеле за каждый запрошенный день есть свободный номер запрошенного типа.
Постусловия:
-
В БД в коллекцию бронирований добавлена бронь, соответствующая запросу;
-
Количество доступных номеров указанного типа за указанный период уменьшено на 1.
POST /guest/reservations
>
<RoomReservationRequest>
<
201
<ReservationSuccess>
400
<ErrorResponse> // некорректный зарос
409
<ErrorResponse:reservation-dates-in-past> // до даты начала резервации осталось менее дня
409
<ErrorResponse:hotel-not-found> // отель с указанным идентификатором не найден
409
<ErrorResponse:room-type-not-found> // номер указанного типа в отеле с указанным идентификатором не найден
409
<ErrorResponse:no-available-rooms> // за запрошенные даты в отеле нет свободных комнат запрошенного типа
500
<ErrorResponse> // при обработке запроса произошла ошибка неожиданная ошибка
Метод просмотра информации о бронировании
Предусловия:
-
Передан идентификатор существующей брони;
Постусловия:
-
Возвращена информация о бронировании, соответствующая переданному идентификатору
GET /guest/reservations/{reservationId}
>
<
201
<ReservationDetails>
400
<ErrorResponse> // некорректный зарос
500
<ErrorResponse> // при обработке запроса произошла ошибка неожиданная ошибка
ℹ️
|
Здесь используется обновлённая и пока неописанная нотация Диаграммы эффектов - зелёные шестиугольники — это события, фиолетовые прямоугольники и круги — операции, оранжевые (коричневые?) прямоугольники - ресурсы, ресурсы в ресурсах — агрегированные ресурсы. |
ℹ️
|
Здесь красными блоками отмечены функции с эффектами (функции, выполняющие ввод-вывод - императивная оболочка), а синими - чистые функции трансформаций (функциональное ядро). |
Пакет | Описание |
---|---|
mariotte |
Код, специфичный для данного приложения. |
mariotte.apps |
Код приложений проекта. Я придерживаюсь модели, когда у одного проекта может быть несколько приложений, для разных ролей пользователей и/или с разным UX. Как правило, у проекта есть приложения анонима для входа в систему, приложения основного пользователя для работы с системой, приложение администратора для настройки системы, приложение DevOps-инженера/инфраструктуры для эксплуатации и технического обслуживания системы. В этом проекте есть только приложение гостя — основного пользователя системы. |
mariotte.apps.guest |
Код, обеспечивающий работу приложения гостя. |
mariotte.apps.guest.reservations |
Код, обеспечивающий работу юзкейса "Бронирование номера". Пакеты отдельных приложений можно декомпозировать по экранам пользовательского интерфейса, юзкейсам и фичам, в зависимости от ваших предпочтений. |
mariotte.apps.infra |
Пакет инфраструктурных бинов всех (web-) приложений. В любом пакете проекта может быть подпакет В данном случае в этом пакете содержится бин, глобального обработчика ошибок, который рендерит ошибки в виде ProblemDetails с timestamp-ом. |
mariotte.apps.platform |
Библиотечный код, используемый всеми приложениями. Эвристика для разделения инфраструктурного и библиотечного кода — количество и срок жизни экземпляров классов.
Если экземпляров создаётся немного и живут они долго — такие штуки идут в |
mariotte.apps.platform.spring |
Библиотечный код, дополняющий проекты Spring. У меня нет жёсткого гайдлайна по декомпозиции кода платформы, но в целом я стараюсь чтобы структура пакетов напоминала структуру пакетов дополняемого кода. |
mariotte.apps.platform.spring.http |
Расширения классов в пакете |
mariotte.core |
Ядро (предметная область, сущности, ресурсы) системы. Части ядра используются разными приложениями. Например, часть приложения с профилями пользователей может использоваться как приложением основного пользователя для доступа к собственному профилю, так и приложением администратора для доступа к профилю любого пользователя.
|
mariotte.core.hotels |
Пакет составного ресурса логического* агрегата "Отель". |
mariotte.core.hotels.rooms |
Пакет ресурса физического агрегата "Номер". |
mariotte.core.hotels.root |
Пакет ресурса физического агрегата "Отель", который является корнем одноимённого логического агрегата. |
mariotte.core.reservations |
Пакет ресурса агрегата "Бронь". |
mariotte.core.infra |
Пакет с инфраструктурными бинами (конвертерами класса Period в данном случае), обеспечивающими работу модулей ядра. |
platform |
Универсальный код, который можно переиспользовать во множестве приложений. Как вариант, его можно вынести в отдельную библиотеку, но, на мой взгляд, это создаст лишнюю сцепленность между независимыми проектами, поэтому я обычно такой код копирую по мере необходимости. |
platform.domain.errors |
Фреймворк доменных ошибок, используемый всем прикладным кодом. |
platform.kotlin |
Расширения стандартной библиотеки Kotlin. |
platform.java.lang |
Расширения классов из пакета |
platform.postgres |
Расширения классов JDBC-драйвера PostgreSQL. |
platform.spring |
Расширения модулей Spring |
platform.spring.jdbc |
Расширения классов из пакета |
platform.spring.data |
Дополнения функциональности модуля Spring Data Commons |
java -jar -Dspring.profiles.active=demo build/libs/project-mariotte-0.0.1-SNAPSHOT.jar
curl --url 'https://localhost:8080/guest/reservations' \
--header 'Content-Type: application/json' \
--data-raw '{
"hotelId": 1,
"roomType": 1,
"from": "2024-06-14",
"period": "p1d",
"email": "[email protected]"
}'
curl --url 'https://localhost:8080/guest/reservations/1'