JavaRush /Курсы /Spring Data JPA /Проверки vs ограничения БД

Проверки vs ограничения БД

Spring Data JPA
24 уровень , 0 лекция
Открыта

1. Корректность данных и инварианты

Если вы только начинаете, мозг очень любит простую мысль: «Я всё проверю в коде — и база будет счастлива». Это хорошая установка, но с подвохом: она смешивает два разных вопроса. Первый вопрос — «входные данные вообще похожи на адекватные?» (например, sku не пустой, цена не null). Второй вопрос — «могу ли я гарантировать, что в базе никогда не появится невозможное состояние?» (например, два товара с одним sku). Это как разница между «проверить, что человек трезвый» и «поставить замок на дверь». Трезвость можно проверить глазами, но замок нужен, потому что глаза не контролируют весь мир 24/7.

Давайте сразу введём полезное слово: инвариант. Это правило, которое должно быть истинным всегда, не «обычно», не «в среднем», а в любой момент в данных. В нашем mini-shop такими правилами являются, например, уникальность sku у товара, обязательность категории для продукта и невозможность ссылки «в пустоту» в order_item.product_id. Инварианты — это не просто «пожелания сервиса», это фундаментальная часть модели данных.

Когда мы говорим «проверки в коде», мы чаще всего имеем в виду два механизма. Первый — Bean Validation: аннотации вроде @NotBlank, которые помогают отловить очевидную ерунду до записи в БД. Второй — предварительная проверка в сервисе, например existsBySku(...), чтобы дать понятную реакцию сценария («SKU уже занят») до того, как база начнёт ругаться. Оба механизма полезны, но ни один из них не заменяет ограничения в схеме БД, потому что они живут на другом уровне ответственности.

2. Bean Validation: фильтр на входе

Bean Validation легко полюбить: вы ставите пару аннотаций — и кажется, что данные автоматически становятся правильными. И это действительно удобно, особенно когда вы хотите защитить код от «пустых строк», null и отрицательных чисел. Но важно понимать границу: Bean Validation проверяет форму данных, а не «истину мира». Она отлично отвечает на вопрос «это вообще похоже на корректный ввод?», но почти не умеет отвечать на вопрос «а не занято ли такое значение уже в таблице?».

В нашем проекте удобно держать вход в use case в виде небольшого объекта-команды. Даже если у нас нет «толстого web-слоя», команда на создание товара всё равно появляется: сервису нужно получить sku, name, price, categoryId и дальше работать с ними.

package com.example.shopdatajpa.catalog.service.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;

/**
 * Команда (input) для use case "создать товар".
 * Важно: это проверка формы входа, а не гарантия инвариантов таблицы.
 */
public record CreateProductInput(
        @NotBlank String sku,                        // sku должен быть непустым (отсекаем явный мусор)
        @NotBlank String name,                       // имя тоже не должно быть пустым
        @NotNull @PositiveOrZero BigDecimal price,   // цена обязана быть задана и не может быть отрицательной
        @NotNull @Positive Long categoryId           // id категории должен быть передан как корректное число
) { }

Эти проверки полезны сразу по нескольким причинам. Во-первых, они ловят ошибки максимально рано: «пустой sku» — это не повод идти в базу и делать там какие-то запросы. Во-вторых, они документируют ожидания от входа: когда вы через неделю откроете код, вы увидите, что price не должна быть отрицательной, и не придётся читать десяток if-ов. В-третьих, они помогают не превращать сервис в бесконечную простыню ручных проверок.

И отдельно держите в голове: @NotNull @Positive на categoryId говорят только «id передан и похож на нормальный», а не «такая категория действительно существует». Существование категории — уже история про FK и базу.

Но вот в чём важный момент: Bean Validation не умеет гарантировать уникальность. Да, теоретически можно написать кастомную аннотацию @UniqueSku, которая полезет в репозиторий, но это почти всегда плохая идея: вы смешаете «проверку формы данных» с «доступом к данным», а главное — всё равно не получите железной гарантии. Почему? Потому что уникальность — это правило о всей таблице, а не о текущем объекте.

Чтобы validation реально сработала на границе сервисного метода, Spring обычно использует @Validated + @Valid. Нам не нужно углубляться в AOP-механику, достаточно понимать: так мы включаем проверку аргументов метода на входе.

import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;

@Validated // включает проверку параметров методов (в связке с @Valid)
public class CatalogService {

