← Kursa Dön
📄 Text · 40 min

Java ile Gerçek Dünya Projesi — E-Commerce Search

Giriş — Teoriyi Pratiğe Dönüştürme Zamanı

8 bölüm boyunca Elasticsearch'ün her yönünü öğrendiniz. Ama tüm parçalar ayrı ayrı öğrenildi. Bu derste sıfırdan bir e-ticaret arama API'si kuracağız — multi-field arama, faceted filtering, autocomplete, sorting, pagination hepsi bir arada, tam çalışan bir proje.


1. Maven Bağımlılıkları

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.2</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>co.elastic.clients</groupId>
        <artifactId>elasticsearch-java</artifactId>
        <version>8.12.0</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.datatype</groupId>
        <artifactId>jackson-datatype-jsr310</artifactId>
    </dependency>
    <dependency>
        <groupId>jakarta.json</groupId>
        <artifactId>jakarta.json-api</artifactId>
        <version>2.1.3</version>
    </dependency>
    <dependency>
        <groupId>org.eclipse.parsson</groupId>
        <artifactId>parsson</artifactId>
        <version>1.1.5</version>
    </dependency>
</dependencies>

2. Elasticsearch Konfigürasyonu

package com.example.search.config;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ElasticsearchConfig {

    @Value("${elasticsearch.host:localhost}")
    private String host;

    @Value("${elasticsearch.port:9200}")
    private int port;

    @Bean
    public RestClient restClient() {
        return RestClient.builder(new HttpHost(host, port, "http"))
            .setRequestConfigCallback(c -> c.setConnectTimeout(5000).setSocketTimeout(30000))
            .build();
    }

    @Bean
    public ElasticsearchClient elasticsearchClient(RestClient restClient) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return new ElasticsearchClient(
            new RestClientTransport(restClient, new JacksonJsonpMapper(mapper))
        );
    }
}

3. Product Entity

package com.example.search.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@JsonIgnoreProperties(ignoreUnknown = true)
public class Product {
    private String id;
    private String title;
    private String description;
    private String brand;
    private String category;
    @JsonProperty("sub_category") private String subCategory;
    private double price;
    @JsonProperty("original_price") private double originalPrice;
    private double rating;
    @JsonProperty("review_count") private int reviewCount;
    @JsonProperty("in_stock") private boolean inStock;
    private List<String> tags;
    private List<String> colors;
    @JsonProperty("image_url") private String imageUrl;
    @JsonProperty("created_at") private LocalDateTime createdAt;
    private Map<String, Object> suggest;

    public Product() {}

    // Tüm field'lar için getter/setter — IDE ile otomatik oluşturun
    // IntelliJ: Alt+Insert → Getter and Setter → Select All
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String t) { this.title = t; }
    public String getBrand() { return brand; }
    public void setBrand(String b) { this.brand = b; }
    public String getCategory() { return category; }
    public void setCategory(String c) { this.category = c; }
    public double getPrice() { return price; }
    public void setPrice(double p) { this.price = p; }
    public double getRating() { return rating; }
    public void setRating(double r) { this.rating = r; }
    public boolean isInStock() { return inStock; }
    public void setInStock(boolean s) { this.inStock = s; }
    // ... diğer getter/setter'lar: description, subCategory, originalPrice,
    //     reviewCount, tags, colors, imageUrl, createdAt, suggest
}

4. Index Oluşturma Servisi

package com.example.search.service;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import com.example.search.model.Product;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.util.List;

@Service
public class ProductIndexService {

    private static final String INDEX = "products_v1";
    private final ElasticsearchClient client;

    public ProductIndexService(ElasticsearchClient client) {
        this.client = client;
    }

    @PostConstruct
    public void initIndex() throws IOException {
        if (!client.indices().exists(e -> e.index(INDEX)).value()) {
            createIndex();
        }
    }

