Пишешь на Spring Boot уже пару лет и уверен, что знаешь все подводные камни? Рассмотрим классические ошибки, которые продолжают проникать в прод даже у бывалых разработчиков. Вместе с Mohamed Akthar в новом переводе от команды Java Insider разбираем три распространённые проблемы, которые могут привести к бессонным ночам отладки.
Ошибка №1: Отсутствие валидации входных данных
Сколько раз вы писали endpoints примерно так:
@PostMapping("/register") public ResponseEntity<?> register(@RequestBody RegisterRequest req) { // Валидация на фронтенде же есть, правда? return ResponseEntity.ok(authService.register(req)); }
А потом кто-то отправляет пустой email. Или username длиной в 500 символов. Или просто пустой JSON {}. В итоге — мусор в базе данных и вечер пятницы, потраченный на hotfix.
Правильное решение
public class RegisterRequest { @NotBlank(message = "Имя пользователя не может быть пустым") @Size(min = 3, max = 30, message = "Длина имени пользователя должна быть от 3 до 30 символов") private String username; @Email(message = "Некорректный формат email") @NotBlank(message = "Email не может быть пустым") private String email; @NotNull(message = "Пароль обязателен") @Size(min = 8, message = "Пароль должен содержать минимум 8 символов") private String password; } @PostMapping("/register") public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest req) { return ResponseEntity.ok(authService.register(req)); }
Ключевые моменты:
Аннотация
@Validактивирует валидациюBean Validation автоматически возвращает HTTP 400 с деталями ошибок
Затраты времени: 5 минут. Экономия: часы отладки и потенциальные уязвимости
💡 Комментарий от редакции Java Insider
Не полагайтесь исключительно на клиентскую валидацию — это всегда вопрос безопасности, а не только UX. Злоумышленник может отправить запрос напрямую через curl или Postman, минуя вашу красивую форму на React.
Более того, Bean Validation поддерживает создание собственных constraint-аннотаций. Например, можно создать @ValidPhone для проверки телефонных номеров или @UniqueEmail с обращением к базе данных. Это делает код самодокументируемым и переиспользуемым.
Глобальный обработчик ошибок валидации
Чтобы сделать ответы более user-friendly, добавьте @ControllerAdvice:
@RestControllerAdvice public class ValidationExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, String>> handleValidationExceptions( MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors().forEach(error -> errors.put(error.getField(), error.getDefaultMessage()) ); return ResponseEntity.badRequest().body(errors); } }
Теперь вместо стандартного громоздкого ответа Spring вы получите чистый JSON:
{ "username": "Длина имени пользователя должна быть от 3 до 30 символов", "email": "Некорректный формат email" }
Ошибка №2: Проблема N+1 запросов
Классическая ситуация — endpoint для получения транзакций с деталями:
@GetMapping("/transactions") public List<TxnResponse> getAll() { var txns = txnRepo.findByUserId(userId); return txns.stream() .map(t -> new TxnResponse( t.getId(), t.getAmount(), t.getDetails() // ⚠️ Здесь запускается отдельный запрос! )) .toList(); }
Что происходит на самом деле:
Выполняется 1 запрос для получения транзакций:
SELECT * FROM transactions WHERE user_id = ?Для каждой транзакции выполняется дополнительный запрос:
SELECT * FROM transaction_details WHERE transaction_id = ?
Результат: 100 транзакций = 101 запрос к базе данных. Время ответа endpoint'а: 4 секунды. 😱
Решение: JOIN FETCH
@Query("SELECT t FROM Transaction t JOIN FETCH t.details WHERE t.userId = :userId") List<Transaction> findByUserIdWithDetails(@Param("userId") Long userId);
Теперь Hibernate выполняет один запрос с JOIN:
SELECT t.*, d.* FROM transactions t LEFT JOIN transaction_details d ON t.id = d.transaction_id WHERE t.user_id = ?
До: 101 запрос, 4 секунды
После: 1 запрос, 200 мс
💡 Комментарий от редакции Java Insider
N+1 — это не просто теоретическая проблема из учебников. В реальных проектах мы видели случаи, когда один неоптимизированный endpoint генерировал 10 000+ запросов при загрузке списка заказов с товарами, адресами доставки и статусами. База данных буквально умирала под нагрузкой.
Полезные инструменты для обнаружения N+1:
Включите
spring.jpa.show-sql=trueиspring.jpa.properties.hibernate.format_sql=trueв developmentВ production мониторьте метрики через Micrometer: количество запросов на endpoint
Попробуйте инструмент Hypersistence Optimizer от Vlad Mihalcea — он автоматически детектирует N+1 в тестах
Альтернативные подходы к решению:
@EntityGraph— более декларативный способ, чем JOIN FETCH@BatchSize— группирует запросы, но не решает проблему полностьюDTO Projections — получайте только нужные поля без lazy loading
Пример с @EntityGraph
@Entity public class Transaction { @Id private Long id; @OneToMany(fetch = FetchType.LAZY) @JoinColumn(name = "transaction_id") private List<TransactionDetail> details; } // В репозитории: @EntityGraph(attributePaths = {"details"}) List<Transaction> findByUserId(Long userId);
Ошибка №3: Ловля общего Exception
Взгляните на этот код:
public AccountDTO getAccount(Long id) { try { var acc = accountRepo.findById(id).orElseThrow(); return mapper.toDto(acc); } catch (Exception e) { log.error("failed to get account", e); throw new RuntimeException("something went wrong"); } }
"Something went wrong" — супер-полезное сообщение, когда ты дебажишь в 11 вечера. 🤦♂️
Что это было:
Таймаут базы данных?
NullPointerException где-то в mapper?
Баг, который я только что внёс?
Лог просто говорит "failed to get account" для всех случаев. Никакого контекста.
Правильный подход
public AccountDTO getAccount(Long id) { try { var acc = accountRepo.findById(id) .orElseThrow(() -> new AccountNotFoundException( "Аккаунт не найден: " + id)); return mapper.toDto(acc); } catch (DataAccessException e) { log.error("Ошибка базы данных при получении аккаунта {}", id, e); throw new ServiceException("База данных временно недоступна", e); } catch (MappingException e) { log.error("Ошибка маппинга аккаунта {}", id, e); throw new ServiceException("Ошибка обработки данных аккаунта", e); } // Другие исключения пробрасываются выше — возможно, это баги }
Преимущества:
Специфичные исключения → специфичные исправления
Разные HTTP-коды для разных ошибок (404 для NotFound, 503 для DB issues)
Логи с контекстом упрощают отладку
Централизованная обработка с @ControllerAdvice
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(AccountNotFoundException.class) public ResponseEntity<ErrorResponse> handleNotFound(AccountNotFoundException e) { return ResponseEntity .status(HttpStatus.NOT_FOUND) .body(new ErrorResponse( "ACCOUNT_NOT_FOUND", e.getMessage(), LocalDateTime.now() )); } @ExceptionHandler(DataAccessException.class) public ResponseEntity<ErrorResponse> handleDatabaseError(DataAccessException e) { log.error("Database error occurred", e); return ResponseEntity .status(HttpStatus.SERVICE_UNAVAILABLE) .body(new ErrorResponse( "DATABASE_ERROR", "Временные проблемы с базой данных. Попробуйте позже.", LocalDateTime.now() )); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) { log.error("Unexpected error", e); return ResponseEntity .status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse( "INTERNAL_ERROR", "Внутренняя ошибка сервера", LocalDateTime.now() )); } } record ErrorResponse(String code, String message, LocalDateTime timestamp) {}
Помните: код пишется один раз, а читается и дебажится десятки раз. Инвестируйте в качество сразу.
А какие ошибки в Spring Boot вы совершали или видели в production? Делитесь в комментариях!
Больше материалов о Spring Boot, Java и backend-разработке ищите в нашем телеграм-канале Java Insider.
