← Назад к вопросам

Что такое Specification в Spring Data JPA?

2.0 Middle🔥 191 комментариев
#Spring Boot и Spring Data

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Specification в Spring Data JPA: динамические запросы

Specification — это паттерн в Spring Data JPA для построения динамических SQL запросов без необходимости писать строки SQL или создавать множество методов репозитория. Это реализация Specification паттерна из Domain-Driven Design, который инкапсулирует логику запроса к базе данных.

Основная идея

Вместо того, чтобы писать метод репозитория для каждого возможного набора фильтров:

// ❌ ПЛОХО: explosion of query methods
public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);
    List<User> findByAge(Integer age);
    List<User> findByNameAndAge(String name, Integer age);
    List<User> findByNameAndAgeAndCity(String name, Integer age, String city);
    List<User> findByNameOrAgeAndCity(String name, Integer age, String city);
    // ... и так далее для всех комбинаций
}

Правильно: Specifications

// Один универсальный метод
public interface UserRepository extends JpaRepository<User, Long>, 
                                       JpaSpecificationExecutor<User> {
    // Всего один метод для всех случаев!
}

Синтаксис и использование

Шаг 1: Реализуй интерфейс JpaSpecificationExecutor

@Repository
public interface UserRepository extends JpaRepository<User, Long>, 
                                       JpaSpecificationExecutor<User> {
    // JpaSpecificationExecutor даёт методы:
    // Optional<User> findOne(Specification<User> spec)
    // List<User> findAll(Specification<User> spec)
    // Page<User> findAll(Specification<User> spec, Pageable pageable)
    // long count(Specification<User> spec)
}

Шаг 2: Создай Specification

import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.*;

public class UserSpecifications {
    
    // Простая спецификация: по имени
    public static Specification<User> hasName(String name) {
        return (root, query, cb) -> {
            // root - сущность User
            // query - объект CriteriaQuery
            // cb - CriteriaBuilder (для создания условий)
            
            if (name == null) {
                return cb.conjunction();  // true условие
            }
            return cb.equal(root.get("name"), name);
        };
    }
    
    // Спецификация: возраст больше чем
    public static Specification<User> ageGreaterThan(Integer age) {
        return (root, query, cb) -> {
            if (age == null) {
                return cb.conjunction();
            }
            return cb.greaterThan(root.get("age"), age);
        };
    }
    
    // Спецификация: город
    public static Specification<User> fromCity(String city) {
        return (root, query, cb) -> {
            if (city == null) {
                return cb.conjunction();
            }
            return cb.equal(root.get("city"), city);
        };
    }
}

Шаг 3: Используй Specification в запросах

@Service
public class UserService {
    
    private final UserRepository userRepository;
    
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    // Простой поиск
    public List<User> findUsersByName(String name) {
        return userRepository.findAll(
            UserSpecifications.hasName(name)
        );
    }
    
    // Комбинирование спецификаций (AND)
    public List<User> findUsersByNameAndCity(String name, String city) {
        Specification<User> spec = 
            UserSpecifications.hasName(name)
                .and(UserSpecifications.fromCity(city));
        
        return userRepository.findAll(spec);
    }
    
    // Комбинирование спецификаций (OR)
    public List<User> findYoungOrFromCity(Integer age, String city) {
        Specification<User> spec = 
            UserSpecifications.ageGreaterThan(age)
                .or(UserSpecifications.fromCity(city));
        
        return userRepository.findAll(spec);
    }
    
    // С пагинацией
    public Page<User> searchUsers(String name, Integer age, String city, int page) {
        Specification<User> spec = 
            Specification.where(UserSpecifications.hasName(name))
                .and(UserSpecifications.ageGreaterThan(age))
                .and(UserSpecifications.fromCity(city));
        
        Pageable pageable = PageRequest.of(page, 20);
        return userRepository.findAll(spec, pageable);
    }
}

Примеры сложных условий

Like поиск

public class UserSpecifications {
    
    public static Specification<User> nameLike(String pattern) {
        return (root, query, cb) -> {
            if (pattern == null || pattern.isEmpty()) {
                return cb.conjunction();
            }
            return cb.like(
                cb.lower(root.get("name")),
                "%" + pattern.toLowerCase() + "%"
            );
        };
    }
}

// Использование:
List<User> results = userRepository.findAll(
    UserSpecifications.nameLike("john")
);  // Найдёт: john, John, JOHN, johnny и т.д.

Диапазон значений

public class UserSpecifications {
    
    public static Specification<User> ageBetween(Integer minAge, Integer maxAge) {
        return (root, query, cb) -> {
            if (minAge == null && maxAge == null) {
                return cb.conjunction();
            }
            
            List<Predicate> predicates = new ArrayList<>();
            
            if (minAge != null) {
                predicates.add(cb.greaterThanOrEqualTo(root.get("age"), minAge));
            }
            if (maxAge != null) {
                predicates.add(cb.lessThanOrEqualTo(root.get("age"), maxAge));
            }
            
            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}

// Использование:
List<User> users = userRepository.findAll(
    UserSpecifications.ageBetween(18, 65)
);

Работа с объединениями (JOIN)

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    
    @ManyToMany
    private List<Role> roles;  // Связь многие-ко-многим
}

@Entity
public class Role {
    @Id
    private Long id;
    private String name;
}

// Specification с JOIN
public class UserSpecifications {
    