    public void createIndex() throws IOException {
        client.indices().create(c -> c
            .index(INDEX)
            .settings(s -> s
                .numberOfShards("3").numberOfReplicas("1")
                .analysis(a -> a
                    .charFilter("diacritics_map", cf -> cf.definition(d -> d
                        .mapping(m -> m.mappings(
                            "ş => s", "Ş => S", "ç => c", "Ç => C",
                            "ğ => g", "Ğ => G", "ü => u", "Ü => U",
                            "ö => o", "Ö => O", "ı => i"
                        ))
                    ))
                    .filter("tr_lower", f -> f.definition(d -> d
                        .lowercase(lc -> lc.language("turkish"))))
                    .filter("tr_stop", f -> f.definition(d -> d
                        .stop(st -> st.stopwords("_turkish_"))))
                    .filter("tr_stem", f -> f.definition(d -> d
                        .stemmer(st -> st.language("turkish"))))
                    .filter("apostrophe", f -> f.definition(d -> d
                        .apostrophe(ap -> ap)))
                    .filter("autocomplete_ngram", f -> f.definition(d -> d
                        .edgeNgram(e -> e.minGram(2).maxGram(15))))
                    .analyzer("tr_index", an -> an.custom(cu -> cu
                        .tokenizer("standard")
                        .filter("apostrophe", "tr_lower", "tr_stop", "tr_stem")))
                    .analyzer("tr_search", an -> an.custom(cu -> cu
                        .tokenizer("standard")
                        .filter("apostrophe", "tr_lower", "tr_stop", "tr_stem")))
                    .analyzer("folded", an -> an.custom(cu -> cu
                        .tokenizer("standard").charFilter("diacritics_map")
                        .filter("lowercase", "tr_stop", "tr_stem")))
                    .analyzer("auto_index", an -> an.custom(cu -> cu
                        .tokenizer("standard")
                        .filter("tr_lower", "autocomplete_ngram")))
                    .analyzer("auto_search", an -> an.custom(cu -> cu
                        .tokenizer("standard").filter("tr_lower")))
                )
            )
            .mappings(m -> m
                .properties("title", p -> p.text(t -> t
                    .analyzer("tr_index").searchAnalyzer("tr_search")
                    .fields("folded", f -> f.text(tx -> tx.analyzer("folded")))
                    .fields("autocomplete", f -> f.text(tx -> tx
                        .analyzer("auto_index").searchAnalyzer("auto_search")))
                    .fields("keyword", f -> f.keyword(k -> k))
                ))
                .properties("description", p -> p.text(t -> t
                    .analyzer("tr_index").searchAnalyzer("tr_search")
                    .fields("folded", f -> f.text(tx -> tx.analyzer("folded")))
                ))
                .properties("brand", p -> p.text(t -> t.analyzer("tr_index")
                    .fields("keyword", f -> f.keyword(k -> k))))
                .properties("category", p -> p.keyword(k -> k))
                .properties("sub_category", p -> p.keyword(k -> k))
                .properties("price", p -> p.float_(f -> f))
                .properties("original_price", p -> p.float_(f -> f))
                .properties("rating", p -> p.float_(f -> f))
                .properties("review_count", p -> p.integer(i -> i))
                .properties("in_stock", p -> p.boolean_(b -> b))
                .properties("tags", p -> p.keyword(k -> k))
                .properties("colors", p -> p.keyword(k -> k))
                .properties("image_url", p -> p.keyword(k -> k.index(false)))
                .properties("created_at", p -> p.date(d -> d))
                .properties("suggest", p -> p.completion(comp -> comp
                    .analyzer("auto_search")))
            )
        );
    }

    public void bulkIndex(List<Product> products) throws IOException {
        var builder = new co.elastic.clients.elasticsearch.core.BulkRequest.Builder();
        for (Product p : products) {
            builder.operations(op -> op.index(idx -> idx
                .index(INDEX).id(p.getId()).document(p)));
        }
        var resp = client.bulk(builder.build());
        if (resp.errors()) {
            resp.items().stream().filter(i -> i.error() != null)
                .forEach(i -> System.err.printf("Hata [%s]: %s%n",
                    i.id(), i.error().reason()));
        }
    }
}

5. DTO Sınıfları

5.1 Search Request

package com.example.search.dto;

