Spring Data Elasticsearch — Repository Pattern
Giriş — ORM'in Arama Motoru Versiyonu
Spring Data JPA'yı biliyorsanız, Hibernate'in veritabanı ile aranızdaki tercüman olduğunu da bilirsiniz. SQL yazmadan findByEmail(String email) diye method tanımlarsınız, Hibernate gerisini halleder. Şimdi aynı konforu Elasticsearch için düşünün — CRUD, arama, pagination, aggregation... hepsini Spring'in repository pattern'ı ile yapabilirsiniz.
Spring Data Elasticsearch, Elasticsearch Java Client üzerine oturan bir soyutlama katmanıdır. Low-level client'ın gücünü kaybetmeden Spring ekosisteminin kolaylığını kazanırsınız. Bu derste sıfırdan bir Spring Boot projesi kuracak, entity tanımlayacak, repository oluşturacak ve hem derived query hem native query ile arama yapacağız.
1. Proje Kurulumu
1.1 Maven Dependencies
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>💡 spring-boot-starter-data-elasticsearch otomatik olarak Elasticsearch Java Client, Jackson ve gerekli tüm bağımlılıkları çeker.
1.2 application.yml
spring:
elasticsearch:
uris: http://localhost:9200
# Birden fazla node:
# uris: http://node1:9200,http://node2:9200
username: elastic # Opsiyonel
password: changeme # Opsiyonel
connection-timeout: 5s
socket-timeout: 30s
logging:
level:
org.springframework.data.elasticsearch: DEBUG
tracer: TRACE # HTTP isteklerini logla (dev ortamı için)1.3 Proje Yapısı
src/main/java/com/example/esdemo/
├── EsDemoApplication.java
├── document/
│ └── Product.java # @Document entity
├── repository/
│ ├── ProductRepository.java # ElasticsearchRepository
│ └── ProductCustomRepository.java
├── service/
│ └── ProductService.java
└── controller/
└── ProductController.java2. @Document Entity Tanımı
package com.example.esdemo.document;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Document(indexName = "products")
@Setting(shards = 3, replicas = 1)
public class Product {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "standard")
private String name;
@Field(type = FieldType.Text, analyzer = "standard")
private String description;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Double)
private double price;
@Field(type = FieldType.Integer)
private int stock;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
private LocalDateTime createdAt;
@Field(type = FieldType.Boolean)
private boolean active;
public Product() {}
public Product(String name, String description, String category,
double price, int stock, List<String> tags) {
this.name = name;
this.description = description;
this.category = category;
this.price = price;
this.stock = stock;
this.tags = tags;
this.createdAt = LocalDateTime.now();
this.active = true;
}
// Getter & Setter (kısaltıldı — tüm alanlar için standart getter/setter)
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
public int getStock() { return stock; }
public void setStock(int stock) { this.stock = stock; }
public String getCategory() { return category; }
public List<String> getTags() { return tags; }
public LocalDateTime getCreatedAt() { return createdAt; }
public String getDescription() { return description; }
public boolean isActive() { return active; }
}Annotation Rehberi
| Annotation | Açıklama |
|---|---|
@Document(indexName) | Elasticsearch index adı |
@Id | Document ID — String veya Long |
@Field(type, analyzer) | Alan tipi ve analiz ayarı |
@Setting(shards, replicas) | Index ayarları |
@MultiField + @InnerField | Aynı alan için birden fazla mapping (text + keyword) |
Multi-Field Mapping
@MultiField(
mainField = @Field(type = FieldType.Text, analyzer = "standard"),
otherFields = {
@InnerField(suffix = "keyword", type = FieldType.Keyword)
}
)
private String name;
// → name (text) + name.keyword (keyword) — hem arama hem exact match3. ElasticsearchRepository — CRUD
3.1 Repository Interface
package com.example.esdemo.repository;
import com.example.esdemo.document.Product;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// Hazır metodlar: save, saveAll, findById, findAll,
// existsById, count, deleteById, deleteAll
}3.2 Service ile CRUD Kullanımı
package com.example.esdemo.service;
import com.example.esdemo.document.Product;
import com.example.esdemo.repository.ProductRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public Product save(Product product) {
return repository.save(product); // ID yoksa otomatik üretilir
}
public Iterable<Product> saveAll(List<Product> products) {
return repository.saveAll(products); // Bulk insert
}
public Optional<Product> findById(String id) {
return repository.findById(id);
}
public Product update(String id, Product updated) {
return repository.findById(id).map(existing -> {
existing.setName(updated.getName());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());
return repository.save(existing); // Aynı ID ile save = update
}).orElseThrow(() -> new RuntimeException("Not found: " + id));
}
public void deleteById(String id) {
repository.deleteById(id);
}
}⚠️ Spring Data Elasticsearch'te partial update yok — save() tüm document'ı yeniden yazar. Partial update için ElasticsearchOperations kullanın (bölüm 6).
4. Derived Query Methods
Spring Data'nın en büyük sihri: method adından otomatik sorgu üretimi.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// Kategoriye göre bul
List<Product> findByCategory(String category);
// Fiyat aralığı
List<Product> findByPriceBetween(double min, double max);
// Stoku 0'dan büyük
List<Product> findByStockGreaterThan(int stock);
// İsimde kelime arama (match query)
List<Product> findByNameContaining(String keyword);
// Aktif, fiyata göre sıralı
List<Product> findByActiveTrueOrderByPriceAsc();
// Kategori + fiyat filtresi
List<Product> findByCategoryAndPriceLessThan(String category, double maxPrice);
// Tag arama
List<Product> findByTagsContaining(String tag);
// Pagination destekli
Page<Product> findByCategory(String category, Pageable pageable);
// İsim VEYA açıklamada arama
List<Product> findByNameContainingOrDescriptionContaining(
String nameKey, String descKey);
// Sayma ve varlık
long countByCategory(String category);
boolean existsByNameAndCategory(String name, String category);
void deleteByCategory(String category);
}Keyword Tablosu
| Keyword | ES Query | Örnek |
|---|---|---|
Between | range | findByPriceBetween(min, max) |
LessThan/GreaterThan | range | findByPriceLessThan(max) |
Containing | match/wildcard | findByNameContaining(kw) |
And / Or | bool must/should | findByNameAndCategory(...) |
Not | must_not | findByCategoryNot(cat) |
OrderBy...Asc/Desc | sort | findByXOrderByPriceDesc() |
True/False | term (bool) | findByActiveTrue() |
In | terms | findByCategoryIn(List) |
5. @Query Annotation — Native Query
Derived query yetmediğinde, doğrudan Elasticsearch JSON sorgusu:
import org.springframework.data.elasticsearch.annotations.Query;
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// Native query — ?0, ?1, ?2 = parametreler
@Query("""
{
"bool": {
"must": [{ "match": { "name": "?0" } }],
"filter": [
{ "term": { "category": "?1" } },
{ "range": { "price": { "lte": ?2 } } }
]
}
}
""")
Page<Product> searchProducts(String keyword, String category,
double maxPrice, Pageable pageable);
// Fuzzy search
@Query("""
{
"multi_match": {
"query": "?0",
"fields": ["name^3", "description", "tags^2"],
"fuzziness": "AUTO"
}
}
""")
List<Product> fuzzySearch(String keyword);
// Stokta olanlar
@Query("""
{
"bool": {
"must": { "match_all": {} },
"filter": { "range": { "stock": { "gt": 0 } } }
}
}
""")
Page<Product> findInStock(Pageable pageable);
}6. ElasticsearchOperations — Gelişmiş Sorgular
Repository yetmediğinde (aggregation, highlight, custom scoring) ElasticsearchOperations devreye girer.
6.1 NativeQuery ile Arama
package com.example.esdemo.service;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.json.JsonData;
import com.example.esdemo.document.Product;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Service;
@Service
public class AdvancedSearchService {
private final ElasticsearchOperations operations;
public AdvancedSearchService(ElasticsearchOperations operations) {
this.operations = operations;
}
public SearchHits<Product> search(String keyword, String category,
Double minPrice, Double maxPrice,
int page, int size) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.bool(b -> {
if (keyword != null && !keyword.isBlank()) {
b.must(m -> m.multiMatch(mm -> mm
.query(keyword)
.fields("name^3", "description", "tags^2")
.fuzziness("AUTO")));
}
if (category != null) {
b.filter(f -> f.term(t -> t
.field("category").value(category)));
}
if (minPrice != null || maxPrice != null) {
b.filter(f -> f.range(r -> {
r.field("price");
if (minPrice != null) r.gte(JsonData.of(minPrice));
if (maxPrice != null) r.lte(JsonData.of(maxPrice));
return r;
}));
}
b.filter(f -> f.range(r -> r.field("stock").gt(JsonData.of(0))));
return b;
}))
.withSort(s -> s.field(f -> f.field("price").order(SortOrder.Asc)))
.withPageable(PageRequest.of(page, size))
.build();
return operations.search(query, Product.class);
}
}6.2 SearchHits Kullanımı
SearchHits<Product> searchHits = service.search(
"laptop", "electronics", null, 80000.0, 0, 10);
System.out.println("Toplam: " + searchHits.getTotalHits());
System.out.println("Max score: " + searchHits.getMaxScore());
for (SearchHit<Product> hit : searchHits.getSearchHits()) {
Product p = hit.getContent();
System.out.printf("[%.2f] %s — %.2f TL%n",
hit.getScore(), p.getName(), p.getPrice());
// Highlight
hit.getHighlightField("name")
.forEach(h -> System.out.println(" → " + h));
}6.3 Highlight ile Arama
import org.springframework.data.elasticsearch.core.query.highlight.*;
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.match(m -> m
.field("description").query("profesyonel bilgisayar")))
.withHighlightQuery(new HighlightQuery(
new Highlight(List.of(
new HighlightField("description",
HighlightFieldParameters.builder()
.withPreTags("<em>").withPostTags("</em>")
.withFragmentSize(200)
.withNumberOfFragments(3)
.build()),
new HighlightField("name")
)), Product.class))
.build();
SearchHits<Product> hits = operations.search(query, Product.class);
for (SearchHit<Product> hit : hits) {
hit.getHighlightFields().forEach((field, fragments) ->
System.out.println(field + ": " + String.join(" ... ", fragments)));
}6.4 Aggregation
import org.springframework.data.elasticsearch.client.elc.ElasticsearchAggregations;
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.matchAll(m -> m))
.withMaxResults(0) // Sadece aggregation, document çekme
.withAggregation("categories",
a -> a.terms(t -> t.field("category").size(20)))
.withAggregation("avg_price",
a -> a.avg(av -> av.field("price")))
.build();
SearchHits<Product> result = operations.search(query, Product.class);
ElasticsearchAggregations aggs = (ElasticsearchAggregations)
result.getAggregations();
// Terms aggregation parse
aggs.get("categories").aggregation().getAggregate().sterms()
.buckets().array().forEach(bucket ->
System.out.printf(" %s: %d%n",
bucket.key().stringValue(), bucket.docCount()));
// Avg
double avg = aggs.get("avg_price").aggregation()
.getAggregate().avg().value();
System.out.printf("Ortalama fiyat: %.2f%n", avg);7. Pagination
7.1 Repository ile Pagination
import org.springframework.data.domain.*;
@Service
public class PaginatedProductService {
private final ProductRepository repository;
public PaginatedProductService(ProductRepository repository) {
this.repository = repository;
}
public Page<Product> getProducts(int page, int size, String sortField) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(Sort.Direction.ASC, sortField));
Page<Product> result = repository.findByCategory("electronics", pageable);
System.out.println("Toplam: " + result.getTotalElements());
System.out.println("Sayfa: " + result.getNumber() + "/" + result.getTotalPages());
System.out.println("İlk: " + result.isFirst() + ", Son: " + result.isLast());
return result;
}
}7.2 REST Controller
package com.example.esdemo.controller;
import com.example.esdemo.document.Product;
import com.example.esdemo.repository.ProductRepository;
import com.example.esdemo.service.ProductService;
import org.springframework.data.domain.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
private final ProductRepository repository;
public ProductController(ProductService service, ProductRepository repository) {
this.service = service;
this.repository = repository;
}
@GetMapping
public Page<Product> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "price") String sort) {
return repository.findAll(PageRequest.of(page, size, Sort.by(sort)));
}
@GetMapping("/search")
public List<Product> search(@RequestParam String q) {
return repository.fuzzySearch(q);
}
@PostMapping
public Product create(@RequestBody Product product) {
return service.save(product);
}
@GetMapping("/{id}")
public Product get(@PathVariable String id) {
return service.findById(id)
.orElseThrow(() -> new RuntimeException("Not found: " + id));
}
@DeleteMapping("/{id}")
public void delete(@PathVariable String id) {
service.deleteById(id);
}
}8. Custom Repository Implementation
Derived query ve @Query yetmediğinde, custom implementation ile tam kontrol:
// 1. Custom interface
public interface ProductCustomRepository {
SearchHits<Product> advancedSearch(String keyword, String category,
Double minPrice, Double maxPrice,
int page, int size);
}// 2. Implementation — sınıf adı "Impl" ile bitmeli!
import co.elastic.clients.json.JsonData;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.stereotype.Component;
@Component
public class ProductCustomRepositoryImpl implements ProductCustomRepository {
private final ElasticsearchOperations operations;
public ProductCustomRepositoryImpl(ElasticsearchOperations operations) {
this.operations = operations;
}
@Override
public SearchHits<Product> advancedSearch(String keyword, String category,
Double minPrice, Double maxPrice,
int page, int size) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.bool(b -> {
if (keyword != null) {
b.must(m -> m.multiMatch(mm -> mm
.query(keyword).fields("name^3", "description")
.fuzziness("AUTO")));
}
if (category != null) {
b.filter(f -> f.term(t -> t.field("category").value(category)));
}
if (minPrice != null) {
b.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(minPrice))));
}
if (maxPrice != null) {
b.filter(f -> f.range(r -> r.field("price").lte(JsonData.of(maxPrice))));
}
return b;
}))
.withPageable(PageRequest.of(page, size))
.build();
return operations.search(query, Product.class);
}
}// 3. Ana repository'ye ekle — derived + custom birlikte
public interface ProductRepository
extends ElasticsearchRepository<Product, String>,
ProductCustomRepository {
// Tüm derived query'ler + custom advancedSearch
}9. Bütünleşik Örnek — CommandLineRunner
package com.example.esdemo;
import com.example.esdemo.document.Product;
import com.example.esdemo.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.util.List;
@SpringBootApplication
public class EsDemoApplication {
public static void main(String[] args) {
SpringApplication.run(EsDemoApplication.class, args);
}
@Bean
CommandLineRunner initData(ProductRepository repository) {
return args -> {
repository.deleteAll();
repository.saveAll(List.of(
new Product("MacBook Pro 16", "Apple M3 Max laptop",
"electronics", 74999.99, 50, List.of("apple", "laptop")),
new Product("iPhone 15 Pro", "A17 Pro telefon",
"electronics", 64999.99, 100, List.of("apple", "phone")),
new Product("Samsung Galaxy S24", "AI telefon",
"electronics", 49999.99, 80, List.of("samsung", "phone")),
new Product("Sony WH-1000XM5", "ANC kulaklık",
"electronics", 12999.99, 200, List.of("sony", "headphone")),
new Product("Clean Code", "Robert C. Martin",
"books", 349.99, 300, List.of("java", "best-practice"))
));
System.out.println("✅ Veriler yüklendi");
// Derived query örnekleri
System.out.println("\n--- Electronics ---");
repository.findByCategory("electronics")
.forEach(p -> System.out.printf(" %s — %.2f TL%n", p.getName(), p.getPrice()));
System.out.println("\n--- 10K-60K arası ---");
repository.findByPriceBetween(10000, 60000)
.forEach(p -> System.out.printf(" %s — %.2f TL%n", p.getName(), p.getPrice()));
System.out.println("\n--- 'apple' tag ---");
repository.findByTagsContaining("apple")
.forEach(p -> System.out.printf(" %s%n", p.getName()));
System.out.println("\nToplam: " + repository.count());
System.out.println("Elektronik: " + repository.countByCategory("electronics"));
};
}
}10. Integration Test
package com.example.esdemo;
import com.example.esdemo.document.Product;
import com.example.esdemo.repository.ProductRepository;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductRepositoryTest {
@Autowired
private ProductRepository repository;
@BeforeEach
void setup() {
repository.deleteAll();
repository.saveAll(List.of(
new Product("MacBook Pro", "Apple laptop", "electronics",
74999.99, 50, List.of("apple", "laptop")),
new Product("iPhone 15", "Apple phone", "electronics",
64999.99, 100, List.of("apple", "phone")),
new Product("Clean Code", "Programming book", "books",
349.99, 300, List.of("java"))
));
}
@Test
void testSaveAndFind() {
Product saved = repository.save(new Product("Test", "Desc",
"test", 99.99, 10, List.of("test")));
assertNotNull(saved.getId());
assertTrue(repository.findById(saved.getId()).isPresent());
}
@Test
void testFindByCategory() {
assertEquals(2, repository.findByCategory("electronics").size());
}
@Test
void testPriceBetween() {
List<Product> result = repository.findByPriceBetween(50000, 70000);
assertEquals(1, result.size());
assertEquals("iPhone 15", result.get(0).getName());
}
@Test
void testPagination() {
Page<Product> page = repository.findByCategory("electronics",
PageRequest.of(0, 1));
assertEquals(2, page.getTotalElements());
assertEquals(2, page.getTotalPages());
assertEquals(1, page.getContent().size());
}
@Test
void testCount() {
assertEquals(3, repository.count());
assertEquals(2, repository.countByCategory("electronics"));
}
}11. Best Practices
✅ Yapın
| Uygulama | Neden |
|---|---|
| Derived query ile başlayın | Basit sorgular için en temiz yol |
Karmaşık sorgularda NativeQuery | Elasticsearch'ün tam gücü |
Aggregation → ElasticsearchOperations | Repository aggregation desteklemez |
@Field annotation'larını açık yazın | Otomatik mapping sürpriz yapabilir |
| Integration test yazın | Mapping + sorgu hatalarını erkenden yakalayın |
❌ Yapmayın
| Uygulama | Neden |
|---|---|
Partial update için save() kullanmayın | Tüm document'ı yeniden yazar |
Production'da createIndex = true bırakmayın | Index yönetimi CI/CD ile olmalı |
findAll() ile tüm veriyi çekmeyin | Milyon document = OutOfMemory |
@Query'de kullanıcı girdisini direkt koymayın | Injection riski |
12. Yaygın Hatalar
Hata 1: Field Type Uyumsuzluğu
// ❌ Java int ↔ Elasticsearch text → mapping hatası
@Field(type = FieldType.Text)
private int stock;
// ✅ Doğru tip eşlemesi
@Field(type = FieldType.Integer)
private int stock;Hata 2: Keyword Alanda Full-Text Search
// ❌ Keyword alanda "Containing" beklendiği gibi çalışmaz
@Field(type = FieldType.Keyword)
private String name;
// findByNameContaining("Mac") → bulamaz
// ✅ Text alan kullanın
@Field(type = FieldType.Text)
private String name;Hata 3: Lazy Refresh
// ❌ save() sonrası hemen arama
repository.save(product);
repository.findByName("test"); // Boş dönebilir!
// ✅ Refresh bekleyin
repository.save(product);
operations.indexOps(Product.class).refresh();
repository.findByName("test"); // BulurHata 4: Custom Repository Adı
// ❌ Impl suffix yok → Spring tanımaz
public class ProductCustomSearch implements ProductCustomRepository { }
// ✅ "Impl" ile bitmeli
public class ProductCustomRepositoryImpl implements ProductCustomRepository { }Özet
Spring Data Elasticsearch, ES Java Client üzerine kurulu soyutlama —
ElasticsearchRepositoryile JPA benzeri CRUD@Document ile entity, @Field ile mapping —
FieldType.Text,Keyword,Date,Integergibi tiplerDerived query ile method adından otomatik sorgu:
findByCategory,findByPriceBetween,findByNameContaining@Query annotation ile native ES JSON sorgusu — derived query'nin yetemediği yerde
NativeQuery builder ile programatik karmaşık sorgular — bool, aggregation, highlight, sort
Pagination:
Pageable+Page<T>—PageRequest.of(page, size, sort)ileCustom Repository ile
ElasticsearchOperationskullanarak tam kontrol — aggregation, highlight desteğisave()= full replace — partial update içinElasticsearchOperationskullanın
AI Asistan
Sorularını yanıtlamaya hazır