    public void createProduct(@Valid CreateProductInput input) {
        // Если input.sku пустой — метод даже не начнёт выполнять бизнес-логику:
        // будет брошено исключение валидации на границе вызова.
    }
}

Здесь важно не «заучить аннотации», а уловить смысл: Bean Validation работает как фильтр, который позволяет не продолжать сценарий, если вход уже явно неверный. Но она не может быть вашим «замком» от невозможных состояний в БД. И это нормально: у каждого механизма своя роль, и хуже всего — пытаться заставить один механизм делать всё сразу.

3. Pre-check в сервисе: existsBySku(...)

Когда мы говорим «давайте проверим в коде», чаще всего подразумевается именно это: перед тем как сохранять товар, спросим у базы, существует ли такой sku. На практике это действительно делает сценарий приятнее. Пользователь (или вызывающий код) получает нормальную ошибку «SKU занят», а не «что-то там violated constraint uk_product_sku» — от чего у новичка обычно начинается паника, а у опытного разработчика появляется желание выпить чай и не трогать этот код.

Репозиторий для этого выглядит очень простым: derived-метод existsBySku — классический «хлеб с маслом» Spring Data.

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query: Spring Data сам построит SQL для проверки существования по sku.
    boolean existsBySku(String sku);
}

Теперь сервис может сделать предсказуемую и «человеческую» проверку. Заметьте: смысл этой проверки — не гарантировать уникальность навсегда. Смысл — дать понятную реакцию конкретному use case «создать товар».

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void createProduct(CreateProductInput input) {
    // Pre-check для понятной реакции сценария (UX/логика use case),
    // но это НЕ "железная" гарантия уникальности при параллельных запросах.
    if (productRepository.existsBySku(input.sku())) {
        throw new DuplicateSkuException(input.sku());
    }

    // Дальше создаём Product и сохраняем.
    // Важно: инвариант "sku уникален" должен обеспечиваться UNIQUE в БД.
}

Обычно здесь поднимают своё runtime-исключение уровня сценария, например DuplicateSkuException. Конкретная форма такого класса для этого места не так важна, как сам принцип: сервис отвечает понятным языком use case, а не швыряет наружу техническую кашу из слоя данных.

Такой подход очень здорово работает в «обычных» случаях, когда конфликт не конкурентный, а просто кто-то пытается повторно создать товар с тем же sku. Но дальше начинается самая интересная часть: почему даже такая проверка всё равно не делает инвариант железным.

4. Гонка между проверкой и INSERT

Есть коварная штука, которую любят демонстрировать на собеседованиях и которая в реальной жизни случается гораздо чаще, чем хочется: race condition (гонка). Это ситуация, когда между «проверили условие» и «сделали действие» мир успел измениться. И да, мир меняется не потому, что он злой, а потому что ваш сервер обрабатывает много запросов параллельно, а база данных — общий ресурс.

Представьте, что два администратора (или два потока, или два сервиса, или одна и та же админка в двух вкладках браузера) одновременно создают товар с sku = "SKU-1". Оба делают pre-check: «существует ли SKU-1?». В момент проверки в таблице ещё ничего нет. Оба получают false. И оба идут дальше на INSERT. И вот здесь только база данных способна сказать финальное слово.

Чтобы это стало совсем осязаемым, посмотрим на последовательность событий:

sequenceDiagram
    participant A as Запрос A
    participant B as Запрос B
    participant DB as PostgreSQL

    A->>DB: "existsBySku('SKU-1') -> false"
    B->>DB: "existsBySku('SKU-1') -> false"
    A->>DB: "INSERT product(sku='SKU-1') -> OK"
    B->>DB: "INSERT product(sku='SKU-1') -> FAIL (UNIQUE)"

Обратите внимание на мораль: оба запроса действовали «логично». У них не было ошибки в проверке. Они честно спросили базу — база честно ответила. Но ответ был «на момент вопроса», а не «на весь оставшийся жизненный путь этого сервиса». И именно поэтому pre-check — это удобство и UX на уровне сценария, но не механизм гарантии.

Можно попытаться «лечить» это транзакциями, например «ну я же в @Transactional, значит всё атомарно». Атомарно — внутри каждой отдельной транзакции. Но у вас две транзакции в двух параллельных запросах. Они живут отдельно, и база — единственное место, которое видит картину целиком и может удерживать глобальные инварианты таблицы.