import java.util.List;

public class ProductSearchRequest {
    private String query;
    private String category;
    private String brand;
    private Double minPrice, maxPrice, minRating;
    private Boolean inStock;
    private List<String> colors;
    private String sortBy = "relevance"; // relevance, price_asc, price_desc, rating, newest
    private int page = 0;
    private int size = 20;
    private List<String> searchAfter;
    private boolean includeFacets = true;

    // Tüm field'lar için getter/setter — IDE ile oluşturun
    // Önemli: setSize() içinde Math.min(s, 100) ile max 100 sınırlaması
    public String getQuery() { return query; }
    public void setQuery(String q) { this.query = q; }
    public String getCategory() { return category; }
    public void setCategory(String c) { this.category = c; }
    public String getBrand() { return brand; }
    public void setBrand(String b) { this.brand = b; }
    public Double getMinPrice() { return minPrice; }
    public void setMinPrice(Double p) { this.minPrice = p; }
    public Double getMaxPrice() { return maxPrice; }
    public void setMaxPrice(Double p) { this.maxPrice = p; }
    public Double getMinRating() { return minRating; }
    public void setMinRating(Double r) { this.minRating = r; }
    public Boolean getInStock() { return inStock; }
    public void setInStock(Boolean s) { this.inStock = s; }
    public List<String> getColors() { return colors; }
    public void setColors(List<String> c) { this.colors = c; }
    public String getSortBy() { return sortBy; }
    public void setSortBy(String s) { this.sortBy = s; }
    public int getPage() { return page; }
    public void setPage(int p) { this.page = p; }
    public int getSize() { return size; }
    public void setSize(int s) { this.size = Math.min(s, 100); }
    // searchAfter, includeFacets için de getter/setter ekleyin
    public List<String> getSearchAfter() { return searchAfter; }
    public void setSearchAfter(List<String> sa) { this.searchAfter = sa; }
    public boolean isIncludeFacets() { return includeFacets; }
    public void setIncludeFacets(boolean f) { this.includeFacets = f; }
}

5.2 Search Response

package com.example.search.dto;

import com.example.search.model.Product;
import java.util.List;
import java.util.Map;

public class ProductSearchResponse {
    private long totalHits;
    private List<ProductHit> products;
    private Map<String, List<FacetBucket>> facets;
    private PriceStats priceStats;
    private List<String> searchAfter; // Sonraki sayfa için

    // Inner classes
    public static class ProductHit {
        private Product product; private double score;
        private Map<String, List<String>> highlights; private List<String> sortValues;
        public ProductHit(Product p, double s, Map<String, List<String>> h, List<String> sv) {
            this.product = p; this.score = s; this.highlights = h; this.sortValues = sv;
        }
        public Product getProduct() { return product; }
        public double getScore() { return score; }
        public Map<String, List<String>> getHighlights() { return highlights; }
        public List<String> getSortValues() { return sortValues; }
    }

    public static class FacetBucket {
        private String key; private long count;
        public FacetBucket(String k, long c) { this.key = k; this.count = c; }
        public String getKey() { return key; }
        public long getCount() { return count; }
    }

    public static class PriceStats {
        private double min, max, avg;
        public PriceStats(double min, double max, double avg) {
            this.min = min; this.max = max; this.avg = avg;
        }
        public double getMin() { return min; }
        public double getMax() { return max; }
        public double getAvg() { return avg; }
    }

    // Getter/Setter — tüm field'lar için (IDE ile oluşturun)
    public long getTotalHits() { return totalHits; }
    public void setTotalHits(long t) { this.totalHits = t; }
    public List<ProductHit> getProducts() { return products; }
    public void setProducts(List<ProductHit> p) { this.products = p; }
    public Map<String, List<FacetBucket>> getFacets() { return facets; }
    public void setFacets(Map<String, List<FacetBucket>> f) { this.facets = f; }
    public PriceStats getPriceStats() { return priceStats; }
    public void setPriceStats(PriceStats ps) { this.priceStats = ps; }
    public List<String> getSearchAfter() { return searchAfter; }
    public void setSearchAfter(List<String> sa) { this.searchAfter = sa; }
}

