Влияние дизайна HTTP API на дизайн модели
June 1, 2024
Введение
Мне недавно попалось на глаза тестовое задание, где соискателю давался (паршивый) код реализации операции бронирования номера в отеле и предлагалось его отрефакторить.
И я подумал, что это отличный повод продемонстрировать рефакторинг проекта к Эргономичному подходу. Так родился Проект Мариотт - демонстрационный проект Эргономичного подхода на базе вымышленного сервиса бронирования номеров в отелях.
Я ещё полторы недели назад писал, что забацал проект и написал мясо поста с его и не могу написать только введение.
Но ни проект, ни пост до сих пор не опубликованы. Написать введение я отчаялся, поэтому решил вместо поста просто тщательно задокументировать код. И по ходу дела внёс пачку правок, часть которых описал в этом посте.
Первая версия HTTP API
В оригинальном коде на вход методу подавалось такое тело запроса:
{
"hotel_id": <number>
"room_type_id": <number>
"email": <string:email>
"from": <string:iso-8601 timestamp>
"to": <string:iso-8601 timestamp>
}
Обратите внимание, на способ передачи интервала брони — моменты времени от и до.
А из ошибок была только 500-ка в случае отсутствия свободных номеров.
Вторая версия API
Взявшись за рефакторинг АПИ, перовое что я сделал — сузил формат границ интервала до дат (так как далее это в коде всё равно происходило):
{
"hotel_id": <number>
"room_type_id": <number>
"email": <string:email>
"from": <string:iso-8601 date>
"to": <string:iso-8601 date>
}
Это снижает вероятность формирования некорректных ожиданий у разработчиков клиентов этого АПИ о том, что комнаты можно бронировать с точностью до секунд.
Вторым делом я расписал возможные ошибки АПИ исходя из текущей стратегии сигнализации об ошибках в Эргономичном подходе:
400
<RequestFailed.type=reservation-dates-in-past> // до даты начала резервации осталось менее дня
400
<RequestFailed.type=invalid-reservation-dates> // дата резервации "до" меньше либо равна дате "от"
400
<RequestFailed // некорректный зарос
409
<RequestFailed.type=hotel-not-found> // отель с указанным идентификатором не найден
409
<RequestFailed.type=room-type-not-found> // номер указанного типа в отеле с указанным идентификатором не найден
409
<RequestFailed.type=no-available-rooms> // за запрошенные даты в отеле нет свободных комнат запрошенного типа
500
<RequestFailed> // при обработке запроса произошла ошибка
Тут я исправил две вещи:
- В целом точнее прописал контракт метода;
- Привёл код ошибки отсутствия свободных комнат в соответствие со спецификацией (500 - это неожиданные условия).
И с таким АПИ я приступил к реализации проекта по ЭП.
Первая версия модели
Для реализации этого АПИ я завёл DTO запроса:
data class RoomReservationRequest(
val hotelId: Int,
val roomType: RoomType,
val email: String,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
val from: LocalDate,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
val to: LocalDate
)
Сущность бронирования:
@Table("reservations")
data class Reservation(
@JsonProperty("hotel")
val hotelRef: HotelRef,
val roomType: RoomType,
val email: String,
val from: LocalDate,
val to: LocalDate,
@Id
val id: Int = 0
)
И функцию парсинга ДТО в сущность (в духе parse don’t validate):
object Reservations {
fun reservationFromRequest(request: RoomReservationRequest, now: LocalDate): Result<Reservation> {
if (request.from > request.to) {
return Result.failure(InvalidReservationDatesException(request.from, request.to))
}
if (request.from <= now) {
return Result.failure(ReservationDatesInPastException(request.from))
}
val reservation =
Reservation(Hotel.ref(request.hotelId), request.roomType, request.email, request.from, request.to)
return Result.success(reservation)
}
}
Далее с такой версией реализации сервиса я пошёл писать пост, а потом документировать код.
Третья версия АПИ
В процессе рефакторинга мне на интуитивном уровне стало не нравиться то, что проверка даты начала резервации (что она в будущем) находилась в функции парсинга запроса.
Размышляя о том, где должна находиться эта логика, я перечитал пост parse don’t validate и полистал Domain Modeling Made Functional, но не нашёл ответа ни в посте, ни в книге.
Внезапно финальным доводом в пользу переноса проверки в операцию для меня стал собственный гайдлайн выбора 400-ого статуса ошибки: … Эту ошибку можно выявить, основываясь только на запросе.
Мою же ошибку нельзя было выявить, только основываясь на запросе — один и тот же запрос может быть валидным и невалидными в разные моменты времени.
Эти рассуждения привели меня к тому, чтобы, помимо собственно правки статуса этой ошибки, ещё и вынести проверку даты из функции парсинга запроса в функцию операции резервации (ака метода юз-кейса). И я считаю, что это повысило наглядность кода и упростило понимание бизнес-логики операции резервации.
А потом у меня случился приступ Hammock Driven Development.
Спустя два-три часа после упражнений с проверкой даты, в пол-одиннадцатого ночи, посреди чтения перед сном From objects to functions мне в голову пришла ещё одна идея, как улучшить АПИ.
Четвёртая версия АПИ
Мысль заключалась в том, что DTO-шка допускает представление невалидного состояния, когда дата начала брони больше даты конца и этого можно избежать.
Для этого надо поменять дату окончания брони на её длительность.
Но какой тип использовать для представления длительности?
Просто Int будет не сильно лучше - он может быть отрицательным и мы получим ту же проблему.
В Kotlin есть беззнаковый UInt - это уже лучше, но всё равно допускает невалидное значение (0) и в целом слишком… эээммм… примитивный.
В Java есть тип Period - он уже точно отражает смысл того, что мы хотим передавать, но всё ещё допускает невалидное состояние в виде длительности в 0 дней.
Поэтому я решил завести вокруг периода value class с проверкой на то, что период непустой:
@JvmInline
value class ReservationPeriod(
val period: Period
) {
val days: UInt
get() = period.days.toUInt()
init {
require(period.days > 0) { "Reservation period should more than 0 days" }
}
}
И только закончив с АПИ, я осознал и поправил ту же проблему в модели.
В итоге мне удалось свести конвертацию DTO в сущность просто до вызова конструктора:
object Reservations {
fun reservationFromRequest(request: RoomReservationRequest): Reservation =
Reservation(Hotel.ref(request.hotelId), request.roomType, request.email, request.from, request.period)
}
И избавиться от одного из типов ошибок (InvalidReservationDatesException). Хотя справедливости ради я немного считерил в этой версии, перенеся проверку в конструктор ReservationPeriod, и избавиться от этого типа ошибки можно было и в оригинальном варианте.
Любопытно, что Trainer Advisor, фактически аналогичную штуку в виде приёма я сразу сделал в виде даты начала и длительности. Это к тому, насколько наши проектные решения продиктованы средой.
Заключение
Итого в стремлении сделать АПИ пуленепробиваемым и соответствующим стандартам я попутно существенно улучшил дизайн модели:
- Исключил даже теоретическую возможность создать невалидный объект резервации;
- Перенёс важное бизнес-правило в правильное место;
- Существенно упростил код конвертации DTO в сущность;
- Вынес один из типов ошибок за пределы прикладного кода.
Профит? Я считаю профит.
Надеюсь, на следующей неделе у меня процесс сойдётся и я опубликую весь проект целиком.