Пишешь на 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. Выполняется 1 запрос для получения транзакций: SELECT * FROM transactions WHERE user_id = ?

  2. Для каждой транзакции выполняется дополнительный запрос: 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.