6. Search Service — Projenin Kalbi

package com.example.search.service;

import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.FieldValue;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.aggregations.*;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch.core.search.CompletionSuggestOption;
import com.example.search.dto.*;
import com.example.search.dto.ProductSearchResponse.*;
import com.example.search.model.Product;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class ProductSearchService {

    private static final String INDEX = "products_v1";
    private final ElasticsearchClient client;

    public ProductSearchService(ElasticsearchClient client) {
        this.client = client;
    }

    // ============= ANA ARAMA =============
    public ProductSearchResponse search(ProductSearchRequest req) throws IOException {
        SearchResponse<Product> response = client.search(s -> {
            var b = s.index(INDEX).size(req.getSize());

            b.query(buildQuery(req));
            addSorting(b, req);

            // Pagination
            if (req.getSearchAfter() != null && !req.getSearchAfter().isEmpty()) {
                b.searchAfter(req.getSearchAfter().stream()
                    .map(FieldValue::of).collect(Collectors.toList()));
            } else {
                b.from(req.getPage() * req.getSize());
            }

            // Highlighting
            b.highlight(h -> h
                .fields("title", hf -> hf.numberOfFragments(0))
                .fields("description", hf -> hf.fragmentSize(150).numberOfFragments(2))
                .preTags("<mark>").postTags("</mark>"));

            // Facets
            if (req.isIncludeFacets()) addFacets(b);

            b.source(src -> src.filter(f -> f.excludes("suggest")));
            return b;
        }, Product.class);

        return buildResponse(response, req);
    }

    // ============= QUERY BUILDER =============
    private Query buildQuery(ProductSearchRequest req) {
        var bool = new BoolQuery.Builder();

        // Text search — multi-field with boosting
        if (req.getQuery() != null && !req.getQuery().isBlank()) {
            bool.must(m -> m.multiMatch(mm -> mm
                .query(req.getQuery())
                .fields("title^3", "title.folded^1.5", "description^2",
                        "description.folded", "brand^2", "tags^1.5")
                .type(TextQueryType.BestFields)
                .fuzziness("AUTO")
                .minimumShouldMatch("75%")
            ));
        } else {
            bool.must(m -> m.matchAll(ma -> ma));
        }

        // Filters (filter context — cached, no scoring)
        if (req.getCategory() != null)
            bool.filter(f -> f.term(t -> t.field("category").value(req.getCategory())));

        if (req.getBrand() != null)
            bool.filter(f -> f.term(t -> t.field("brand.keyword").value(req.getBrand())));

        if (req.getMinPrice() != null || req.getMaxPrice() != null) {
            bool.filter(f -> f.range(r -> r.number(n -> {
                var nb = n.field("price");
                if (req.getMinPrice() != null) nb.gte(req.getMinPrice());
                if (req.getMaxPrice() != null) nb.lte(req.getMaxPrice());
                return nb;
            })));
        }

        if (req.getMinRating() != null)
            bool.filter(f -> f.range(r -> r.number(n ->
                n.field("rating").gte(req.getMinRating()))));

        if (req.getInStock() != null && req.getInStock())
            bool.filter(f -> f.term(t -> t.field("in_stock").value(true)));

        if (req.getColors() != null && !req.getColors().isEmpty())
            bool.filter(f -> f.terms(t -> t.field("colors")
                .terms(tv -> tv.value(req.getColors().stream()
                    .map(FieldValue::of).collect(Collectors.toList())))));

        return Query.of(q -> q.bool(bool.build()));
    }

    // ============= SORTING =============
    private void addSorting(
        co.elastic.clients.elasticsearch.core.SearchRequest.Builder b,
        ProductSearchRequest req
    ) {
        switch (req.getSortBy() != null ? req.getSortBy() : "relevance") {
            case "price_asc" ->
                b.sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)));
            case "price_desc" ->
                b.sort(so -> so.field(f -> f.field("price").order(SortOrder.Desc)));
            case "rating" -> {
                b.sort(so -> so.field(f -> f.field("rating").order(SortOrder.Desc)));
                b.sort(so -> so.field(f -> f.field("review_count").order(SortOrder.Desc)));
            }
            case "newest" ->
                b.sort(so -> so.field(f -> f.field("created_at").order(SortOrder.Desc)));
            default ->
                b.sort(so -> so.score(sc -> sc.order(SortOrder.Desc)));
        }
        // Tiebreaker — search_after için gerekli
        b.sort(so -> so.field(f -> f.field("_id").order(SortOrder.Asc)));
    }

    // ============= FACETS =============
    private void addFacets(
        co.elastic.clients.elasticsearch.core.SearchRequest.Builder b
    ) {
        b.aggregations("categories", a -> a.terms(t -> t.field("category").size(20)));
        b.aggregations("brands", a -> a.terms(t -> t.field("brand.keyword").size(30)));
        b.aggregations("colors", a -> a.terms(t -> t.field("colors").size(15)));
        b.aggregations("price_ranges", a -> a.range(r -> r.field("price")
            .ranges(rr -> rr.key("0-1000").to("1000"))
            .ranges(rr -> rr.key("1000-5000").from("1000").to("5000"))
            .ranges(rr -> rr.key("5000-10000").from("5000").to("10000"))
            .ranges(rr -> rr.key("10000-25000").from("10000").to("25000"))
            .ranges(rr -> rr.key("25000+").from("25000"))
        ));
        b.aggregations("rating_ranges", a -> a.range(r -> r.field("rating")
            .ranges(rr -> rr.key("4+").from("4"))
            .ranges(rr -> rr.key("3-4").from("3").to("4"))
            .ranges(rr -> rr.key("3 altı").to("3"))
        ));
        b.aggregations("price_stats", a -> a.stats(st -> st.field("price")));
    }

    // ============= RESPONSE BUILDER =============
    private ProductSearchResponse buildResponse(
        SearchResponse<Product> response, ProductSearchRequest req
    ) {
        var result = new ProductSearchResponse();
        result.setTotalHits(response.hits().total().value());

        // Products + highlights
        List<ProductHit> products = new ArrayList<>();
        for (Hit<Product> hit : response.hits().hits()) {
            Map<String, List<String>> hl = hit.highlight() != null
                ? hit.highlight() : Collections.emptyMap();
            List<String> sortVals = hit.sort().stream()
                .map(FieldValue::_toJsonString).collect(Collectors.toList());
            products.add(new ProductHit(hit.source(),
                hit.score() != null ? hit.score() : 0, hl, sortVals));
        }
        result.setProducts(products);

        // search_after for next page
        if (!products.isEmpty())
            result.setSearchAfter(products.get(products.size() - 1).getSortValues());

        // Parse facets
        if (req.isIncludeFacets() && response.aggregations() != null) {
            Map<String, List<FacetBucket>> facets = new HashMap<>();
            facets.put("categories", parseTerms(response, "categories"));
            facets.put("brands", parseTerms(response, "brands"));
            facets.put("colors", parseTerms(response, "colors"));
            facets.put("price_ranges", parseRange(response, "price_ranges"));
            facets.put("rating_ranges", parseRange(response, "rating_ranges"));
            result.setFacets(facets);

            StatsAggregate stats = response.aggregations().get("price_stats").stats();
            result.setPriceStats(new PriceStats(stats.min(), stats.max(), stats.avg()));
        }
        return result;
    }

    private List<FacetBucket> parseTerms(SearchResponse<Product> r, String name) {
        return r.aggregations().get(name).sterms().buckets().array().stream()
            .map(b -> new FacetBucket(b.key().stringValue(), b.docCount()))
            .collect(Collectors.toList());
    }

    private List<FacetBucket> parseRange(SearchResponse<Product> r, String name) {
        return r.aggregations().get(name).range().buckets().array().stream()
            .map(b -> new FacetBucket(b.key(), b.docCount()))
            .collect(Collectors.toList());
    }

    // ============= AUTOCOMPLETE =============
    public List<String> autocomplete(String prefix) throws IOException {
        var response = client.search(s -> s
            .index(INDEX).size(0)
            .suggest(sg -> sg.suggesters("product_suggest", su -> su
                .prefix(prefix)
                .completion(c -> c.field("suggest").size(8)
                    .fuzzy(f -> f.fuzziness("1")))
            )),
            Product.class
        );

        var suggestions = response.suggest().get("product_suggest");
        if (suggestions == null || suggestions.isEmpty()) return Collections.emptyList();

        return suggestions.get(0).completion().options().stream()
            .map(CompletionSuggestOption::text)
            .collect(Collectors.toList());
    }
}

