Skip to content

Демонстрационный проект Эргономичного подхода - сервис бронирования номеров в отелях

Notifications You must be signed in to change notification settings

ergonomic-code/Project-Mariotte

Repository files navigation

Project Mariotte

Карта проекта

В этом разделе собранны "Points of Interest" кодовой базы — ссылки на код, иллюстрирующий применение Эргономичного подхода или просто штуки, которые я считаю малоизвестными и полезными.

  1. Код, иллюстрирующий Эргономичный подход

    1. Тестирование

      1. Тесты на поведение

      2. Эффективный запуск PostgreSQL для тестов

      3. Доменно-специфичный матчер

      4. Генерация случайных тестовых данных

      5. ObjectMothers

      6. Клиенты приложений и API фич

    2. Неизменяемая модель данных

    3. Модель на базе диаграммы эффектов

      1. Простая операция

      2. Сложная операция (аля ROP на guard clause-ах, структурный дизайн)

      3. Простой ресурс

      4. Сложный ресурс

    4. Кодирование

      1. Обход проблемы интеграции Spring Transactions с Kotlin Result

        В идеальном мире, операции должны явно возвращать результат в случае ошибок. Однако Spring-овые транзакции поддерживают Кotlin Result из стандартной библиотеки (а тащить ради этого Java-вый vavr кажется оверкиллом).

        Для того чтобы обойти эту проблему, я сейчас использую схему, когда операции выбрасывают ошибки исключениями (чтобы откатить транзакцию), а клиенты операций оборачивают их в Result с помощью runCatching.

      2. "Очевидизация" вариантов ответов эндпоинта

        Я придерживаюсь мнения, что явное перечисление всех возможных исходов выполнения операции способствует пониманию кода и снижает вероятность внесения регрессий при его модификации. Для этого я, с одной стороны, оборачиваю исключения в Result, а с другой стороны явно разбираю его на ожидаемые варианты в одном when-выражении.

  2. Прочие "Points of interest"

    1. Верификация Json-схем

      Для того чтобы переиспользовать продовые модели запросов и ответов и при этом обезопасится от поломки обратной совместимости API, я использую Json-схемы.

      Для этого API фич в сигнатурах методов используют те же модели, что и продовые контроллеры, но внутри самостоятельно (де)сериализуют их в JSON-строки и верифицируют их на соответствие схемам.

    2. Problem Details В проекте используется тело ответа по стандарту Problem Details for HTTP APIs. Но в стандарт почему-то входит момент времени возникновения ошибки, что, на мой взгляд, является очень полезной информацией.

      Поэтому я завёл собственную обёртку, которая добавляет время в ответ - ErrorResponse, и Spring-овый обработчик исключений - UnhandledExceptionsHandler, который собирает тела ответа этого типа.

    3. PostgreSQL generate_series

      SQL-запросы можно выполнять не только к таблицам и представлениям, но и к результатам выполнения некоторых функций, например generate_series.

    4. Пессимистичные блокировки в Spring Data JDBC

      SQL позволяет обеспечивать синхронизацию потоков приложения с помощью явных блокировок отдельных строк и таблиц. Злоупотреблять этим не стоит, потому как это может привести к дедлокам и проблемам с производительностью и масштабируемостью, но в ограниченных количествах это бывает полезно.

      И Spring Data JDBC есть ограниченная поддержка этой возможности.

    5. Kotlin value-класс

      В Kotlin есть value classes - легковесные обёртки вокруг других типов, которые могут выступать отличными доменно-специфичными типами.

      И на удивление они почти хорошо поддерживаются и Spring Data Jdbc, и Jackson.

    6. RepeatedTest

      В JUnit есть возможность повторять тест-кейсы несколько раз, что может быть полезно для повышения надёжности недетерминированных тестов (тестов, включающих многопоточную работу).

"Техническое задание"

  • Сервис должен обеспечивать бронирования номеров в разных отелях;

  • Сервис должен поддерживать типы номеров, определённых "международным стандартом" ISO 404:404 - люкс и полулюкс;

  • Бронирование определяет только тип номера, но не конкретный номер. Конкретный номер выбирает администратор при заселении гостя;

  • Сервис должен исключать овербукинг — создание большего количества броней номеров определённого типа в одном отеле за определённую дату, чем есть в целом номеров данного типа в отеле;

  • Сервис должен не допускать бронирования, начинающиеся ранее следующих суток относительно момента времени запроса на бронирование.

Системная аналитика

Модель предметной области

ℹ️

Модель предметной области представлена Эргономичной нотации ER-модели

ER.drawio

Спецификация HTTP API

ℹ️

Этот раздел написан в моём самодельном легковесном и слабо формализованном формате описания HTTP-эндпоинтов.

Маппинг ошибок на коды статусов выполнен в соответствии с гайдлайном эргономичного подхода.

Модель RoomReservationRequest

{
  "hotelId": <number>
  "roomType": <number>
  "email": <string:email>
  "from": <string:iso-8601 date>
  "period": <string:iso-8601 duration>
}

Модель ReservationSuccess

{
  "reservationId": <number>
}

Модель ReservationDetails

{
  "hotel": <number>
  "roomType": <number>
  "email": <string:email>
  "from": <string:iso-8601 date>
  "period": <string:iso-8601 duration>
}

Модель ErrorResponse

Соответствует спецификации 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>
}

Метод reserveRoom

Метод бронирования номера в отеле на период.