    public static Specification<User> hasRole(String roleName) {
        return (root, query, cb) -> {
            if (roleName == null) {
                return cb.conjunction();
            }
            
            // Создаём JOIN
            Join<User, Role> roleJoin = root.join("roles", JoinType.INNER);
            
            return cb.equal(roleJoin.get("name"), roleName);
        };
    }
}

// SQL эквивалент:
// SELECT DISTINCT u FROM User u 
// INNER JOIN u.roles r 
// WHERE r.name = 'ADMIN'

Сложная бизнес-логика

public class OrderSpecifications {
    
    public static Specification<Order> isPending() {
        return (root, query, cb) -> 
            cb.equal(root.get("status"), OrderStatus.PENDING);
    }
    
    public static Specification<Order> isOverdue() {
        return (root, query, cb) -> 
            cb.lessThan(root.get("dueDate"), LocalDate.now());
    }
    
    public static Specification<Order> totalGreaterThan(BigDecimal amount) {
        return (root, query, cb) -> {
            if (amount == null) {
                return cb.conjunction();
            }
            return cb.greaterThan(root.get("total"), amount);
        };
    }
}

// Комплексный запрос: просроченные неоплаченные заказы
List<Order> overdueOrders = orderRepository.findAll(
    Specification.where(OrderSpecifications.isPending())
        .and(OrderSpecifications.isOverdue())
        .and(OrderSpecifications.totalGreaterThan(new BigDecimal("100.00")))
);

Реальный пример: поисковый фильтр

// DTO для фильтра
public class UserSearchFilter {
    private String name;
    private Integer minAge;
    private Integer maxAge;
    private String city;
    private String roleName;
}

// Service с универсальным поиском
@Service
public class UserService {
    
    private final UserRepository userRepository;
    
    public Page<UserDTO> search(UserSearchFilter filter, Pageable pageable) {
        // Строим спецификацию динамически
        Specification<User> spec = Specification.where(null);
        
        if (filter.getName() != null && !filter.getName().isEmpty()) {
            spec = spec.and(UserSpecifications.nameLike(filter.getName()));
        }
        
        if (filter.getMinAge() != null || filter.getMaxAge() != null) {
            spec = spec.and(
                UserSpecifications.ageBetween(
                    filter.getMinAge(), 
                    filter.getMaxAge()
                )
            );
        }
        
        if (filter.getCity() != null && !filter.getCity().isEmpty()) {
            spec = spec.and(UserSpecifications.fromCity(filter.getCity()));
        }
        
        if (filter.getRoleName() != null && !filter.getRoleName().isEmpty()) {
            spec = spec.and(UserSpecifications.hasRole(filter.getRoleName()));
        }
        
        return userRepository.findAll(spec, pageable)
            .map(UserMapper::toDTO);
    }
}

// REST контроллер
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    
    private final UserService userService;
    
    @GetMapping("/search")
    public ResponseEntity<Page<UserDTO>> search(
            @RequestParam(required = false) String name,
            @RequestParam(required = false) Integer minAge,
            @RequestParam(required = false) Integer maxAge,
            @RequestParam(required = false) String city,
            @RequestParam(required = false) String role,
            @PageableDefault(size = 20) Pageable pageable) {
        
        UserSearchFilter filter = new UserSearchFilter();
        filter.setName(name);
        filter.setMinAge(minAge);
        filter.setMaxAge(maxAge);
        filter.setCity(city);
        filter.setRoleName(role);
        
        Page<UserDTO> results = userService.search(filter, pageable);
        return ResponseEntity.ok(results);
    }
}

// Использование:
// GET /api/v1/users/search?name=john&minAge=25&maxAge=40&city=NYC

Лучшие практики

1. Null-safety в спецификациях

// ✅ ПРАВИЛЬНО
public static Specification<User> hasName(String name) {
    return (root, query, cb) -> {
        if (name == null) {
            return cb.conjunction();  // true условие, не фильтруем
        }
        return cb.equal(root.get("name"), name);
    };
}

// ❌ НЕПРАВИЛЬНО
public static Specification<User> hasName(String name) {
    return (root, query, cb) -> 
        cb.equal(root.get("name"), name);  // NPE если name = null
}

2. Отделение спецификаций в отдельный класс

// ✅ Хорошая структура
src/
  domain/
    User.java
  repository/
    UserRepository.java
  specification/
    UserSpecifications.java
  service/
    UserService.java

3. Используй Specification.where() для удобства

// ✅ Читаемо
Specification<User> spec = Specification.where(
    UserSpecifications.hasName(name)
).and(UserSpecifications.fromCity(city));

// ❌ Менее читаемо
Specification<User> spec = 
    UserSpecifications.hasName(name)
    .and(UserSpecifications.fromCity(city));

4. Тестирование спецификаций

@SpringBootTest
class UserSpecificationsTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void testNameSpecification() {
        // Подготовка
        userRepository.save(new User("John", 30));
        userRepository.save(new User("Jane", 25));
        
        // Выполнение
        List<User> results = userRepository.findAll(
            UserSpecifications.hasName("John")
        );
        
        // Проверка
        assertEquals(1, results.size());
        assertEquals("John", results.get(0).getName());
    }
}

Заключение

Specification в Spring Data JPA:

  • Устраняет explosion of query methods
  • Позволяет создавать динамические запросы
  • Реализует паттерн Specification из DDD
  • Поддерживает сложные условия, JOIN, пагинацию
  • Тестируется легко
  • Производительна — генерирует оптимальные SQL запросы