7. REST Controller

package com.example.search.controller;

import com.example.search.dto.ProductSearchRequest;
import com.example.search.dto.ProductSearchResponse;
import com.example.search.service.ProductSearchService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/products")
public class ProductSearchController {

    private final ProductSearchService searchService;

    public ProductSearchController(ProductSearchService searchService) {
        this.searchService = searchService;
    }

    @GetMapping("/search")
    public ResponseEntity<ProductSearchResponse> search(
        @RequestParam(required = false) String query,
        @RequestParam(required = false) String category,
        @RequestParam(required = false) String brand,
        @RequestParam(required = false) Double minPrice,
        @RequestParam(required = false) Double maxPrice,
        @RequestParam(required = false) Double minRating,
        @RequestParam(required = false) Boolean inStock,
        @RequestParam(required = false) List<String> colors,
        @RequestParam(defaultValue = "relevance") String sortBy,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "true") boolean includeFacets
    ) throws IOException {
        var request = new ProductSearchRequest();
        request.setQuery(query);
        request.setCategory(category);
        request.setBrand(brand);
        request.setMinPrice(minPrice);
        request.setMaxPrice(maxPrice);
        request.setMinRating(minRating);
        request.setInStock(inStock);
        request.setColors(colors);
        request.setSortBy(sortBy);
        request.setPage(page);
        request.setSize(size);
        request.setIncludeFacets(includeFacets);
        return ResponseEntity.ok(searchService.search(request));
    }

    @GetMapping("/autocomplete")
    public ResponseEntity<List<String>> autocomplete(
        @RequestParam String prefix
    ) throws IOException {
        return ResponseEntity.ok(searchService.autocomplete(prefix));
    }

}

