16 KiB
Ревью проекта MovieNight Backend
Покрытие обязательных тем
Обозначения: ✅ — есть вклад / правки; ❌ — нет вклада в репозитории.
| Тема | skettiks | glashark | devitq |
|---|---|---|---|
| Скелет проекта (Spring Boot, gradle, базовые настройки) | ✅ | ❌ | ✅ |
| Контракты API (постман / описание запросов) | ? | ? | ❌ |
| Контроллеры | ✅ | ❌ | ❌ |
| Docker | ✅ | ❌ | ✅ |
| SQL (схема + миграции) | ✅ | ✅ | ❌ |
| Spring JPA | ❌ | ❌ | ❌ |
| Spring Test | ❌ | ❌ | ❌ |
Замечания
Замечание 1. InMemory-адаптеры больше не нужны
Файлы:
src/main/kotlin/com/project/movienight/adapters/persistence/inmemory/FilmRepositoryAdapter.ktsrc/main/kotlin/com/project/movienight/adapters/persistence/inmemory/UserRepositoryAdapter.ktsrc/main/kotlin/com/project/movienight/adapters/persistence/inmemory/FilmLibraryRepositoryAdapter.kt
Когда у вас появились реальные репозитории с БД, in-memory адаптеры в src/main/ стали лишними.
Но есть один важный нюанс: новые JDBC-репозитории НЕ реализуют порты FilmRepositoryPort/UserRepositoryPort/FilmLibraryRepositoryPort. Сейчас сервисы (FilmService, UserService) зависят именно от портов, а единственная реализация портов — это in-memory адаптеры.
Замечание 2. Request/Response DTO лучше выносить в отдельные пакеты
Файлы:
src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt:50-58src/main/kotlin/com/project/movienight/adapters/web/UserController.kt:49-56src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt:70-72
Сейчас DTO-классы (CreateFilmRequest, EditFilmRequest, CreateUserRequest, EditUserRequest, CreateFilmLibraryRequest) объявлены прямо в файлах контроллеров. Пока их по 1-2 на файл — это терпимо. Но как только добавятся новые endpoint'ы, валидация и Response-DTO — контроллер будет не читаем.
Я могу предложить такую структуру:
adapters/web/
├── FilmController.kt
├── UserController.kt
├── FilmLibraryController.kt
└── dto/
├── request/
│ ├── CreateFilmRequest.kt
│ ├── EditFilmRequest.kt
│ ├── CreateUserRequest.kt
│ └── ...
└── response/
├── FilmResponse.kt
├── UserResponse.kt
└── ...
request — то, что приходит в контроллер (что клиент отправляет вам в теле запроса).
response — то, что контроллер отдаёт наружу (см. замечание №3, оно с этим тесно связано).
Замечание 3. Гексагональная архитектура реализована неправильно — domain «течёт» наружу
Файлы:
src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt:23—fun create(...): Filmsrc/main/kotlin/com/project/movienight/adapters/web/UserController.kt:23—fun create(...): Usersrc/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt:30—fun create(...): FilmLibrary- (то же касается всех остальных endpoint'ов)
В чём суть. В гексагональной архитектуре для вашего проекта есть три слоя:
┌──────────┐ ┌─────────┐ ┌─────┐
│ REST │ <--> │ DOMAIN │ <--> │ DB │
│ (адаптер)│ │ (ядро) │ │ │
└──────────┘ └─────────┘ └─────┘
Request Domain DB
/Response Models Entity
Главное правило: domain — это центр. Он не зависит ни от кого. REST и DB — это адаптеры, они зависят от domain. И никто извне не должен видеть/возвращать domain-модели наружу — иначе любое изменение в domain.User сломает контракт API клиента.
Что у вас сейчас нарушено:
- REST-слой возвращает доменные модели (
Film,User,FilmLibrary) напрямую в HTTP-ответе. Должен возвращатьFilmResponse,UserResponse,FilmLibraryResponse— отдельные DTO для API. - DB-слой работает с доменом без маппинга. Должны быть JDBC/JPA-сущности (
UserEntity,FilmEntity) и мапперUserEntity ↔ Userвнутри репозитория. Наружу из репозитория всегда уходят domain-модели.
Как должно быть (на примере User):
Главная мысль: domain-слой — это «правда» о бизнес-сущности. Снаружи (в REST или в БД) у этой сущности могут быть совершенно другие представления — и это нормально. Цель архитектуры — чтобы домен можно было поменять, не ломая никого.
Замечание 4. Repositories не подключены к логике — сделаны «для галочки»
Файлы:
src/main/kotlin/com/project/movienight/adapters/persistence/FilmRepository.kt:9-10src/main/kotlin/com/project/movienight/adapters/persistence/UserRepository.kt:9-10src/main/kotlin/com/project/movienight/adapters/persistence/FilmLibraryRepository.kt:8-9
- Не имплементируют порты (
FilmRepositoryPort,UserRepositoryPort,FilmLibraryRepositoryPort). - Никем не инжектятся. Ни один сервис на них не ссылается. Spring их создаст как бины — и они никогда не будут вызваны.
- Реальная логика всё ещё работает на in-memory адаптерах.
Замечание 5. Использовать кастомные исключения
Файлы:
src/main/kotlin/com/project/movienight/application/services/FilmService.kt:25,28,45,48,51,59src/main/kotlin/com/project/movienight/application/services/UserService.kt:25,43,46,54
В чём суть. Сейчас везде вы кидаете throw IllegalArgumentException(...). Это плохо по двум причинам:
- Невозможно отличить «такого пользователя нет» от «имя в чёрном списке» — оба исключения одного типа.
- В REST-слое нельзя по типу исключения вернуть правильный HTTP-статус (404 vs 409 vs 400).
Как сделать правильно. Заведите иерархию доменных исключений. Например:
// domain/exception/DomainException.kt
sealed class DomainException(message: String) : RuntimeException(message)
class UserNotFoundException(id: UUID) :
DomainException("User with id $id not found")
class BlockedUserNameException(name: String) :
DomainException("User name '$name' is blocked")
class FilmNotFoundException(id: UUID) :
DomainException("Film with id $id not found")
class BlockedFilmContentException(field: String) :
DomainException("Film $field contains blocked pattern")
В сервисе:
val user = userRepository.findById(id) ?: throw UserNotFoundException(id)
В REST-слое — @RestControllerAdvice, который мапит исключения на HTTP-ответы:
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException::class)
fun handleNotFound(e: UserNotFoundException): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(e.message ?: "Not found"))
@ExceptionHandler(BlockedUserNameException::class)
fun handleBlocked(e: BlockedUserNameException): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse(e.message ?: "Blocked"))
}
Замечание 6. Новые JDBC-репозитории сломаны — они не работают, даже если их подключить
Помимо того, что они никуда не подключены (см. замечание №4), сами по себе они не запустятся ни на одном запросе.
6.1. FilmRepository.findById принимает Int вместо UUID
6.2. FilmRepository.save — SQL не соответствует модели и параметры не сходятся
6.3. UserRepository.save — смешаны именованные и позиционные параметры
6.4. UserRepository.findByEmail — пропущен аргумент email
6.5. findTopN(sortBy) — SQL-инъекция
src/main/kotlin/com/project/movienight/adapters/persistence/FilmRepository.kt:36-47 (и аналогично в UserRepository.kt:60, FilmLibraryRepository.kt:54)
fun findTopN(limit: Int, sortBy: String = "name"): List<User> =
jdbc.query("SELECT * FROM users ORDER BY $sortBy LIMIT ?", ...)
Параметр sortBy подставляется в SQL через интерполяцию строки. Если этот метод когда-нибудь будет вызван из контроллера с пользовательским вводом — это классическая SQL-инъекция (типа ?sortBy=name; DROP TABLE users; --).
Никогда не подставляйте пользовательский ввод в SQL через строковую интерполяцию. Для значений — ?. Для имён колонок/таблиц — белый список:
private val allowedSortColumns = setOf("id", "name", "email")
fun findTopN(limit: Int, sortBy: String = "name"): List<User> {
require(sortBy in allowedSortColumns) { "Invalid sort column: $sortBy" }
return jdbc.query("SELECT * FROM users ORDER BY $sortBy LIMIT ?", { ... }, limit)
}
Замечание 7. Схема БД (V1__init.sql) не соответствует доменным моделям
Файл: src/main/resources/db/migration/V1__init.sql
Сравним, что в схеме и что в коде:
| Сущность | В схеме (V1__init.sql) |
В домене (domain/model/) |
|---|---|---|
users |
id int4 IDENTITY, login varchar, password varchar |
User(id: UUID, name: String, email: String, library: FilmLibrary?) |
films |
id int4 IDENTITY, title, genre_id int4, issue_date date |
Film(id: UUID, title: String, description: String) |
favorites |
id int4, userid int4, film_id int4, comment, is_viewed |
FilmLibrary(id: UUID, userId: UUID, filmId: UUID, comment, isViewed) |
Что не так:
- Типы ID не совпадают. В схеме
int4, в кодеUUID. Нужно одно из двух: либо в схемеid uuid PRIMARY KEY, либо в кодеLong. Так как UUID везде в коде — значит в схеме переходим наuuid. - Поля
usersсовершенно другие. В схемеlogin/password, в кодеname/email. Это две разные сущности — кто-то рисовал схему, не глядя на модели. - У
filmsв схемеgenre_id+issue_date, которых нет в домене. И наоборот — в домене естьdescription, которого нет в схеме. - Сломанная FK в
favorites: колонка называетсяuserid, но FK ссылается наuser_id:Эта миграция вообще не применится — Flyway упадёт сuserid int4 NULL, ... CONSTRAINT favorites_users_fk FOREIGN KEY (user_id) REFERENCES public.users(id)column "user_id" does not exist. DROP TABLE if existsв Flyway-миграции — антипаттерн. Каждая миграция должна быть аддитивной: создавать новое, а не уничтожать существующее. Иначе при повторном применении мигратор будет терять данные.
Замечание 8. Use case'ы для FilmLibrary не имеют ни одной реализации
Файлы:
src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt:8-49(объявление интерфейсов)src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt:18-23(инжект в контроллер)
FilmLibraryController инжектит четыре зависимости:
class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
)
Но ни один сервис эти интерфейсы не имплементирует.
При старте приложения Spring не сможет создать FilmLibraryController — вылетит NoSuchBeanDefinitionException для CreateFilmLibraryUseCase. Приложение просто не запустится.
Замечание 9. Нет конфигурации подключения к БД
Файл: src/main/resources/application.yaml
spring:
application:
name: MovieNight
services:
user:
blocked-names: ...
В файле нет ни одного блока spring.datasource.*, spring.flyway.*.
Замечание 10. MovieNightService — удалить
Файл: src/main/kotlin/com/project/movienight/MovieNightService.kt
Это CommandLineRunner, который нужен был на стадии демонстрации проекта. Теперь есть контроллеры