← Kursa Dön
📄 Text · 18 min

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, orderBy gibi özellikleri kontrol eder

  • `CriteriaBuilder`: Koşul (predicate) oluşturma fabrikası — equal, like, between, greaterThan vb.

💡 Lambda ile Specification: Specification fonksiyonel 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 true dönen bir predicate'tir (SQL'de 1=1). `cb.disjunction()` ise her zaman false dö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);
ÖzellikSpecificationQuerydsl
Ek dependencyYok (Spring Data JPA dahili)Querydsl kütüphanesi
Tip güvenliğiMetamodel ileVarsayılan
API stiliLambda/fonksiyonelFluent/builder
Öğrenme eğrisiOrtaDüşük-orta
IDE desteğiZayı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") ile

  • Subquery 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