[prplhd/weather](https://github.com/prplhd/weather) ## ХОРОШО 1. **Интерфейс `WeatherApiClient` и декоратор `CachingWeatherApiClient`** — внешний API изолирован за контрактом, а кэширование вынесено в отдельный компонент с делегированием. Это классический decorator: бизнес-сервисы зависят от абстракции, реализацию HTTP и политику кэша можно менять независимо. 2. **Инъекция `Clock` в `SessionService`** — время берётся из абстракции, а не из `Instant.now()` напрямую. Это упрощает тестирование истечения сессий и исключает скрытую зависимость от системных часов. 3. **MapStruct для маппинга между слоями** — преобразования entity → view DTO и OpenWeather DTO → view описаны декларативно. Ошибки несоответствия полей ловятся на этапе компиляции, а не в рантайме на странице. 4. **`ExpiredSessionCleanupScheduler` делегирует работу в `SessionService`** — scheduler содержит только расписание и один вызов сервиса. Логика «как удалять просроченные сессии» сосредоточена в application-слое, её можно протестировать без scheduling-инфраструктуры. 5. **`SessionRepository#findBySessionIdWithUser` с `JOIN FETCH`** — при разрешении сессии пользователь подгружается одним запросом, без N+1 при обращении к `session.getUser()`. 6. **`LocationRepository#deleteByIdAndUser_Id`** — удаление привязано к паре `(locationId, userId)`, что закрывает типовую уязвимость подмены ID чужой локации. 7. **Параллельная загрузка погоды через `CompletableFuture` и выделенный `ExecutorService`** — I/O-запросы к OpenWeather для разных локаций не блокируют друг друга в одном потоке. Для списка из нескольких городов это существенно сокращает время ответа главной страницы. --- ## ЗАМЕЧАНИЯ ### структура пакетов 1. Ты вынес `entity`, `repository` и `util` в отдельный umbrella-пакет `persistence`, хотя для монолита на Spring Data JPA обычно держат слои рядом на одном уровне с `service` и `web`. Чтобы добраться от `AuthService` до `UserEntity` или `UserRepository`, нужно «провалиться» в `persistence`, хотя по смыслу это соседние слои одного приложения, а не отдельный модуль. `ConstraintViolationHandler` в `persistence.util` тоже выглядит чужеродно — это application-утилита для маппинга ошибок регистрации, а не persistence-деталь. Лишний уровень вложенности не даёт изоляции — всё равно один WAR и один `@ComponentScan`. Зато растёт cognitive load: в учебных и production-монолитах привычнее структура `entity` / `repository` / `service` / `controller` на одном уровне — по ней быстрее читать поток «контроллер → сервис → репозиторий → entity». При росте проекта `persistence` превращается в свалку всего, что «касается БД», включая классы, которые логически относятся к service-слою. **Рекомендация:** Разверни `persistence` в плоские пакеты слоёв на корневом уровне. 2. Umbrella-пакет `web` с подпакетами `controller`, `advice`, `interceptor`, `auth`, `validation` смешивает разные слои под одной меткой «веб», хотя эти классы по роли относятся к разным местам проекта. `GlobalExceptionHandler` обрабатывает прикладные исключения из `exception.*` — логичнее держать его рядом с ними, а не в `web.advice`. `AuthInterceptor` — инфраструктура Spring MVC, его регистрация уже в `WebConfig`; сам класс чаще лежит в `config` вместе с настройкой цепочки servlet. `SessionCookieManager` и `AuthenticatedUserProvider` — не контроллеры и не advice, это инфраструктура сессии, которую удобнее вынести в отдельный пакет `session` на корневом уровне. `SignUpDtoValidator` — костыль: бизнес-правила регистрации должны жить в `AuthService`, а не в отдельном web-валидаторе с доступом к `UserRepository`. `web` превращается в «всё, что не service и не persistence», без чёткой границы. Искать `GlobalExceptionHandler` по пакету `exception` не получится. При росте проекта в `web` начнут скапливаться фильтры, resolvers, argument resolvers и handlers — навигация усложнится. `@ComponentScan("ru.prplhd.weather.web")` в `WebConfig` привязывает сканирование к искусственному корню, хотя контроллеры можно сканировать так же из `controller` на верхнем уровне. **Рекомендация:** Разверни `web` по назначению классов ```text ru.prplhd.weather ├── controller # AuthController, HomeController, LocationController, ... ├── config # AppConfig, WebConfig, AuthInterceptor (регистрация в WebConfig) ├── exception # *Exception + GlobalExceptionHandler ├── session # SessionCookieManager, AuthenticatedUserProvider ├── service ├── entity └── repository ``` `AuthenticatedUserModelAdvice` можно оставить в `controller` или вынести в `controller.support` — это MVC-advice для модели, а не обработчик исключений. --- ### использование Lombok 1. Lombok подключён в проекте, но применяется выборочно: entity частично аннотированы, а Spring-компоненты везде пишут однотипные конструкторы вручную. На entity стоят `@Getter`, `@NoArgsConstructor`, `@ToString` — это нормально. Но `ExpiredSessionCleanupScheduler`, все сервисы, контроллеры, web-компоненты, `CachingWeatherApiClient`, `WebConfig` и `JpaConfig` содержат один и тот же шаблон: ```java private final SessionService sessionService; public ExpiredSessionCleanupScheduler(SessionService sessionService) { this.sessionService = sessionService; } ``` При этом все поля уже `final` — ровно тот случай, для которого в Lombok есть `@RequiredArgsConstructor`. Получается дублирование: библиотека в `build.gradle.kts` есть, а boilerplate constructor injection написан руками в ~15 классах. Нет единого стиля внутри одного проекта — entity через Lombok, bean'ы через copy-paste. При добавлении зависимости в сервис правишь и поле, и конструктор, и присваивание — три места вместо одного поля. `WebConfig` дополнительно помечает единственный конструктор `@Autowired`, что в Spring 4.3+ избыточно. В `exception.*` пять классов с одинаковыми конструкторами `super(message)` — ещё один слой повторяющегося кода, который базовый `WeatherApplicationException` (см. `/exception`) тоже не снимает полностью. **Рекомендация:** Приведи Spring-компоненты к `@RequiredArgsConstructor` и убери лишний `@Autowired` с конструкторов. --- ### пакет /client 1. В `OpenWeatherApiClient#parseLocations()` при пустом теле ответа или невалидном JSON `jsonMapper.readValue` выбросит непроверенное исключение, которое не преобразуется в `OpenWeatherApiException`. `sendRequest()` аккуратно маппит HTTP-ошибки в `OpenWeatherApiException`, а парсинг отделён и не обёрнут. Сбой десериализации уйдёт наверх как generic runtime exception. Инфраструктурный клиент не сдерживает ошибки интеграции в одном типе. `GlobalExceptionHandler` и `UserLocationService` рассчитывают на `OpenWeatherApiException`, но получат другой stack trace. Тест `OpenWeatherApiClientTest` не покрывает битый JSON — регрессия останется незамеченной. **Рекомендация:** Оберни в try-catch парсинг в `OpenWeatherApiException` внутри клиента 2. В `CachingWeatherApiClient` ключ кэша погоды `WeatherCacheKey` использует `BigDecimal` без нормализации масштаба. `51.5074` и `51.507400` — разные ключи, хотя координаты совпадают. После сохранения локации с `scale(6)` и ответа API с другим scale кэш продублируется, лимит `maximumSize` расходуется быстрее. Инфраструктурный слой кэша не согласован с тем, как координаты нормализуются в `UserLocationService#saveLocation`. Поведение кэша зависит от представления числа, а не от геопозиции. Расширяемость страдает при смене точности в БД. **Рекомендация:** Нормализуй координаты в ключе кэша до единого scale. --- ### пакет /dto 1. У request-DTO нет единой и полной Bean Validation — валидация transport-слоя покрывает только часть входных данных и реализована разрозненно. Сейчас три разных подхода на трёх endpoint'ах: **`AddLocationDto`** — mutating-вход без единой аннотации. Поля `name`, `latitude`, `longitude` могут быть `null`. В `LocationController#addLocation()` нет `@Valid` и `BindingResult`. При подмене hidden-полей из `search-results.html` сервис падает на `addLocationDto.latitude().setScale(...)` в `UserLocationService#saveLocation()`. Диапазоны координат (−90…90, −180…180) нигде не проверяются — в OpenWeather уйдёт мусор. **`SignUpDto`** — поля `login` и `password` аннотированы, но `confirmPassword` без `@NotBlank`. Проверка «пароли совпадают» и «логин занят» вынесена в `SignUpDtoValidator` на web-слое вместо `AuthService`. **`SignInDto`** — для того же логина только `@NotBlank`, без `@Size` и `@Pattern`, которые есть у `SignUpDto`. Логин `"ab"` формально проходит sign-in, хотя регистрация такой логин отклонит. Контракт одного и того же поля разный на двух формах. **Поиск локаций** — request-DTO вообще нет. `LocationSearchController#searchLocationsPage()` принимает сырой `@RequestParam String name` и проверяет только `isBlank()` в теле контроллера. Нет ограничения длины, нет `@Valid`, нет отображения ошибки пользователю — при пустом `name` просто пустой список. Transport-слой не выполняет роль единого gatekeeper. Часть правил в DTO, часть в `SignUpDtoValidator`, часть в `AuthService` через constraint — граница размыта. Нарушается согласованность: один и тот же логин валидируется по-разному; mutating-операции с координатами защищены слабее auth-форм. **Рекомендация:** Опиши все входные данные как request-DTO с Bean Validation и подключай `@Valid` на каждом mutating/read endpoint. --- ### пакет /exception 1. В `exception.auth`, `exception.location` и `exception.openweather` все классы наследуются напрямую от `RuntimeException` — общего корня для прикладных ошибок нет. `InvalidCredentialsException`, `LocationNotFoundException`, `OpenWeatherApiException` и остальные — отдельные ветки без общего предка. В `GlobalExceptionHandler` нельзя одним `@ExceptionHandler` обработать все доменные сбои приложения, а в сервисах сложнее отличить «ожидаемую бизнес-ошибку» от случайного `RuntimeException`. **Рекомендация:** Введи базовый `WeatherApplicationException` и наследуй от него все прикладные исключения --- ### пакет /persistence/entity 1. В `LocationEntity` у поля `user` есть публичный `@Setter`, хотя связь помечена `updatable = false` и устанавливается один раз при создании локации. Любой код может вызвать `locationEntity.setUser(otherUser)` и сломать инвариант владельца до flush, даже если колонка `updatable = false` — Hibernate может не записать изменение, но в persistence context граф будет неконсистентен. Нарушена инкапсуляция entity: владелец задаётся в сервисе через setter вместо конструктора. Потенциальный баг при рефакторинге — смена user у существующей локации. Тесты, полагающие на неизменность владельца, не защищены на уровне модели. **Рекомендация:** Передавай `UserEntity` только через конструктор и убери setter. --- ### пакет /persistence/repository 1. В `SessionRepository#deleteExpiredSessions()` модифицирующий запрос не объявлен с `clearAutomatically = true`, из-за чего persistence context может содержать устаревшие `SessionEntity` после batch-delete. Метод вызывается из scheduler в отдельной транзакции — для фоновой задачи это реже проявляется. Но если в той же транзакции ранее загружали сессию, кэш первого уровня не синхронизирован с БД. Смешение bulk SQL и JPA state без очистки context — классический источник трудноуловимых багов при расширении логики сессий. Тестируемость страдает: unit-тест repository не покажет рассинхронизацию entity manager без интеграционного сценария. **Рекомендация:** Добавь `@Modifying(clearAutomatically = true)` на метод удаления. --- ### пакет /persistence/util 1. В `ConstraintViolationHandler` ты разбираешь `org.hibernate.exception.ConstraintViolationException` по имени constraint — Hibernate-тип протекает в application-слой через `AuthService`. `AuthService` зависит от утилиты persistence-пакета, которая знает имя индекса `uk_users_login_lower` из Liquibase. Это деталь конкретной СУБД и миграции, а не доменное правило «логин занят». Application-слой связан с Hibernate и конкретным именем constraint. При смене диалекта или имени индекса регистрация сломается без изменения Java. Тестировать маппинг ошибок без поднятия PostgreSQL constraint сложно. **Рекомендация:** Проверяй уникальность логина явно в `AuthService` до сохранения, а `ConstraintViolationHandler` удали из цепочки регистрации. ```java @Transactional public void signUp(SignUpDto signUpDto) { if (userRepository.existsByLoginIgnoreCase(signUpDto.login())) { throw new LoginAlreadyExistsException("This login is already taken"); } userRepository.saveAndFlush( new UserEntity(signUpDto.login(), passwordEncoder.encode(signUpDto.password())) ); } ``` --- ### пакет /scheduler 1. В `ExpiredSessionCleanupScheduler` ты вручную написал constructor injection для единственной зависимости `SessionService`, хотя Lombok уже в проекте — см. раздел «использование Lombok». 2. В `ExpiredSessionCleanupScheduler#cleanupExpiredSessions()` задержка `fixedDelay = 3_600_000` не связана с TTL сессии из `app.session.ttl-hours` и захардкожена в аннотации. Scheduler и `SessionService` используют одну доменную сущность «сессия», но политика очистки задаётся отдельно от политики жизни. При уменьшении TTL до 1 часа просроченные записи всё равно будут жить до следующего часового тика scheduler. Нарушена связность подсистемы сессий: два несогласованных параметра в разных местах. Масштабируемость страдает — таблица `sessions` может разрастаться между запусками cleanup. Поддержка требует помнить про магическое число 3_600_000. **Рекомендация:** Вынеси 3_600_000 в константу с понятным названием --- ### пакет /service 1. В `UserLocationService#findUserLocationsWithCurrentWeather()` метод выполняется внутри `@Transactional(readOnly = true)` на уровне класса и удерживает JDBC-соединение на время параллельных HTTP-запросов к OpenWeather. Сначала ты читаешь локации из БД, затем в том же transactional-контексте запускаешь `CompletableFuture`, которые ходят во внешний API. Пока futures не завершатся, read-only транзакция и connection из пула заняты. Смешаны границы транзакции и медленный I/O. Под нагрузкой пул Hikari (у тебя `maximum-pool-size=5`) быстро исчерпается — остальные запросы встанут в очередь. Масштабируемость падает линейно с числом пользователей, одновременно открывающих главную. Тестировать таймауты транзакций сложнее, потому что длительность метода определяется сетью, а не SQL. **Рекомендация:** Раздели чтение из БД и обогащение погодой: снимай транзакцию до HTTP. ```java public List findUserLocationsWithCurrentWeather(Long userId) { List userLocations = findUserLocations(userId); return enrichWithWeather(userLocations); } @Transactional(readOnly = true) //помни про self-injection!!!! если будет в 1 бине - не заработает protected List findUserLocations(Long userId) { return locationRepository.findAllByUser_Id(userId); } protected List enrichWithWeather(List userLocations) { // CompletableFuture + weatherApiClient без @Transactional } ``` 2. В `UserLocationService#findUserLocationsWithCurrentWeather()` в блоке `catch (CompletionException e)` ты пробрасываешь только `OpenWeatherApiException`, а остальные причины проглатываешь. Если future завершится с `NullPointerException` или `JsonException`, элемент списка молча не попадёт в `result` — пользователь увидит меньше карточек погоды без ошибки. Нарушена предсказуемость application-слоя: частичный успех маскируется как норма. Отладка крайне сложна — баг в маппере проявится как «пропавший город». Тест, ожидающий исключение, не сработает. **Рекомендация:** После `join()` пробрасывай любую нетривиальную причину, оборачивая в доменное исключение. 3. В `LocationSearchService` на классе висит `@Transactional(readOnly = true)`, хотя `searchByName()` не обращается к базе данных. Каждый поиск локаций открывает транзакцию и получает JDBC connection из пула ради вызова внешнего API через `WeatherApiClient`. Лишняя транзакция увеличивает связность с JPA там, где её нет по смыслу. Это трата ресурсов пула и потенциальный источник неожиданных `TransactionException` при смене propagation. Сервис выглядит как persistence-aware, хотя это чистый оркестратор над HTTP-клиентом. **Рекомендация:** Убери `@Transactional` с `LocationSearchService` целиком. 4. В `AuthService#signUp()` бизнес-правила регистрации размазаны: часть в `SignUpDtoValidator` на web-слое, часть — в обработке `DataIntegrityViolationException` через `ConstraintViolationHandler`. Сервис не проверяет совпадение паролей и не проверяет занятость логина до `save` — это делает web-валидатор. Если убрать `SignUpDtoValidator`, останется только constraint БД как последний рубеж, а `ConstraintViolationHandler` тянет Hibernate-имя индекса `uk_users_login_lower` в application-слой. **Рекомендация:** Собери весь сценарий регистрации в `AuthService#signUp()` — см. код в `AuthController#signUp()`. Удали `SignUpDtoValidator` и `ConstraintViolationHandler` из цепочки sign-up: явная проверка `existsByLoginIgnoreCase` до `save` надёжнее, чем парсинг constraint после flush. --- ### пакет /web/advice 1. В `GlobalExceptionHandler` обработчик `@ExceptionHandler(Exception.class)` объявлен раньше, чем `OpenWeatherApiException`, и поглощает любые необработанные ошибки без логирования (`Exception ignored`). Spring выберет наиболее специфичный тип, поэтому `OpenWeatherApiException` всё же попадёт в свой handler. Но любая другая runtime-ошибка (например, `NullPointerException` в `saveLocation` при `null` latitude) уйдёт в generic-ответ без записи в лог — диагностика в production будет слепой. Глобальный обработчик скрывает технические сбои и нарушает наблюдаемость. Поддержка усложняется: по сообщению «Internal server error» нельзя восстановить первопричину. Тесты интеграции не подскажут, что в логах пусто. **Рекомендация:** Логируй неожиданные исключения и оставь специализированные handlers явно выше по смыслу --- ### пакет /web/controller 1. В `LocationSearchController` конструктор принимает `UserLocationService`, но поле не присваивается — зависимость объявлена и не используется. **Рекомендация:** Оставь в конструкторе только реально используемые зависимости. От таких случаев спасает ломбок, писал выше 2. В `HomeController#home()` ты проверяешь `userDto != null`, хотя на этот endpoint пользователь попадает только после `AuthInterceptor`. Интерцептор при отсутствии сессии делает redirect и не вызывает контроллер. Атрибут `authenticatedUserDto` на защищённых маршрутах всегда установлен. Ветка `if (userDto != null)` создаёт ложное ощущение, что главная может обслуживать гостей, и дублирует ответственность между interceptor и контроллером. **Рекомендация:** Убери проверку на `null` и сделай атрибут обязательным в сигнатуре. 3. В `LocationController#addLocation()` и `LocationSearchController#searchLocationsPage()` ты не подключаешь `@Valid` — валидация входа не выполняется на границе HTTP, хотя для auth-форм `@Valid` уже используется. `AddLocationDto` принимается без `@Valid` и `BindingResult`. Поиск принимает сырой `@RequestParam String name` без request-DTO. Полный перечень пробелов в transport-моделях — в замечании 1 раздела `/dto`. Контроллеры mutating- и read-endpoint'ов должны отклонять невалидный ввод до вызова сервиса. Сейчас location/search ведут себя иначе, чем `AuthController#signIn()` — единого pipeline нет. **Рекомендация:** Подключи `@Valid` и `BindingResult` на всех endpoint'ах с пользовательским вводом по образцу auth-контроллера. ```java @PostMapping public String addLocation( @RequestAttribute("authenticatedUserDto") AuthenticatedUserDto userDto, @Valid @ModelAttribute AddLocationRequest request, BindingResult bindingResult ) { if (bindingResult.hasErrors()) { return "redirect:/search-results"; } userLocationService.saveLocation(userDto.id(), request); return "redirect:/"; } ``` 4. В `AuthController#signUp()` ты вручную вызываешь `SignUpDtoValidator` и тянешь его в конструктор контроллера — бизнес-правила регистрации оказались в web-слое, хотя им место в `AuthService`. Сейчас `@Valid` проверяет формат полей, а `SignUpDtoValidator` — уникальность логина через `UserRepository` и совпадение паролей. `AuthService#signUp()` при этом только хэширует пароль и ловит `DataIntegrityViolationException`. Получается костыль: один сценарий «зарегистрировать пользователя» разрезан между transport-валидатором и сервисом. Контроллер знает про `SignUpDtoValidator`, валидатор знает про БД, сервис дублирует защиту через constraint — три точки вместо одной. `SignUpDtoValidator` нарушает границы слоёв: web зависит от persistence. При вызове `signUp` не из MVC (тест, будущий API) валидатор не сработает — поведение другое. **Рекомендация:** Удали `SignUpDtoValidator`. Перенеси проверки в `AuthService#signUp()`, на DTO оставь только Bean Validation формата полей. --- ### пакет /web/interceptor 1. В `AuthInterceptor#preHandle()` при любой проблеме с сессией ты всегда делаешь `sendRedirect` на `/auth/sign-in`, не различая истёкшую сессию, отсутствие cookie и невалидный UUID. `AuthenticatedUserProvider` уже удаляет протухший cookie, но пользователь получает тот же сценарий, что и при первом визите. Для защищённых POST-запросов (удаление локации) браузер выполнит GET на sign-in без сообщения, почему действие не выполнено. Инфраструктурный слой не передаёт в transport-слой причину отказа. Расширять UX (flash-сообщение «сессия истекла») без изменения interceptor сложно. Тестировать различие сценариев на уровне MVC тоже неудобно — один redirect на все случаи. **Рекомендация:** Верни из `AuthenticatedUserProvider` типизированный результат и в interceptor добавь flash-атрибут перед redirect. ```java public enum AuthResolution { AUTHENTICATED, NO_COOKIE, SESSION_EXPIRED, SESSION_UNKNOWN } public Optional resolveAuthenticatedUser( HttpServletRequest request, HttpServletResponse response, AuthResolution[] resolutionOut ) { // заполни resolutionOut[0] и верни user при успехе } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { AuthResolution[] resolution = new AuthResolution[1]; Optional userOpt = authenticatedUserProvider.resolveAuthenticatedUser(request, response, resolution); if (userOpt.isPresent()) { request.setAttribute("authenticatedUserDto", userOpt.get()); return true; } if (resolution[0] == AuthResolution.SESSION_EXPIRED) { request.getSession(true).setAttribute("authMessage", "Session expired, please sign in again"); } response.sendRedirect(request.getContextPath() + "/auth/sign-in"); return false; } ``` --- ### пакет /web/validation 1. `SignUpDtoValidator` — лишний слой: класс тянет `UserRepository` в web-пакет и дублирует то, что должен делать `AuthService#signUp()`. Проверка «логин занят» и «пароли совпадают» — бизнес-правила регистрации, а не валидация HTML-формы. Формат полей (`@NotBlank`, `@Size`, `@Pattern`) достаточно держать на `SignUpDto` через `@Valid`. Всё остальное — в сервис: он уже владеет `UserRepository`, транзакцией и `PasswordEncoder`. Отдельный `Validator` в web-слое создаёт второй вход в сценарий регистрации и ломает единообразие — `signIn` идёт сразу в сервис, а `signUp` — через промежуточный костыль. **Рекомендация:** Удали `SignUpDtoValidator` и пакет `validation`. Перенеси логику в `AuthService` 2. В `SignUpDto` поле `confirmPassword` не имеет `@NotBlank` — даже после переноса логики в сервис формат поля должен отсекаться на transport-слое через `@Valid`, а не доходить до `AuthService` пустым. **Рекомендация:** Добавь `@NotBlank` на `confirmPassword` в record. --- ### пакет /test 1. В `AuthServiceTest#whenSignUp_withExistingLogin_thenThrowsException()` ты сохраняешь пользователя с паролем `"randomPass"` без хэширования, в отличие от остальных тестовых данных. ```java userRepository.save(new UserEntity(VALID_SIGN_UP_DTO.login(), "randomPass")); ``` Тест проверяет только duplicate login, но создаёт нереалистичное состояние БД. Если позже сервис начнёт валидировать формат hash, тест станет ложноположительным. Данные не соответствуют инварианту production-кода. Тесты должны строить согласованные фикстуры. Поддержка усложняется — новый разработчик скопирует паттерн «сырой пароль в БД» в другие тесты. Связность с реальной моделью `password_hash` слабая. **Рекомендация:** Используй `PasswordEncoder` в фикстуре так же, как в `givenUserExistsInDb()`. 2. Интеграционные тесты сервисов (`AuthServiceTest`, `SessionServiceTest`, `UserLocationServiceTest`) поднимают один и тот же набор config-классов, но не переиспользуют общую test-конфигурацию — дублирование `@SpringJUnitConfig`. При добавлении нового bean в `AppConfig` придётся править несколько классов. Риск рассинхронизации тестового контекста растёт. Поддерживаемость тестового слоя ниже: нет единой точки сборки Spring-контекста для integration tests. DRY нарушен на уровне инфраструктуры тестов. **Рекомендация:** Вынеси общий набор в один `@Import` config. ```java @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @SpringJUnitConfig(classes = { AppConfig.class, DataSourceConfig.class, JpaConfig.class, LiquibaseConfig.class }) @TestPropertySource("classpath:app-test.properties") @Transactional public @interface WeatherIntegrationTest { } @WeatherIntegrationTest @Tag("auth") class AuthServiceTest { // ... } ``` --- ## РЕКОМЕНДАЦИИ 1. **Настройки — в `@ConfigurationProperties`.** БД, OpenWeather, TTL сессии, кэш, пул потоков. Не размазывай `Environment` и magic numbers по config-классам. 2. **Транзакция только для БД.** Читай локации в `@Transactional`, погоду из OpenWeather — уже без открытой транзакции. 3. **Бизнес-логика — в сервисе, не в web.** Убери `SignUpDtoValidator`. На DTO — `@Valid` для формата полей, регистрация целиком в `AuthService`, контроллер ловит исключения и кладёт их в `BindingResult`. 4. **Плоские пакеты вместо `persistence` и `web`.** `entity`, `repository`, `service`, `controller` рядом. `GlobalExceptionHandler` — в `exception`, `AuthInterceptor` — в `config`, session-хелперы — в `session`. 5. **`@RequiredArgsConstructor` на Spring-компонентах.** Сервисы, контроллеры, scheduler, config — без copy-paste конструкторов. На entity с generated `id` оставь ручной конструктор создания. 6. **Ошибки — через общий базовый exception.** `OpenWeatherApiException` и прикладные исключения наследуются от одного корня. В `GlobalExceptionHandler` логируй неожиданные сбои. 7. **Доведи валидацию входа.** `@Valid` на add location и search, `@NotBlank` на `confirmPassword`. Request/response DTO — с понятными суффиксами, OpenWeather-типы не покидают `client`. 8. **Собери `WeatherApiClient` в config.** Один bean с кэшем, без `@Primary` на декораторе. --- ## ИТОГ Уверенный учебный проект: ТЗ закрыто, стек освоен, README есть, тесты с WireMock/Mockito на месте. **Плюсы:** ручные сессии, `WeatherApiClient` + кэш, параллельная погода, защита удаления локации по `userId`, `Clock` в `SessionService`. **Минусы:** размытые границы слоёв (`SignUpDtoValidator`, `persistence`, `web`), дыры в валидации, конфиг и Lombok без единого стиля, транзакция на время HTTP. **Куда двигаться:** собрать бизнес-правила в сервисах, упростить пакеты, typed-конфиг, короткие транзакции, нормальная валидация на всех формах. Этого хватит, чтобы выйти за рамки «работает для сдачи» и приблизиться к maintainable-коду.