И тут появляется очень взрослая мысль: если вы хотите, чтобы невозможное состояние не могло оказаться в таблице, вам нужно правило на стороне БД. Иначе ваша «гарантия» будет держаться на надежде, что «ну в проде никто одновременно не нажмёт две кнопки». Спойлер: нажмут. Прод любит такие шутки.

5. Ограничения БД: последняя линия обороны

Ограничение (constraint) в базе данных — это не «ещё одна проверка». Это правило, которое база применяет к любому INSERT и UPDATE, который пытается изменить таблицу. Не важно, пришёл ли этот INSERT из вашего Spring Boot приложения, из ручного SQL-скрипта, из миграции, из консоли DBA или из соседнего инструмента. База не спрашивает: «а вы там точно делали @NotBlank?». Она просто говорит: «в этой таблице так нельзя».

Для уникальности sku это выглядит максимально прямолинейно:

-- Железная гарантия инварианта на уровне таблицы:
-- два одинаковых sku физически не смогут сосуществовать в product.
alter table product
    add constraint uk_product_sku unique (sku);

И вот теперь инвариант «sku уникален» становится реальным, физическим, железобетонным. Если два параллельных запроса попытаются вставить одинаковый sku, один из них будет отклонён. Да, это означает ошибку на уровне записи (и мы ещё поговорим, как Spring превращает её в понятное исключение), но главное — таблица не будет испорчена.

После перехода на Flyway такие ограничения должны жить в миграциях. Это и есть дисциплина «схема как код». Аннотация @Column(unique = true) на entity может остаться как документация намерения, но источник правды — миграция. Иначе вы снова вернётесь в режим «на моей машине было так, а у коллеги иначе».

В терминах ответственности это выглядит очень красиво. Код может быть вежливым, человечным и объяснять ошибки, но база — это суровый, но справедливый «охранник на входе в клуб данных». Он не ведётся на уговоры и не верит «я больше так не буду». Он просто не пропускает то, что запрещено.

6. Типичные ошибки при проверках и ограничениях

Ошибка №1: приравнять аннотации в коде к ограничениям БД.
На этой теме студенты чаще всего ошибаются не в синтаксисе, а в модели мира. Первая типичная ошибка — считать, что @NotBlank и @NotNull «почти то же самое», что UNIQUE и NOT NULL в базе. Это похоже только по названиям, но не по силе гарантии: аннотация работает там, где вы её запустили, а ограничение базы работает всегда, даже если к вашей базе подключился другой инструмент или вы забыли вызвать валидатор.

Ошибка №2: считать existsBySku(...) гарантией уникальности.
Вторая частая ошибка — опираться только на existsBySku(...) и искренне верить, что это гарантия уникальности. Такая проверка делает сценарий удобным, но между проверкой и вставкой у вас остаётся окно, в которое может «влезть» другой параллельный запрос. В спокойном дев-режиме это может не проявляться неделями, а потом вдруг «вылезти» в самый неподходящий момент — обычно перед релизом, потому что релизы питаются вашим стрессом.

Ошибка №3: спрятать доступ к БД внутрь Bean Validation.
Третья ошибка — пытаться спрятать доступ к базе внутрь Bean Validation (кастомные аннотации уникальности). В результате validation становится не «быстрой проверкой формы», а скрытым data-access, который сложнее тестировать, сложнее объяснять и который всё равно не решает проблему гонки. Если уж вы делаете pre-check по базе, делайте его явно в сервисе, где у вас есть контекст use case и где код читается как бизнес-сценарий.

Ошибка №4: считать ограничения БД факультативными.
И последняя ошибка — воспринимать ограничения БД как «что-то факультативное, для DBA». В реальном backend’е схема — это часть архитектуры. Flyway в нашем курсе появился не для красоты: он нужен, чтобы ограничения были воспроизводимыми, проверяемыми и одинаковыми у всей команды. База данных не читает ваши комментарии и не уважает ваши хорошие намерения, зато она отлично уважает UNIQUE, FOREIGN KEY и NOT NULL — если вы их действительно создали в миграциях.

1
Задача
Spring Data JPA, 24 уровень, 0 лекция
Недоступна
Bean Validation для команды создания товара
Bean Validation для команды создания товара
1
Задача
Spring Data JPA, 24 уровень, 0 лекция
Недоступна
Предварительная сервисная проверка по `sku`
Предварительная сервисная проверка по `sku`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