Пишешь на 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.