8. Kullanım Örnekleri

# Basit arama
curl "localhost:8080/api/products/search?query=samsung+telefon"

# Filtrelemeli + sıralamalı
curl "localhost:8080/api/products/search?query=telefon&category=Telefon&minPrice=10000&sortBy=price_asc"

# Autocomplete
curl "localhost:8080/api/products/autocomplete?prefix=sam"

# Facet'siz sayfalama (daha hızlı)
curl "localhost:8080/api/products/search?query=telefon&page=2&size=10&includeFacets=false"

9. Production Notları

KonuTavsiye
Index aliasingproducts alias → zero-downtime reindex
Error handling@ExceptionHandler, retry geçici hatalar için
CachingFacet sonuçlarını Redis'te 1-5dk cache
Validationsize max 100, query min 2 karakter
ScalingApp 2-3 replika, ES 3+ data node, 10-50GB/shard

Özet

  • Proje yapısı: Config → Model → DTO → Service → Controller katmanları — Spring Boot best practice

  • Multi-field search: title^3, title.folded^1.5, description^2 ile ağırlıklı arama + fuzziness

  • Faceted filtering: categories, brands, colors, price_ranges, rating_ranges — tek sorguda

  • Autocomplete: Completion suggester + fuzzy toleransı

  • Sorting: relevance, price_asc/desc, rating, newest + _id tiebreaker

  • Pagination: from/size (basit) veya search_after (ölçeklenebilir)

  • Highlighting: <mark> etiketleri ile eşleşen terimleri vurgulama

  • Türkçe destek: Turkish lowercase, apostrophe, stemmer, diacritics folding — mapping'de

  • Production-ready: Tüm kodu kopyalayıp projenize uyarlayabilirsiniz