Specification Pattern — Dinamik Sorgular
Giriş — Neden Bu Konu Önemli?
Gerçek dünya uygulamalarında kullanıcılar, verileri birçok farklı kritere göre filtrelemek ister: kategoriye göre, fiyat aralığına göre, tarihe göre, duruma göre — ve bunların kombinasyonlarına göre. Her filtre kombinasyonu için ayrı bir repository metodu yazmak sürdürülemez hale gelir. İşte Specification Pattern tam bu problemi çözer.
Gerçek Hayat Analojisi: Bir e-ticaret sitesinin sol panelindeki filtreleri düşünün: kategori, fiyat aralığı, marka, renk, stok durumu, puanlama... Kullanıcı bunlardan istediğini seçip "Filtrele" diyor. Backend'de her kombinasyon için ayrı SQL yazmak yerine, filtreleri Lego blokları gibi birleştirip tek bir sorgu oluşturursunuz. İşte Specification Pattern bu Lego yaklaşımıdır.
Problem: Kombinatorik Patlama
Diyelim ki ürün listeleme sayfanız var ve şu filtreler mevcut:
Kategori (category)
Minimum fiyat (minPrice)
Maksimum fiyat (maxPrice)
Stok durumu (inStock)
Bu 4 filtre ile 2⁴ = 16 farklı kombinasyon mümkün:
// Bu sürdürülebilir DEĞİL!
List<Product> findByCategory(String category);
List<Product> findByCategoryAndPriceBetween(String category, BigDecimal min, BigDecimal max);
List<Product> findByCategoryAndInStock(String category, boolean inStock);
List<Product> findByPriceBetweenAndInStock(BigDecimal min, BigDecimal max, boolean inStock);
List<Product> findByCategoryAndPriceBetweenAndInStock(...);
// ... 16 metot! Ve her yeni filtre eklediğinizde sayı 2 katına çıkar!5 filtre = 32, 6 filtre = 64, 10 filtre = 1024 kombinasyon! Specification pattern ile her filtreyi bağımsız bir birim olarak tanımlar ve dinamik olarak birleştirirsiniz.
JpaSpecificationExecutor
Spring Data JPA, Specification pattern'i kutudan çıkar destekler. Repository'niz JpaSpecificationExecutor<T> interface'ini extend etmelidir:
public interface ProductRepository extends
JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
// Aşağıdaki metotlar otomatik olarak eklenir:
// Optional<Product> findOne(Specification<Product> spec);
// List<Product> findAll(Specification<Product> spec);
// Page<Product> findAll(Specification<Product> spec, Pageable pageable);
// List<Product> findAll(Specification<Product> spec, Sort sort);
// long count(Specification<Product> spec);
// boolean exists(Specification<Product> spec);
// void delete(Specification<Product> spec);
}JpaSpecificationExecutor tek başına yeterlidir — ekstra dependency gerekmez.
Specification Interface
Specification<T> fonksiyonel bir interface'dir ve tek bir metodu vardır:
@FunctionalInterface
public interface Specification<T> {
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}`Root<T>`: Entity'nin kök referansı — field'lara erişim sağlar (
root.get("name"))`CriteriaQuery<?>`: Sorgunun kendisi —
distinct,orderBygibi özellikleri kontrol eder`CriteriaBuilder`: Koşul (predicate) oluşturma fabrikası —
equal,like,between,greaterThanvb.
💡 Lambda ile Specification:
Specificationfonksiyonel interface olduğu için lambda ifadesi ile kısa ve okunabilir şekilde yazılabilir.
Specification Tanımlama
Her filtreyi bağımsız bir Specification olarak tanımlayın:
public class ProductSpecifications {
public static Specification<Product> hasCategory(String category) {
return (root, query, cb) ->
cb.equal(root.get("category"), category);
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) ->
cb.between(root.get("price"), min, max);
}
public static Specification<Product> priceGreaterThan(BigDecimal minPrice) {
return (root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("price"), minPrice);
}
public static Specification<Product> priceLessThan(BigDecimal maxPrice) {
return (root, query, cb) ->
cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}
public static Specification<Product> isInStock() {
return (root, query, cb) ->
cb.greaterThan(root.get("stockQuantity"), 0);
}
public static Specification<Product> nameLike(String keyword) {
return (root, query, cb) ->
cb.like(cb.lower(root.get("name")),
"%" + keyword.toLowerCase() + "%");
}
public static Specification<Product> createdAfter(LocalDate date) {
return (root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("createdAt"), date.atStartOfDay());
}
public static Specification<Product> hasStatus(ProductStatus status) {
return (root, query, cb) ->
cb.equal(root.get("status"), status);
}
}Null-Safe Specification
Değer null ise filtre uygulanmamalı — bu çok yaygın bir ihtiyaçtır:
// Null-safe — değer null ise filtre uygulanmaz
public static Specification<Product> hasCategoryIfPresent(String category) {
return (root, query, cb) -> {
if (category == null || category.isBlank()) {
return cb.conjunction(); // her zaman true — filtre yok
}
return cb.equal(root.get("category"), category);
};
}
// Daha kısa versiyon — static helper
public static Specification<Product> optionalEqual(String fieldName, Object value) {
return (root, query, cb) -> {
if (value == null) return cb.conjunction();
return cb.equal(root.get(fieldName), value);
};
}💡 `cb.conjunction()` her zaman
truedönen bir predicate'tir (SQL'de1=1). `cb.disjunction()` ise her zamanfalsedöner (1=0). Null-safe specification'larda bu ikisi çok kullanılır.
Composable Specifications — Birleştirme
Specification'ları and(), or() ve not() ile birleştirebilirsiniz:
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> searchProducts(ProductSearchCriteria criteria, Pageable pageable) {
Specification<Product> spec = Specification.where(null); // Başlangıç — boş spec
if (criteria.getCategory() != null) {
spec = spec.and(ProductSpecifications.hasCategory(criteria.getCategory()));
}
if (criteria.getMinPrice() != null) {
spec = spec.and(ProductSpecifications.priceGreaterThan(criteria.getMinPrice()));
}
if (criteria.getMaxPrice() != null) {
spec = spec.and(ProductSpecifications.priceLessThan(criteria.getMaxPrice()));
}
if (criteria.isOnlyInStock()) {
spec = spec.and(ProductSpecifications.isInStock());
}
if (criteria.getKeyword() != null) {
spec = spec.and(ProductSpecifications.nameLike(criteria.getKeyword()));
}
if (criteria.getCreatedAfter() != null) {
spec = spec.and(ProductSpecifications.createdAfter(criteria.getCreatedAfter()));
}
return productRepository.findAll(spec, pageable);
}
}OR ile Birleştirme
// Kategori VEYA anahtar kelime ile arama
Specification<Product> spec = Specification
.where(ProductSpecifications.hasCategory("elektronik"))
.or(ProductSpecifications.nameLike("laptop"));
// Daha karmaşık: (kategori = X AND fiyat > Y) OR (stokta AND keyword = Z)
Specification<Product> spec = Specification
.where(
ProductSpecifications.hasCategory("elektronik")
.and(ProductSpecifications.priceGreaterThan(new BigDecimal("100")))
)
.or(
ProductSpecifications.isInStock()
.and(ProductSpecifications.nameLike("indirim"))
);NOT ile Olumsuzlama
// Stokta olmayan ürünler
Specification<Product> outOfStock = Specification.not(ProductSpecifications.isInStock());
// Belirli kategori dışındaki ürünler
Specification<Product> notElektronik = Specification.not(
ProductSpecifications.hasCategory("elektronik"));CriteriaBuilder İleri Kullanım
JOIN ile İlişkili Entity'de Filtreleme
// Ürünün etiketlerinde (tags) belirli bir etiket arama
public static Specification<Product> hasTagName(String tagName) {
return (root, query, cb) -> {
Join<Product, Tag> tags = root.join("tags", JoinType.INNER);
return cb.equal(tags.get("name"), tagName);
};
}
// Ürünün kategorisinin adına göre filtreleme (nested ilişki)
public static Specification<Product> hasCategoryName(String categoryName) {
return (root, query, cb) -> {
Join<Product, Category> category = root.join("category", JoinType.INNER);
return cb.equal(category.get("name"), categoryName);
};
}
// Birden fazla etiketten herhangi birine sahip ürünler
public static Specification<Product> hasAnyTag(List<String> tagNames) {
return (root, query, cb) -> {
Join<Product, Tag> tags = root.join("tags", JoinType.INNER);
query.distinct(true); // Kartezyen çarpımı önle
return tags.get("name").in(tagNames);
};
}Subquery
// Belirli sayıdan fazla satışı olan ürünler
public static Specification<Product> hasSalesGreaterThan(int minSales) {
return (root, query, cb) -> {
Subquery<Long> subquery = query.subquery(Long.class);
Root<OrderItem> orderItem = subquery.from(OrderItem.class);
subquery.select(cb.count(orderItem))
.where(cb.equal(orderItem.get("product"), root));
return cb.greaterThan(subquery, (long) minSales);
};
}
// En son yorum tarihi belirli bir tarihten sonra olan ürünler
public static Specification<Product> hasRecentReview(LocalDate since) {
return (root, query, cb) -> {
Subquery<LocalDateTime> subquery = query.subquery(LocalDateTime.class);
Root<Review> review = subquery.from(Review.class);
subquery.select(cb.greatest(review.get("createdAt")))
.where(cb.equal(review.get("product"), root));
return cb.greaterThanOrEqualTo(subquery, since.atStartOfDay());
};
}Aggregate Functions
// Ortalama puanı belirli bir değerden yüksek olan ürünler
public static Specification<Product> hasAverageRatingAbove(double minRating) {
return (root, query, cb) -> {
Subquery<Double> subquery = query.subquery(Double.class);
Root<Review> review = subquery.from(Review.class);
subquery.select(cb.avg(review.get("rating")))
.where(cb.equal(review.get("product"), root));
return cb.greaterThanOrEqualTo(subquery, minRating);
};
}Controller'dan Specification'a
DTO ile Arama Kriterleri
// Arama kriterleri DTO'su
public record ProductSearchCriteria(
String category,
BigDecimal minPrice,
BigDecimal maxPrice,
boolean onlyInStock,
String keyword,
LocalDate createdAfter,
List<String> tags,
ProductStatus status,
String sortBy,
String sortDirection
) {}Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
@GetMapping
public Page<ProductDto> searchProducts(
@RequestParam(required = false) String category,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(defaultValue = "false") boolean inStock,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate createdAfter,
@RequestParam(required = false) List<String> tags,
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
ProductSearchCriteria criteria = new ProductSearchCriteria(
category, minPrice, maxPrice, inStock, keyword,
createdAfter, tags, null, null, null);
return productService.searchProducts(criteria, pageable);
}
}Specification + @EntityGraph
Specification ile birlikte fetch stratejisini de kontrol etmek:
public interface ProductRepository extends
JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
// @EntityGraph + Specification
@EntityGraph(attributePaths = {"category", "brand", "images"})
Page<Product> findAll(Specification<Product> spec, Pageable pageable);
}⚠️ Dikkat: @EntityGraph + Specification birlikte kullanıldığında, collection fetch (OneToMany) ile sayfalama problemi yaşanabilir. ToOne ilişkiler için güvenlidir.
Type-Safe Specification (Metamodel)
String field isimleri hata yapımına açıktır. JPA Metamodel ile tip güvenli specification yazabilirsiniz:
// JPA Metamodel otomatik üretir (hibernate-jpamodelgen):
// Product_ sınıfı
@StaticMetamodel(Product.class)
public class Product_ {
public static volatile SingularAttribute<Product, String> name;
public static volatile SingularAttribute<Product, BigDecimal> price;
public static volatile SingularAttribute<Product, String> category;
public static volatile SingularAttribute<Product, Integer> stockQuantity;
}
// Tip güvenli specification
public static Specification<Product> hasCategory(String category) {
return (root, query, cb) ->
cb.equal(root.get(Product_.category), category); // String yerine metamodel
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) ->
cb.between(root.get(Product_.price), min, max);
}Metamodel kullanımının avantajı: field adı değiştiğinde derleme zamanında hata alırsınız, runtime'da değil.
Querydsl Alternatifi
Specification dışında bir diğer popüler alternatif Querydsl'dir. Daha fluent ve tip güvenli bir API sunar:
// Querydsl ile aynı sorgu
QProduct product = QProduct.product;
BooleanBuilder builder = new BooleanBuilder();
if (category != null) {
builder.and(product.category.eq(category));
}
if (minPrice != null) {
builder.and(product.price.goe(minPrice));
}
if (keyword != null) {
builder.and(product.name.containsIgnoreCase(keyword));
}
Page<Product> results = productRepo.findAll(builder, pageable);| Özellik | Specification | Querydsl |
|---|---|---|
| Ek dependency | Yok (Spring Data JPA dahili) | Querydsl kütüphanesi |
| Tip güvenliği | Metamodel ile | Varsayılan |
| API stili | Lambda/fonksiyonel | Fluent/builder |
| Öğrenme eğrisi | Orta | Düşük-orta |
| IDE desteği | Zayıf (string field adı) | Güçlü (otomatik tamamlama) |
Gerçek Dünya Örneği: İş İlanı Arama
Birden fazla kavramı birleştiren bütünleşik bir örnek — bir iş ilanı platformunun arama motoru:
// Specification'lar
public class JobSpecifications {
public static Specification<Job> inCity(String city) {
return (root, query, cb) -> {
if (city == null) return cb.conjunction();
return cb.equal(root.get("city"), city);
};
}
public static Specification<Job> salaryRange(Integer min, Integer max) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (min != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("salaryMin"), min));
}
if (max != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("salaryMax"), max));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
public static Specification<Job> hasExperienceLevel(ExperienceLevel level) {
return (root, query, cb) -> {
if (level == null) return cb.conjunction();
return cb.equal(root.get("experienceLevel"), level);
};
}
public static Specification<Job> requiresSkill(String skill) {
return (root, query, cb) -> {
if (skill == null) return cb.conjunction();
Join<Job, Skill> skills = root.join("requiredSkills", JoinType.INNER);
query.distinct(true);
return cb.equal(cb.lower(skills.get("name")), skill.toLowerCase());
};
}
public static Specification<Job> isRemote(Boolean remote) {
return (root, query, cb) -> {
if (remote == null) return cb.conjunction();
return cb.equal(root.get("remote"), remote);
};
}
public static Specification<Job> postedWithinDays(Integer days) {
return (root, query, cb) -> {
if (days == null) return cb.conjunction();
LocalDateTime cutoff = LocalDateTime.now().minusDays(days);
return cb.greaterThanOrEqualTo(root.get("postedAt"), cutoff);
};
}
public static Specification<Job> keywordSearch(String keyword) {
return (root, query, cb) -> {
if (keyword == null || keyword.isBlank()) return cb.conjunction();
String pattern = "%" + keyword.toLowerCase() + "%";
return cb.or(
cb.like(cb.lower(root.get("title")), pattern),
cb.like(cb.lower(root.get("description")), pattern),
cb.like(cb.lower(root.get("companyName")), pattern)
);
};
}
}
// Service
@Service
@Transactional(readOnly = true)
public class JobSearchService {
private final JobRepository jobRepo;
public Page<JobDto> search(JobSearchRequest req, Pageable pageable) {
Specification<Job> spec = Specification.where(null)
.and(JobSpecifications.keywordSearch(req.keyword()))
.and(JobSpecifications.inCity(req.city()))
.and(JobSpecifications.salaryRange(req.minSalary(), req.maxSalary()))
.and(JobSpecifications.hasExperienceLevel(req.experienceLevel()))
.and(JobSpecifications.isRemote(req.remote()))
.and(JobSpecifications.postedWithinDays(req.postedWithinDays()));
// Birden fazla skill filtresi
if (req.skills() != null) {
for (String skill : req.skills()) {
spec = spec.and(JobSpecifications.requiresSkill(skill));
}
}
return jobRepo.findAll(spec, pageable).map(JobDto::from);
}
}Bu yapıda 7 filtre var ve kullanıcı bunlardan istediği kombinasyonu seçebilir. Tüm null değerler otomatik olarak atlanır, sadece seçilen filtreler SQL'e dahil edilir.
Specification ile Test Yazma
@DataJpaTest
class ProductSpecificationsTest {
@Autowired
private ProductRepository productRepo;
@BeforeEach
void setup() {
productRepo.saveAll(List.of(
new Product("Laptop", new BigDecimal("2999.99"), "Elektronik", 10),
new Product("Telefon", new BigDecimal("1499.99"), "Elektronik", 0),
new Product("Masa", new BigDecimal("799.99"), "Mobilya", 5),
new Product("Kitap", new BigDecimal("49.99"), "Kırtasiye", 100)
));
}
@Test
void shouldFilterByCategory() {
var spec = ProductSpecifications.hasCategory("Elektronik");
var results = productRepo.findAll(spec);
assertEquals(2, results.size());
}
@Test
void shouldFilterByPriceRange() {
var spec = ProductSpecifications.priceBetween(
new BigDecimal("100"), new BigDecimal("1500"));
var results = productRepo.findAll(spec);
assertEquals(2, results.size()); // Telefon + Masa
}
@Test
void shouldCombineFilters() {
var spec = Specification.where(ProductSpecifications.hasCategory("Elektronik"))
.and(ProductSpecifications.isInStock());
var results = productRepo.findAll(spec);
assertEquals(1, results.size()); // Sadece Laptop (stokta)
}
@Test
void shouldHandleNullGracefully() {
var spec = ProductSpecifications.hasCategoryIfPresent(null);
var results = productRepo.findAll(spec);
assertEquals(4, results.size()); // Tüm ürünler
}
}Özet
Specification Pattern dinamik sorguların Lego blokları gibi birleştirilmesini sağlar — kombinatorik patlamayı önler
JpaSpecificationExecutor Spring Data JPA ile kutudan çıkar — ek dependency gerekmez
Null-safe specification yazın — null değerler için
cb.conjunction()döndürün`and()`, `or()`, `not()` ile specification'ları birleştirin — karmaşık koşullar oluşturun
JOIN ile ilişkili entity'lerde filtreleme yapabilirsiniz —
root.join("tags")ileSubquery desteği vardır — aggregate fonksiyonlar ve karmaşık sorgular için
Her entity için bir Specification sınıfı oluşturun (ProductSpecifications, OrderSpecifications)
Tip güvenliği için JPA Metamodel veya Querydsl alternatifini değerlendirin
AI Asistan
Sorularını yanıtlamaya hazır