Предусловия:

  • Передан идентификатор отеля, существующий в БД;

  • Передан корректный тип номера;

  • В заданном отеле есть номера заданного типа;

  • Переданная дата "от" находится в будущем, не менее чем на один день от момента поступления запроса;

  • Длительность периода бронирования составляет не менее одного дня;

  • В запрошенном отеле за каждый запрошенный день есть свободный номер запрошенного типа.

Постусловия:

  • В БД в коллекцию бронирований добавлена бронь, соответствующая запросу;

  • Количество доступных номеров указанного типа за указанный период уменьшено на 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> // при обработке запроса произошла ошибка неожиданная ошибка

Метод getReservationDetails

Метод просмотра информации о бронировании

Предусловия:

  • Передан идентификатор существующей брони;

Постусловия:

  • Возвращена информация о бронировании, соответствующая переданному идентификатору

GET /guest/reservations/{reservationId}
>

<
  201
    <ReservationDetails>

  400
    <ErrorResponse> // некорректный зарос

  500
    <ErrorResponse> // при обработке запроса произошла ошибка неожиданная ошибка

Диаграмма эффектов

ℹ️

Здесь используется обновлённая и пока неописанная нотация Диаграммы эффектов - зелёные шестиугольники — это события, фиолетовые прямоугольники и круги — операции, оранжевые (коричневые?) прямоугольники - ресурсы, ресурсы в ресурсах — агрегированные ресурсы.

arch.drawio

Структурная схема (Граф вызовов)

ℹ️

Здесь красными блоками отмечены функции с эффектами (функции, выполняющие ввод-вывод - императивная оболочка), а синими - чистые функции трансформаций (функциональное ядро).

Call graph.drawio

Структура пакетов

packages
Figure 1. Пакеты приложения со стержневыми классами и зависимостями между ними.

 

Table 1. Описание пакетов приложения.
Пакет Описание

mariotte

Код, специфичный для данного приложения.

mariotte.apps

Код приложений проекта.

Я придерживаюсь модели, когда у одного проекта может быть несколько приложений, для разных ролей пользователей и/или с разным UX.

Как правило, у проекта есть приложения анонима для входа в систему, приложения основного пользователя для работы с системой, приложение администратора для настройки системы, приложение DevOps-инженера/инфраструктуры для эксплуатации и технического обслуживания системы.

В этом проекте есть только приложение гостя — основного пользователя системы.

mariotte.apps.guest

Код, обеспечивающий работу приложения гостя.

mariotte.apps.guest.reservations

Код, обеспечивающий работу юзкейса "Бронирование номера".

Пакеты отдельных приложений можно декомпозировать по экранам пользовательского интерфейса, юзкейсам и фичам, в зависимости от ваших предпочтений.

mariotte.apps.infra

Пакет инфраструктурных бинов всех (web-) приложений.

В любом пакете проекта может быть подпакет infra, который содержит определения инфраструктурных Spring-бинов (или их аналогов в других фреймворках), обеспечивающих работу модуля родительского пакета.

В данном случае в этом пакете содержится бин, глобального обработчика ошибок, который рендерит ошибки в виде ProblemDetails с timestamp-ом.

mariotte.apps.platform

Библиотечный код, используемый всеми приложениями.

Эвристика для разделения инфраструктурного и библиотечного кода — количество и срок жизни экземпляров классов. Если экземпляров создаётся немного и живут они долго — такие штуки идут в infra. Если это статические (top-level) функции или объекты, которые создаются в больших количествах и живут миллисекунды - такой код идёт в platform.

mariotte.apps.platform.spring

Библиотечный код, дополняющий проекты Spring.

У меня нет жёсткого гайдлайна по декомпозиции кода платформы, но в целом я стараюсь чтобы структура пакетов напоминала структуру пакетов дополняемого кода.

mariotte.apps.platform.spring.http

Расширения классов в пакете org.springframework.http

mariotte.core

Ядро (предметная область, сущности, ресурсы) системы.

Части ядра используются разными приложениями.

Например, часть приложения с профилями пользователей может использоваться как приложением основного пользователя для доступа к собственному профилю, так и приложением администратора для доступа к профилю любого пользователя.

core содержит ресурсы, управляемые организацией-разработчиком проекта, а ресурсы, управляемые третьими лицами, помещаются в пакет i9ns (integrations).

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

Расширения классов из пакета java.lang

platform.postgres

Расширения классов JDBC-драйвера PostgreSQL.

platform.spring

Расширения модулей Spring

platform.spring.jdbc

Расширения классов из пакета org.springframework.jdbc

platform.spring.data

Дополнения функциональности модуля Spring Data Commons

Требования к окружению

  • JDK >=21

  • Docker >=26.1.3

Запуск тестов

./gradlew test

Сборка проекта

./gradlew build

Запуск проекта

java -jar -Dspring.profiles.active=demo build/libs/project-mariotte-0.0.1-SNAPSHOT.jar

Примеры запросов

Бронирование номера
curl --url 'http://localhost:8080/guest/reservations' \
     --header 'Content-Type: application/json' \
     --data-raw '{
        "hotelId": 1,
        "roomType": 1,
        "from": "2024-06-14",
        "period": "p1d",
        "email": "test@azhidkov.pro"
     }'
Запрос информации о брони
curl --url 'http://localhost:8080/guest/reservations/1'

About

Демонстрационный проект Эргономичного подхода - сервис бронирования номеров в отелях

Topics

Resources

Stars

Watchers

Forks

Languages