Skip to main content

Auto-complete System Design: Building a Scalable Search Suggestion Engine

Comprehensive low-level design for a scalable auto-complete system with trie-based indexing, caching strategies, and real-time suggestion generation.

An auto-complete system efficiently suggests search terms as users type, requiring intelligent indexing, caching mechanisms, and real-time processing to deliver low-latency responses at scale.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the scale of data and expected traffic?

    • How many total terms are we dealing with (e.g., 10M, 100M)?
    • What is the expected QPS (queries per second) for auto-complete?
  2. What consistency requirements are there?

    • Should new terms be immediately available in auto-complete results?
    • Is eventual consistency acceptable?
  3. What are the key entities and their relationships?

    • Is auto-complete tied to specific user contexts or global?
    • Do we need to support multiple languages or categories?
  4. What is the read/write ratio?

    • Is auto-complete mostly read-heavy or does it require frequent term updates?
  5. What are the latency sensitivity and SLA requirements?

    • What is the target p50/p95/p99 latency (e.g., < 50ms, < 200ms)?
    • Is there a requirement for sub-100ms response time?
  6. What authentication and authorization model is in place?

    • Is auto-complete accessible to all users or restricted by role?
  7. Are there external integrations or data sources?

    • Is the auto-complete backed by a search engine (e.g., Elasticsearch) or built from scratch?

Functional requirements

  • Real-time auto-complete suggestions
    • Given a prefix input, return matching terms in real time.
  • Support for multiple prefixes
    • Handle concurrent prefix queries efficiently.
  • Configurable suggestion count
    • Allow clients to specify how many suggestions to return (e.g., top 10).
  • Support for user-specific suggestions
    • Optionally provide personalized results based on user behavior or preferences.
  • Support for autocomplete with categories
    • Suggestions can be grouped by type (e.g., products, users, categories).
  • Suggestion scoring and ranking
    • Suggestions can be ranked by relevance or popularity.

Non-functional requirements

  • Latency targets
    • p50: < 50ms
    • p95: < 200ms
    • p99: < 500ms
  • Throughput
    • Handle up to 100,000 QPS with 99.9% success rate.
  • Availability
    • 99.9% uptime (50ms mean time to recovery).
  • Scalability
    • System should scale horizontally across multiple nodes.
  • Memory efficiency
    • Memory usage should be optimized for large term sets.

Constraints and assumptions

  • Assumption: The system will not be used for real-time indexing of new terms in the same session.
  • Assumption: The system will not support real-time updates to existing terms (e.g., term popularity changes).
  • Constraint: The system will not support multi-language auto-complete in this design (can be extended).
  • Constraint: The system assumes that the term set is relatively static or updated in batches.

Out of scope

  • Real-time indexing of new terms
    • Adding new terms to the system in real time.
  • Advanced natural language processing (NLP)
    • Features like synonym expansion, typo correction, or semantic matching.
  • User session management
    • Storing and retrieving user-specific auto-complete preferences or history.
  • Full-text search integration
    • Integration with Elasticsearch, Solr, or other full-text search engines.
  • Auto-complete for complex queries
    • Suggestions based on structured query parsing or filtering logic.
graph TD
    A[Auto-complete System] --> B[Prefix Matching]
    A --> C[Suggestion Ranking]
    A --> D[User Context Support]
    A --> E[Batch Term Updates]

2. API Design

API Style Selection

gRPC with Protocol Buffers is selected for the auto-complete system due to:

  • Strong typing and schema validation at compile time
  • Efficient binary serialization with lower network overhead than REST
  • Native support for streaming responses (important for real-time suggestions)
  • Built-in support for bidirectional communication if needed
  • Better performance characteristics for high-throughput scenarios

Endpoint Definitions

MethodPathDescriptionAuth
search/v1/autocomplete/searchSearch for auto-complete suggestions based on query termJWT
suggest/v1/autocomplete/suggestGet top N suggestions for a query termJWT
index/v1/autocomplete/indexIndex new terms for auto-completionAPI Key
bulkIndex/v1/autocomplete/bulkIndexBulk index terms for auto-completionAPI Key

Request and Response Schemas

Search Request (gRPC):

message SearchRequest {
  string query = 1;
  int32 limit = 2;
  repeated string categories = 3;
  string user_id = 4;
}

message SearchResponse {
  repeated Suggestion suggestions = 1;
  int32 total_count = 2;
}

message Suggestion {
  string term = 1;
  int32 score = 2;
  string category = 3;
  int64 timestamp = 4;
}

Suggest Request (gRPC):

message SuggestRequest {
  string query = 1;
  int32 limit = 2;
  repeated string categories = 3;
}

message SuggestResponse {
  repeated string suggestions = 1;
}

HTTP Status Codes and Error Response

Standardized error response format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Query parameter is required",
    "details": {
      "field": "query"
    }
  }
}

Status codes:

  • 200 OK - Successful search/suggest
  • 400 Bad Request - Invalid query parameters
  • 401 Unauthorized - Missing or invalid auth token
  • 403 Forbidden - Insufficient permissions for indexing
  • 429 Too Many Requests - Rate limiting exceeded
  • 500 Internal Server Error - Server-side failures

API Versioning Strategy

Versioned via path prefix: /v1/autocomplete/*
Future versions will use /v2/autocomplete/*

Pagination Design

Cursor-based pagination is used because:

  • More consistent and predictable than offset-based pagination
  • Better performance for large datasets
  • Handles data changes during pagination gracefully

Authentication and Authorization

Authentication: JWT Bearer tokens for user authentication
Authorization:

  • Search/Suggest endpoints: Public read access (JWT required)
  • Index/BulkIndex endpoints: Admin API key authentication

Rate Limiting

Per-user rate limiting: 1000 requests/minute
Per-API-key rate limiting: 5000 requests/minute
Implementation: Token bucket algorithm with sliding window

Idempotency Design

Idempotency keys:

  • For index and bulkIndex endpoints
  • Client-provided UUID in X-Idempotency-Key header
  • Server stores last result for same key within 5 minutes
  • Returns 200 OK or 201 Created with X-Idempotency-Result header indicating reuse

Sample Request/Response Examples

Search Example:

POST /v1/autocomplete/search
Content-Type: application/json
Authorization: Bearer <jwt-token>
{
  "query": "java",
  "limit": 10,
  "categories": ["programming", "books"]
}

{
  "suggestions": [
    {
      "term": "java",
      "score": 95,
      "category": "programming",
      "timestamp": 1684329600
    },
    {
      "term": "java programming",
      "score": 87,
      "category": "programming",
      "timestamp": 1684329600
    }
  ],
  "total_count": 25
}

Suggest Example:

POST /v1/autocomplete/suggest
Content-Type: application/json
{
  "query": "java",
  "limit": 5,
  "categories": ["programming"]
}

{
  "suggestions": [
    "java",
    "java programming",
    "java tutorial",
    "java book",
    "java development"
  ]
}

Index Example:

POST /v1/autocomplete/index
Content-Type: application/json
Authorization: Bearer <api-key>
{
  "term": "java programming",
  "category": "programming",
  "score": 95
}

{
  "success": true,
  "indexed_at": "2023-05-15T10:30:00Z"
}

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    AUTOCOMPLETE_TERM {
        string id PK
        string term
        integer frequency
        timestamp created_at
        timestamp updated_at
        boolean is_active
    }

    AUTOCOMPLETE_SUGGESTION {
        string id PK
        string term_id FK
        string suggestion_text
        integer score
        timestamp created_at
    }

    AUTOCOMPLETE_SEARCH_HISTORY {
        string id PK
        string user_id
        string search_term
        timestamp searched_at
    }

    AUTOCOMPLETE_TERM ||--o{ AUTOCOMPLETE_SUGGESTION : "has"
    AUTOCOMPLETE_TERM ||--o{ AUTOCOMPLETE_SEARCH_HISTORY : "used_in"

Table Schema Definitions

-- Main table for storing auto-complete terms
CREATE TABLE autocomplete_terms (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    term VARCHAR(255) NOT NULL UNIQUE,
    frequency INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    
    -- Indexes
    INDEX idx_autocomplete_term (term),
    INDEX idx_autocomplete_frequency (frequency DESC),
    INDEX idx_autocomplete_active (is_active, frequency DESC)
);

-- Table for storing suggestions associated with terms
CREATE TABLE autocomplete_suggestions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    term_id UUID NOT NULL REFERENCES autocomplete_terms(id) ON DELETE CASCADE,
    suggestion_text VARCHAR(255) NOT NULL,
    score INTEGER NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    
    -- Indexes
    INDEX idx_suggestion_term (term_id),
    INDEX idx_suggestion_score (score DESC),
    INDEX idx_suggestion_text (suggestion_text)
);

-- Table for tracking user search history (optional for boosting)
CREATE TABLE autocomplete_search_history (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id VARCHAR(255),
    search_term VARCHAR(255) NOT NULL,
    searched_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    
    -- Indexes
    INDEX idx_search_user (user_id),
    INDEX idx_search_term (search_term),
    INDEX idx_search_timestamp (searched_at)
);

Primary Key Strategy

UUIDs are chosen for primary keys because:

  • Scalability: UUIDs allow for distributed system design without coordination
  • Security: Prevents enumeration attacks on sequential IDs
  • Global Uniqueness: Ensures no collisions across microservices or databases

Index Design Justification

  1. idx_autocomplete_term (term) - B-tree index on term field for fast lookups by search term
  2. idx_autocomplete_frequency (frequency DESC) - B-tree index on frequency for sorting by popularity
  3. idx_autocomplete_active (is_active, frequency DESC) - Composite index to efficiently filter active terms and sort by popularity
  4. idx_suggestion_term (term_id) - Foreign key index for fast joins and lookups by term
  5. idx_suggestion_score (score DESC) - B-tree index on score for fast sorting of suggestions
  6. idx_search_user (user_id) - For user-based search history queries
  7. idx_search_term (search_term) - For fast lookup of search terms in history

Data Partitioning / Sharding Strategy

Time-based sharding by creation date:

  • Partition by month/year to support time-based analytics and reduce hotspots
  • Example: autocomplete_terms_2024_01, autocomplete_terms_2024_02
  • Allows for easy data lifecycle management and hot/cold storage strategies

Soft Delete vs. Hard Delete Decision

Soft delete is chosen because:

  • Auditability: Historical search data and suggestions need to be preserved for analytics
  • Recovery capability: Accidentally deleted terms can be recovered without data loss
  • Analytics consistency: Search history and suggestion rankings need to maintain integrity over time

Audit Trail Design

All data modifications are tracked via:

  • created_at and updated_at timestamps
  • is_active flag for soft-deletion tracking
  • Application-level logging of all CRUD operations with user context when available

Data Archival Strategy and Retention Policy

  1. Active data: Terms and suggestions with is_active = TRUE are kept in primary storage
  2. Archival policy: Terms that haven't been updated for 12 months are moved to cold storage
  3. Retention: Search history is retained for 90 days, then soft-deleted but kept in audit logs
  4. Archival format: Compressed JSON or Parquet files stored in S3-compatible object storage
// Example of how to handle soft-deletion in Java
public class AutocompleteTerm {
    private String id;
    private String term;
    private Integer frequency;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    private Boolean isActive;
    
    // When soft-deleting, set isActive to false and update timestamp
    public void softDelete() {
        this.isActive = false;
        this.updatedAt = LocalDateTime.now();
    }
}

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class AutoCompleteService {
        +search(String query, int limit)
        +suggest(String prefix)
        +index(String term)
    }
    
    class SearchIndex {
        -Map~String, Set~String~~ terms
        -Map~String, Set~String~~ prefixIndex
        +addTerm(String term)
        +searchTerms(String query, int limit)
        +getSuggestions(String prefix)
    }
    
    class TrieNode {
        -Map~Character, TrieNode~ children
        -boolean isEnd
        -Set~String~ terms
        +insert(String term)
        +search(String prefix)
    }
    
    class Trie {
        -TrieNode root
        +insert(String term)
        +search(String prefix)
        +getSuggestions(String prefix, int limit)
    }
    
    class EventPublisher {
        +publish(Event event)
    }
    
    class AutoCompleteEvent {
        -String eventType
        -String term
        -Instant timestamp
    }
    
    AutoCompleteService --> SearchIndex : uses
    AutoCompleteService --> EventPublisher : publishes
    SearchIndex --> Trie : delegates
    Trie --> TrieNode : contains
    
    class AutoCompleteConfig {
        -int maxSuggestions
        -int cacheTTLSeconds
        -String indexingStrategy
    }

State Machine Diagram

stateDiagram-v2
    [*] --> Idle
    Idle --> Indexing : index_term
    Indexing --> Indexed : completed
    Indexed --> Idle : refresh_cache
    Indexed --> Error : indexing_failure
    Error --> Idle : retry_success

Service Class Structure and Responsibilities

The AutoCompleteService is the primary entry point for all autocomplete functionality. It encapsulates business logic, orchestrates operations between components, and handles event publishing.

Key responsibilities:

  • Accepts search queries and returns suggestions
  • Manages indexing of new terms
  • Coordinates with caching layer
  • Publishes domain events for audit and async processing

Interface / Abstract Class Design

public interface SearchIndex {
    void addTerm(String term);
    Set<String> searchTerms(String query, int limit);
    Set<String> getSuggestions(String prefix);
}

public interface EventPublisher {
    void publish(AutoCompleteEvent event);
}

Business Logic Encapsulation Patterns

We implement the Strategy Pattern for different indexing strategies:

public interface IndexingStrategy {
    void indexTerm(String term, SearchIndex index);
}

public class TrieBasedIndexingStrategy implements IndexingStrategy {
    private final Trie trie = new Trie();
    
    @Override
    public void indexTerm(String term, SearchIndex index) {
        trie.insert(term);
    }
    
    public Set<String> getSuggestions(String prefix, int limit) {
        return trie.getSuggestions(prefix, limit);
    }
}

public class HybridIndexingStrategy implements IndexingStrategy {
    private final Map<String, Set<String>> prefixMap = new ConcurrentHashMap<>();
    private final Trie trie = new Trie();
    
    @Override
    public void indexTerm(String term, SearchIndex index) {
        trie.insert(term);
        updatePrefixMap(term);
    }
    
    private void updatePrefixMap(String term) {
        for (int i = 1; i <= term.length(); i++) {
            String prefix = term.substring(0, i);
            prefixMap.computeIfAbsent(prefix, k -> new HashSet<>()).add(term);
        }
    }
    
    public Set<String> getSuggestions(String prefix, int limit) {
        // Priority: Trie first, then fallback to prefix map
        Set<String> suggestions = trie.getSuggestions(prefix, limit);
        if (suggestions.size() < limit) {
            Set<String> prefixSuggestions = prefixMap.getOrDefault(prefix, Collections.emptySet());
            suggestions.addAll(
                prefixSuggestions.stream()
                    .limit(limit - suggestions.size())
                    .collect(Collectors.toSet())
            );
        }
        return suggestions;
    }
}

Critical-Path Method Implementation

public class AutoCompleteService {
    private final SearchIndex searchIndex;
    private final EventPublisher eventPublisher;
    private final IndexingStrategy indexingStrategy;
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    public Set<String> search(String query, int limit) {
        // Transaction boundary: single operation but includes cache and index sync
        try (TransactionContext tx = transactionManager.begin()) {
            // 1. Validate input
            if (query == null || query.trim().isEmpty()) {
                throw new IllegalArgumentException("Query cannot be null or empty");
            }
            
            // 2. Check cache first (cache-aside pattern)
            String cacheKey = "autocomplete:" + query;
            Set<String> cachedResults = cache.get(cacheKey);
            if (cachedResults != null) {
                return cachedResults;
            }
            
            // 3. Search in index
            Set<String> results = searchIndex.searchTerms(query, limit);
            
            // 4. Cache results with TTL
            cache.put(cacheKey, results, Duration.ofSeconds(300));
            
            // 5. Publish search event for analytics
            AutoCompleteEvent searchEvent = new AutoCompleteEvent(
                "SEARCH", query, Instant.now());
            eventPublisher.publish(searchEvent);
            
            // 6. Commit transaction
            tx.commit();
            
            return results;
        } catch (Exception e) {
            // Transaction rollback handled by TransactionContext
            throw new AutoCompleteException("Search failed", e);
        }
    }
    
    public void index(String term) {
        try (TransactionContext tx = transactionManager.begin()) {
            // 1. Validate term
            if (term == null || term.trim().isEmpty()) {
                throw new IllegalArgumentException("Term cannot be null or empty");
            }
            
            // 2. Index the term using strategy
            indexingStrategy.indexTerm(term, searchIndex);
            
            // 3. Update cache with fresh data
            String cacheKey = "autocomplete:" + term.substring(0, Math.min(term.length(), 10));
            cache.invalidate(cacheKey);
            
            // 4. Publish indexing event for audit
            AutoCompleteEvent indexEvent = new AutoCompleteEvent(
                "INDEX", term, Instant.now());
            eventPublisher.publish(indexEvent);
            
            // 5. Commit transaction
            tx.commit();
        } catch (Exception e) {
            throw new AutoCompleteException("Indexing failed", e);
        }
    }
}

Transaction Boundary Design

A single transaction encompasses:

  • Database operations (indexing, querying)
  • Cache operations (read, write, invalidate)
  • Event publishing (with transactional guarantee)

We use a TransactionContext that ensures atomicity across all these operations.

Service Interface Contracts

public interface AutoCompleteService {
    Set<String> search(String query, int limit);
    Set<String> suggest(String prefix, int limit);
    void index(String term);
    void batchIndex(List<String> terms);
}

Saga / Orchestration Pattern

For batch operations, we implement a simple saga pattern:

public class BatchIndexSaga {
    private final List<String> terms;
    private final Set<String> failedTerms = new HashSet<>();
    
    public void execute() {
        List<CompletableFuture<Void>> futures = terms.stream()
            .map(term -> CompletableFuture.runAsync(() -> {
                try {
                    autoCompleteService.index(term);
                } catch (Exception e) {
                    failedTerms.add(term);
                    logger.error("Failed to index term: {}", term, e);
                }
            }))
            .collect(Collectors.toList());
            
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .join();
            
        if (!failedTerms.isEmpty()) {
            // Handle partial failures - retry or log
            throw new BatchIndexException(failedTerms);
        }
    }
}

Domain Object / Value Object Design

public class AutoCompleteEvent {
    private final String eventType;
    private final String term;
    private final Instant timestamp;
    
    public AutoCompleteEvent(String eventType, String term, Instant timestamp) {
        this.eventType = eventType;
        this.term = term;
        this.timestamp = timestamp;
    }
    
    // Getters and equals/hashCode
}

public class SearchIndexResult {
    private final Set<String> suggestions;
    private final int totalHits;
    
    public SearchIndexResult(Set<String> suggestions, int totalHits) {
        this.suggestions = new HashSet<>(suggestions);
        this.totalHits = totalHits;
    }
    
    // Getters
}

5. Concurrency & Thread Safety

Concurrency Model

The auto-complete system will use a thread pool-based concurrency model with Java's ExecutorService for handling concurrent requests. Each request will be processed in a separate thread from the pool, ensuring that high-volume autocomplete queries don't block each other. For read-heavy workloads, we'll use a combination of thread-safe collections and immutable data structures to minimize contention.

Race Condition Prevention Strategy

Race conditions in the auto-complete system are primarily concerned with:

  1. Index updates during search: When new data is added to the trie while concurrent searches are happening
  2. Cache invalidation: Concurrent cache updates during data refreshes

To prevent these, we'll use:

  • Read-copy-update (RCU) pattern for the trie structure - readers can safely access a snapshot while writers update a copy
  • Atomic reference updates for cache entries to ensure consistent reads during updates
  • Thread-local buffers for search results to avoid cross-thread contamination

Optimistic vs. Pessimistic Locking Selection

For the auto-complete system, we'll use optimistic locking because:

  • The system is primarily read-heavy with low write frequency
  • Writes are batched and infrequent (e.g., periodic index rebuilds)
  • The cost of handling conflicts is much lower than the overhead of maintaining locks

We'll implement optimistic locking using version numbers on trie nodes and cache entries, allowing concurrent reads while detecting and handling conflicts during writes.

Deadlock Prevention Strategy

Deadlock prevention in the auto-complete system focuses on:

  1. Lock ordering: All operations will acquire locks in a consistent order (e.g., always acquire trie lock before cache lock)
  2. Timeout-based acquisition: Implement timeout mechanisms on lock acquisitions to prevent indefinite blocking
  3. Lock hierarchy: Maintain a clear hierarchy of locks to avoid circular dependencies

Idempotency Implementation for Concurrent Duplicate Requests

For concurrent duplicate requests, we'll implement idempotency using:

  1. Request deduplication: Use a distributed cache (Redis) to store recent request fingerprints
  2. Idempotency keys: Clients can provide idempotency keys which are used to deduplicate requests
  3. Request buffering: For identical search queries, we'll buffer and return the same result to avoid redundant computation
public class IdempotentSearchService {
    private final RedisTemplate<String, String> redisTemplate;
    private final SearchService searchService;
    
    public CompletableFuture<List<AutoCompleteResult>> searchWithIdempotency(
            String query, 
            String idempotencyKey,
            int ttlSeconds) {
        
        String cacheKey = "autocomplete:" + idempotencyKey;
        String cachedResult = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachedResult != null) {
            return CompletableFuture.completedFuture(
                deserializeResults(cachedResult));
        }
        
        // Use a distributed lock to prevent concurrent processing
        String lockKey = "lock:" + idempotencyKey;
        boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "locked", Duration.ofSeconds(5));
        
        if (!acquired) {
            // Wait for the ongoing request to complete
            return waitForCompletion(cacheKey, ttlSeconds);
        }
        
        try {
            CompletableFuture<List<AutoCompleteResult>> future = 
                searchService.searchAsync(query);
            
            return future.thenCompose(results -> {
                String serialized = serializeResults(results);
                redisTemplate.opsForValue()
                    .set(cacheKey, serialized, Duration.ofSeconds(ttlSeconds));
                redisTemplate.delete(lockKey);
                return CompletableFuture.completedFuture(results);
            });
        } catch (Exception e) {
            redisTemplate.delete(lockKey);
            throw new RuntimeException("Search failed", e);
        }
    }
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant U as User
    participant FE as Frontend
    participant API as AutoCompleteAPI
    participant Cache as RedisCache
    participant DB as PostgreSQLDB
    participant Index as SearchIndex

    U->>FE: Type query "java"
    FE->>API: GET /autocomplete?q=java&limit=10
    API->>Cache: Check if results exist
    alt Results found in cache
        Cache-->>API: Return cached results
        API-->>FE: Return cached results
        FE-->>U: Display suggestions
    else No cache hit
        API->>DB: Query for matching terms (prefix search)
        DB-->>API: Return matching terms
        API->>Index: Index new terms (if needed)
        Index-->>API: Confirm indexing
        API->>Cache: Store results with TTL
        Cache-->>API: Confirm storage
        API-->>FE: Return results
        FE-->>U: Display suggestions
    end
sequenceDiagram
    participant U as User
    participant FE as Frontend
    participant API as AutoCompleteAPI
    participant Cache as RedisCache
    participant DB as PostgreSQLDB
    participant Index as SearchIndex

    U->>FE: Type query "java"
    FE->>API: GET /autocomplete?q=java&limit=10
    API->>Cache: Check if results exist
    Cache-->>API: No cache hit
    API->>DB: Query for matching terms (prefix search)
    DB-->>API: Database timeout error
    API->>Cache: Store empty results with short TTL
    Cache-->>API: Confirm storage
    API-->>FE: Return empty results
    FE-->>U: Display no suggestions
sequenceDiagram
    participant U as User
    participant FE as Frontend
    participant API as AutoCompleteAPI
    participant Cache as RedisCache
    participant DB as PostgreSQLDB
    participant Index as SearchIndex

    U->>FE: Type query "java"
    FE->>API: GET /autocomplete?q=java&limit=10
    API->>Cache: Check if results exist
    Cache-->>API: No cache hit
    API->>DB: Query for matching terms (prefix search)
    DB-->>API: Query timeout - retry once
    DB->>DB: Retry query with optimized index scan
    DB-->>API: Return results
    API->>Index: Index new terms (if needed)
    Index-->>API: Confirm indexing
    API->>Cache: Store results with TTL
    Cache-->>API: Confirm storage
    API-->>FE: Return results
    FE-->>U: Display suggestions
sequenceDiagram
    participant Service as AutoCompleteService
    participant Cache as RedisCache
    participant DB as PostgreSQLDB
    participant Index as SearchIndex
    participant EventPublisher as EventPublisher

    Service->>Cache: Check cache for query "java"
    alt Cache miss
        Service->>DB: Query terms with prefix "java"
        DB-->>Service: Found 50 matching terms
        Service->>Index: Index new terms (if needed)
        Index-->>Service: Indexed successfully
        Service->>Cache: Store results with TTL=300s
        Cache-->>Service: Stored successfully
        Service->>EventPublisher: Publish "autocomplete_results_generated" event
        EventPublisher-->>Service: Event published successfully
    else Cache hit
        Cache-->>Service: Return cached results
    end

7. Caching Design

Cache Strategy Selection

For auto-complete functionality, I'll use cache-aside pattern with LRU eviction policy. This approach provides optimal control over cache lifecycle and is well-suited for read-heavy auto-complete workloads where data freshness is important but not critical.

Cache Key Design

Cache keys will follow a structured namespace pattern:

autocomplete:{query}:{maxResults}:{timestamp}

For example: autocomplete:java:10:1698765432

TTL Strategy

Data TypeTTLReason
Auto-complete suggestions5 minutesBalances freshness with performance
Popular query cache1 hourReduces load on primary data source
User-specific suggestions30 secondsHigher personalization needs

Cache Eviction Policy

LRU (Least Recently Used) with probabilistic early expiry for hot keys:

  • Primary eviction based on LRU
  • 10% probabilistic early expiry for top N most accessed keys to prevent cache stampede
  • Cache warming strategy for frequently accessed queries

Cache Stampede Prevention

Implement probabilistic early expiry:

  • When cache miss occurs, set TTL to 50% of normal TTL for 10% of top queries
  • Use distributed locks to prevent multiple concurrent cache rebuilds
  • Implement cache warming for popular queries during low-traffic periods

Distributed Cache Architecture

Using Redis Cluster with:

  • 3 master nodes, 3 replica nodes for high availability
  • Hash slots distribution for consistent partitioning
  • Redis Sentinel for automatic failover
  • Memory-based storage with persistence to disk

Cache Invalidation Strategy

Event-driven invalidation:

  • When new data is indexed, invalidate related cache entries
  • Use Redis Pub/Sub to broadcast invalidation events across services
  • Implement soft invalidation with TTL-based cleanup for stale entries
// Cache key generation utility
public class AutoCompleteCacheKey {
    private static final String CACHE_NAMESPACE = "autocomplete";
    
    public static String generateKey(String query, int maxResults) {
        return String.format("%s:%s:%d:%d", 
            CACHE_NAMESPACE, 
            query.toLowerCase(), 
            maxResults, 
            System.currentTimeMillis() / 1000);
    }
    
    public static String generatePopularKey(String query) {
        return "popular:" + query.toLowerCase();
    }
}

Cache Implementation Pattern

@Component
public class AutoCompleteCache {
    private final RedisTemplate<String, List<Suggestion>> redisTemplate;
    private final CacheManager cacheManager;
    
    public List<Suggestion> getCachedSuggestions(String query, int maxResults) {
        String cacheKey = AutoCompleteCacheKey.generateKey(query, maxResults);
        
        // Try to get from cache
        List<Suggestion> cached = redisTemplate.opsForValue().get(cacheKey);
        
        if (cached != null) {
            return cached;
        }
        
        // Cache miss - use optimistic locking to prevent stampede
        String lockKey = "lock:" + cacheKey;
        if (redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 
            Duration.ofSeconds(1), TimeUnit.SECONDS)) {
            
            try {
                // Double-check pattern
                cached = redisTemplate.opsForValue().get(cacheKey);
                if (cached != null) {
                    return cached;
                }
                
                // Rebuild cache
                cached = buildSuggestionsFromIndex(query, maxResults);
                redisTemplate.opsForValue().set(cacheKey, cached, 
                    Duration.ofMinutes(5));
                
                return cached;
            } finally {
                // Release lock
                redisTemplate.delete(lockKey);
            }
        }
        
        // Wait briefly and retry
        Thread.sleep(50);
        return redisTemplate.opsForValue().get(cacheKey);
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and persistence concerns. Each entity has its own repository interface with standardized CRUD operations.

public interface AutoCompleteRepository {
    CompletableFuture<AutoCompleteResult> findByTerm(String term);
    CompletableFuture<List<AutoCompleteResult>> searchByPrefix(String prefix, int limit);
    CompletableFuture<Void> save(AutoCompleteResult result);
    CompletableFuture<Void> delete(String term);
    CompletableFuture<List<AutoCompleteResult>> findAll(int limit);
}

public class AutoCompleteRepositoryImpl implements AutoCompleteRepository {
    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;
    
    public AutoCompleteRepositoryImpl(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
        this.jdbcTemplate = jdbcTemplate;
        this.objectMapper = objectMapper;
    }
    
    @Override
    public CompletableFuture<AutoCompleteResult> findByTerm(String term) {
        return CompletableFuture.supplyAsync(() -> {
            String sql = "SELECT * FROM autocomplete_results WHERE term = ?";
            return jdbcTemplate.queryForObject(sql, new Object[]{term}, 
                (rs, rowNum) -> mapResultSetToAutoCompleteResult(rs));
        });
    }
    
    @Override
    public CompletableFuture<List<AutoCompleteResult>> searchByPrefix(String prefix, int limit) {
        return CompletableFuture.supplyAsync(() -> {
            String sql = "SELECT * FROM autocomplete_results WHERE term LIKE ? ORDER BY frequency DESC LIMIT ?";
            return jdbcTemplate.query(sql, new Object[]{prefix + "%", limit}, 
                (rs, rowNum) -> mapResultSetToAutoCompleteResult(rs));
        });
    }
    
    @Override
    public CompletableFuture<Void> save(AutoCompleteResult result) {
        return CompletableFuture.runAsync(() -> {
            String sql = "INSERT INTO autocomplete_results (term, frequency, data) VALUES (?, ?, ?) " +
                        "ON DUPLICATE KEY UPDATE frequency = frequency + 1, data = ?";
            jdbcTemplate.update(sql, result.getTerm(), result.getFrequency(), 
                objectMapper.writeValueAsString(result.getData()), 
                objectMapper.writeValueAsString(result.getData()));
        });
    }
    
    @Override
    public CompletableFuture<Void> delete(String term) {
        return CompletableFuture.runAsync(() -> {
            String sql = "DELETE FROM autocomplete_results WHERE term = ?";
            jdbcTemplate.update(sql, term);
        });
    }
    
    @Override
    public CompletableFuture<List<AutoCompleteResult>> findAll(int limit) {
        return CompletableFuture.supplyAsync(() -> {
            String sql = "SELECT * FROM autocomplete_results ORDER BY frequency DESC LIMIT ?";
            return jdbcTemplate.query(sql, new Object[]{limit}, 
                (rs, rowNum) -> mapResultSetToAutoCompleteResult(rs));
        });
    }
    
    private AutoCompleteResult mapResultSetToAutoCompleteResult(ResultSet rs) throws SQLException {
        return new AutoCompleteResult(
            rs.getString("term"),
            rs.getInt("frequency"),
            objectMapper.readValue(rs.getString("data"), Map.class)
        );
    }
}

ORM vs. Raw Query Decisions

For this auto-complete system, raw SQL queries are preferred over ORM for better performance and control. The system requires high-performance prefix matching and frequency-based sorting, which can be optimized more effectively with raw queries than with ORM-generated SQL.

Read vs. Write Separation

The system implements read-write separation using a CQRS-like approach:

public class AutoCompleteReadRepository {
    private final JdbcTemplate readJdbcTemplate;
    
    public CompletableFuture<List<AutoCompleteResult>> searchByPrefix(String prefix, int limit) {
        return CompletableFuture.supplyAsync(() -> {
            String sql = "SELECT term, frequency FROM autocomplete_results WHERE term LIKE ? ORDER BY frequency DESC LIMIT ?";
            return readJdbcTemplate.query(sql, new Object[]{prefix + "%", limit}, 
                (rs, rowNum) -> new AutoCompleteResult(rs.getString("term"), rs.getInt("frequency")));
        });
    }
}

public class AutoCompleteWriteRepository {
    private final JdbcTemplate writeJdbcTemplate;
    
    public CompletableFuture<Void> upsert(AutoCompleteResult result) {
        return CompletableFuture.runAsync(() -> {
            String sql = "INSERT INTO autocomplete_results (term, frequency, data) VALUES (?, ?, ?) " +
                        "ON DUPLICATE KEY UPDATE frequency = frequency + 1, data = ?";
            writeJdbcTemplate.update(sql, result.getTerm(), result.getFrequency(), 
                objectMapper.writeValueAsString(result.getData()), 
                objectMapper.writeValueAsString(result.getData()));
        });
    }
}

Connection Pool Configuration

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      validation-timeout: 5000
      leak-detection-threshold: 60000

N+1 Query Prevention Strategy

The system prevents N+1 queries by:

  1. Batch loading: All autocomplete results are fetched in single queries
  2. Eager loading: Related data is pre-fetched when needed
  3. Query optimization: Using indexed columns for prefix matching
public class AutoCompleteService {
    private final AutoCompleteReadRepository readRepo;
    private final AutoCompleteWriteRepository writeRepo;
    
    public CompletableFuture<List<AutoCompleteResult>> searchWithSuggestions(String prefix, int limit) {
        return readRepo.searchByPrefix(prefix, limit)
            .thenCompose(results -> {
                // All results fetched in single query - no N+1
                return CompletableFuture.completedFuture(results);
            });
    }
}

Batch Operation Design

For bulk operations like data imports or batch updates:

public class AutoCompleteBatchRepository {
    private final JdbcTemplate jdbcTemplate;
    
    public CompletableFuture<Void> batchUpsert(List<AutoCompleteResult> results) {
        return CompletableFuture.runAsync(() -> {
            String sql = "INSERT INTO autocomplete_results (term, frequency, data) VALUES (?, ?, ?) " +
                        "ON DUPLICATE KEY UPDATE frequency = frequency + 1, data = ?";
            
            jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i) throws SQLException {
                    AutoCompleteResult result = results.get(i);
                    ps.setString(1, result.getTerm());
                    ps.setInt(2, result.getFrequency());
                    ps.setString(3, objectMapper.writeValueAsString(result.getData()));
                    ps.setString(4, objectMapper.writeValueAsString(result.getData()));
                }
                
                @Override
                public int getBatchSize() {
                    return results.size();
                }
            });
        });
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The auto-complete system primarily operates synchronously for real-time search experiences. However, asynchronous processing is essential for:

  1. Index population and maintenance - Background jobs to build and update search indexes from data sources
  2. Data ingestion pipelines - Processing new content or updates to existing content that should be immediately searchable
  3. Analytics and reporting - Aggregating usage patterns for search term popularity and performance optimization
  4. Content deduplication and cleanup - Periodic jobs to maintain data quality

The synchronous flows (user queries) remain the primary concern, while asynchronous flows support system health and scalability.

Message schema design with sample payload

{
  "version": "1.0",
  "event_type": "content.created",
  "timestamp": "2023-10-27T14:30:00Z",
  "source": "content-service",
  "payload": {
    "content_id": "uuid-12345",
    "title": "How to Build Scalable Systems",
    "tags": ["architecture", "scalability"],
    "category": "technical",
    "created_at": "2023-10-27T14:30:00Z"
  }
}

Dead letter queue strategy and poison message handling

Messages that fail after maximum retries are moved to a dead letter queue (DLQ) for manual inspection and reprocessing. Poison messages (those consistently failing due to malformed data or system issues) are logged with detailed error context and escalated to monitoring systems.

At-least-once vs. exactly-once delivery

At-least-once delivery is selected for auto-complete system because:

  • Duplicate search index updates are harmless (idempotent operations)
  • Content ingestion events can be safely processed multiple times
  • The system is designed to handle duplicates gracefully

Retry policy for consumers

Retry strategy:

  • Max attempts: 3
  • Backoff formula: exponential with jitter (1s, 2s, 4s ± random 0-1s)
  • Non-retryable errors: malformed payloads, permission denied, content not found

Outbox pattern for reliable event publishing

Outbox pattern is implemented to ensure message delivery reliability:

public class EventPublisher {
    private final EventRepository eventRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public void publishEvent(Event event) {
        // 1. Persist event to outbox table
        EventOutbox outboxEvent = new EventOutbox(event);
        eventRepository.save(outboxEvent);

        // 2. Publish to Kafka
        kafkaTemplate.send("events", event.getId(), event.toJson());
        
        // 3. Mark as published
        outboxEvent.setPublished(true);
        eventRepository.save(outboxEvent);
    }
}

Sequence diagram for async flows

sequenceDiagram
    participant ContentService
    participant EventPublisher
    participant Kafka
    participant IndexerService
    participant SearchIndex

    ContentService->>EventPublisher: Publish content.created event
    EventPublisher->>Kafka: Send to events topic
    Kafka->>IndexerService: Deliver event to indexer consumer
    IndexerService->>SearchIndex: Update search index with new content
    SearchIndex-->>IndexerService: Acknowledge update

10. Error Handling Design

Error Classification Hierarchy

The error handling strategy follows a hierarchical classification system to enable proper error propagation and response formatting:

Error
├── ValidationException (400)
│   ├── InvalidInputException
│   └── ValidationError
├── BusinessLogicException (400)
│   ├── ResourceNotFoundException
│   ├── ConflictException
│   └── InsufficientPermissionsException
├── InfrastructureException (500)
│   ├── DatabaseException
│   ├── ExternalServiceException
│   └── TimeoutException
└── SystemException (500)
    ├── InternalServerException
    └── UnknownException

Error Propagation Strategy

  • Validation errors are caught and converted to ValidationException at the API boundary, returning HTTP 400
  • Business logic errors are handled within service layer and converted to appropriate HTTP 400 responses
  • Infrastructure errors (DB timeouts, external service failures) are wrapped in InfrastructureException and may trigger retry/recovery logic
  • System errors (unhandled exceptions) are logged and converted to InternalServerException for client-facing error responses

Retry Strategy with Exponential Backoff and Jitter

For infrastructure-dependent operations, a retry strategy is implemented:

public class RetryableOperation {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    private static final double JITTER_RANGE = 0.5;

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (attempts >= MAX_RETRIES - 1) {
                    throw e;
                }
                
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts) 
                    * (1 + JITTER_RANGE * (Math.random() - 0.5)));
                
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new InternalServerException("Retry interrupted", ie);
                }
                
                attempts++;
            }
        }
        throw new InternalServerException("Max retries exceeded");
    }
}

Circuit Breaker Design

The system implements a circuit breaker for external dependencies using the Hystrix pattern:

public class CircuitBreaker {
    private final String name;
    private volatile CircuitState state = CircuitState.CLOSED;
    private int failureCount = 0;
    private final int failureThreshold;
    private final long timeoutMs;
    private final long resetTimeoutMs;
    private volatile Instant lastFailureTime = null;

    public enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }

    public boolean canExecute() {
        switch (state) {
            case CLOSED:
                return true;
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime.toEpochMilli() > resetTimeoutMs) {
                    state = CircuitState.HALF_OPEN;
                    return true;
                }
                return false;
            case HALF_OPEN:
                return true;
        }
        return false;
    }

    public void recordSuccess() {
        if (state == CircuitState.HALF_OPEN) {
            state = CircuitState.CLOSED;
            failureCount = 0;
        }
    }

    public void recordFailure() {
        lastFailureTime = Instant.now();
        failureCount++;
        
        if (failureCount >= failureThreshold) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

For external service calls, fallbacks are implemented:

public class AutoCompleteService {
    private final ExternalSearchService searchService;
    
    public List<String> autoCompleteWithFallback(String query) {
        try {
            return searchService.searchSuggestions(query);
        } catch (ExternalServiceException e) {
            // Log the failure for monitoring
            logger.warn("External search service failed, using fallback", e);
            
            // Return cached suggestions or empty list as fallback
            return getCachedSuggestions(query);
        }
    }
}

Error Logging Format and Alerting Thresholds

Structured logging is implemented with the following schema:

{
  "timestamp": "2023-10-27T14:30:00Z",
  "trace_id": "abc123def456",
  "span_id": "xyz789uvw012",
  "user_id": "user-12345",
  "error_code": "DB_CONNECTION_TIMEOUT",
  "error_message": "Database connection timeout after 30 seconds",
  "error_type": "InfrastructureException",
  "service_name": "AutoCompleteService",
  "request_path": "/api/v1/suggestions",
  "http_status_code": 500,
  "stack_trace": "..."
}

Alerting thresholds:

  • Critical (5xx errors): > 10 errors per minute for 5 minutes → On-call alert
  • Warning (4xx errors): > 100 errors per minute for 5 minutes → Alert team
  • Info (retries, fallbacks): > 1000 occurrences per hour → Log review

Error Response Format

Standardized JSON error response:

{
  "error": {
    "code": "INVALID_INPUT",
    "message": "Query parameter must be between 1 and 100 characters",
    "details": {
      "field": "query",
      "value": "a"
    }
  },
  "timestamp": "2023-10-27T14:30:00Z",
  "request_id": "req-abc123"
}

11. Security Design

Input validation and sanitization strategy

All API inputs undergo strict validation using a combination of:

  • Regex patterns for structured fields (e.g., email format, phone number)
  • Length limits to prevent buffer overflow attacks
  • Type checking to ensure proper data types
  • Whitelist validation for categorical inputs (e.g., valid search categories)

Java implementation using Apache Commons Validator:

public class InputValidator {
    private static final Pattern SEARCH_TERM_PATTERN = 
        Pattern.compile("^[a-zA-Z0-9\\s\\-_.]{1,100}$");
    
    public static boolean isValidSearchTerm(String term) {
        return term != null && 
               term.length() <= 100 && 
               SEARCH_TERM_PATTERN.matcher(term).matches();
    }
}

SQL injection prevention

All database queries use parameterized prepared statements exclusively. No dynamic query building or string concatenation is permitted.

// Secure approach - parameterized query
String sql = "SELECT * FROM autocomplete_suggestions WHERE term LIKE ? ORDER BY popularity DESC LIMIT ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, searchTerm + "%");
stmt.setInt(2, maxResults);
ResultSet rs = stmt.executeQuery();

Authentication middleware design

All endpoints require authentication via JWT tokens. Middleware validates token signature, expiration, and scope.

@Component
public class JwtAuthenticationFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String token = extractToken(httpRequest);
        
        if (token != null && jwtService.validateToken(token)) {
            Authentication auth = jwtService.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        
        chain.doFilter(request, response);
    }
}

Authorization enforcement

RBAC-based authorization ensures users can only access autocomplete suggestions for their permitted domains or categories.

@PreAuthorize("hasRole('USER') && hasPermission(#userId, 'autocomplete_access')")
public List<AutocompleteSuggestion> getSuggestions(String userId, String searchTerm) {
    // Implementation
}

Sensitive data encryption at rest and in transit

  • At rest: AES-256 encryption using AWS KMS for sensitive suggestion data
  • In transit: TLS 1.3 with HSTS header enforcement

PII handling and masking strategy

User search history is stored with PII masked:

  • Full terms are stored in encrypted format
  • Only the first 3 characters of sensitive terms are retained for suggestions
  • All personal identifiers (user IDs) are hashed before storage

Secrets management

Configuration secrets (API keys, database credentials) are managed via AWS Secrets Manager with automatic rotation every 90 days.

Audit logging design

All security-relevant events are logged with structured JSON format:

{
  "timestamp": "2023-10-15T14:30:00Z",
  "event_type": "AUTOCOMPLETE_SEARCH",
  "user_id": "hashed_user_id",
  "ip_address": "192.168.1.100",
  "search_term": "hashed_term",
  "session_id": "session_1234567890",
  "action": "SUCCESS"
}

Audit logs are retained for 90 days and stored in encrypted format in CloudWatch with centralized logging.

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage with focus on business logic, edge cases, and error paths
  • Repository Layer: 90% coverage for all data access methods including read/write operations and error handling
  • Domain Objects: 100% coverage for value objects, DTOs, and validation logic
  • API Layer: 75% coverage for request/response validation and error handling paths
  • Critical Path Operations: 100% coverage with full integration of mocks and stubs

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL 13+ for end-to-end database testing
  • External Service Mocking: WireMock for external API dependencies (e.g., search index, analytics)
  • Event System Testing: Embedded Kafka or Redis Streams for async message flow validation
  • Caching Layer: Mocked Redis instances to verify cache behavior and invalidation logic
  • Test Data Setup: Factory pattern with fixtures for consistent test data generation

Contract Testing

  • API Contract Testing: Pact framework for consumer-driven contract testing between frontend and backend services
  • Event Schema Contracts: JSON Schema validation using Avro schema registry for event-driven components
  • External API Contracts: Mocked external service contracts with versioned stubs to prevent breaking changes

Performance Testing Tool and Key Scenarios

  • Load Testing: k6 for high-concurrency testing with realistic user behavior simulation
  • Key Scenarios:
    • Concurrent autocomplete requests (1000+ concurrent users)
    • High-volume search term processing (10,000+ terms/sec)
    • Cache warm-up and hit ratio validation under load
    • Response time SLA compliance (p95 < 200ms, p99 < 500ms)
  • Stress Testing: JMeter for identifying system bottlenecks under peak load conditions

Test Data Management Strategy

  • Fixtures: YAML-based fixtures for initial test data population with predefined search terms and categories
  • Factories: Factory classes to generate realistic test data (e.g., random search terms, weighted popularity scores)
  • Seeding: Automated database seeding scripts with deterministic test data for reproducible test runs
  • Data Isolation: Per-test database transactions with rollback to ensure clean state between tests
  • Test Data Versioning: Git-managed test data sets with version control for regression testing
// Example of unit test for autocomplete service with mocks
@Test
public void testAutocompleteWithCacheHit() {
    // Given
    String query = "java";
    List<SearchSuggestion> cachedSuggestions = Arrays.asList(
        new SearchSuggestion("java", 100),
        new SearchSuggestion("javascript", 80)
    );
    
    when(cacheService.get(query)).thenReturn(cachedSuggestions);
    
    // When
    List<SearchSuggestion> result = autocompleteService.suggest(query);
    
    // Then
    assertEquals(2, result.size());
    verify(cacheService).get(query);
    verify(searchRepository, never()).search(anyString());
}

13. Observability Design

Structured Logging Schema

All logs follow a structured JSON format with consistent fields to support centralized logging and analysis:

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "level": "INFO",
  "service": "autocomplete-service",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-1234-1234-1234-123456789012",
  "user_id": "user-12345",
  "entity_id": "query-98765",
  "operation": "autocomplete.search",
  "request_id": "req-1234567890",
  "duration_ms": 45,
  "error_code": "INVALID_INPUT",
  "message": "Search query validation failed",
  "payload": {
    "query": "foo",
    "max_suggestions": 10
  },
  "error_details": {
    "stack_trace": "...",
    "cause": "Input validation failed"
  }
}

PII masking is applied to sensitive fields:

  • user_id is masked in logs when not needed for debugging
  • payload.query is truncated or redacted for queries exceeding 100 characters

Distributed Tracing Instrumentation

OpenTelemetry is used for distributed tracing with custom spans:

Key Spans:

  1. autocomplete.search - Root span for autocomplete search requests
    • Attributes: query, max_suggestions, user_id, result_count
  2. autocomplete.index.lookup - For index lookups during search
    • Attributes: index_type, query_length, match_count
  3. autocomplete.cache.get - For cache hit/miss operations
    • Attributes: cache_key, is_hit, ttl_ms

Span Context Propagation:

  • All HTTP requests include trace context headers (traceparent, tracestate)
  • Internal service calls maintain span context using OpenTelemetry SDK

Metrics Design

Service-Level RED Metrics:

Metric NameTypeDescriptionLabels
autocomplete.requestsCounterTotal autocomplete requestsoperation, status, error_code
autocomplete.durationHistogramRequest processing duration (ms)operation, status
autocomplete.cache.hitsCounterCache hit counttype
autocomplete.cache.missesCounterCache miss counttype
autocomplete.index.lookupsHistogramIndex lookup duration (ms)index_type, status
autocomplete.errorsCounterError count by typeerror_type, operation

Label Design:

  • operation: search, index_update, suggestion_fetch
  • status: success, error, timeout
  • error_code: INVALID_INPUT, INDEX_NOT_READY, CACHE_ERROR

Health Check Endpoint Design

GET /health
GET /ready
GET /live
GET /startup

/health - Service Health Check

  • Checks database connectivity and cache availability
  • Returns 200 if all dependencies are healthy
  • Returns 503 if any dependency is unhealthy

/ready - Readiness Check

  • Verifies that service can accept traffic
  • Checks if index is loaded and searchable
  • Returns 200 when ready, 503 when not ready

/live - Liveness Check

  • Basic service status check
  • Returns 200 if service process is running

/startup - Startup Validation

  • Validates that all required configurations are loaded
  • Checks for missing dependencies or misconfigurations

Alerting Thresholds

Critical Alerts:

  • Request latency > 500ms (p99): Alert when > 1% of requests exceed threshold
  • Cache miss rate > 30%: Alert when cache efficiency drops below threshold
  • Error rate > 5%: Alert when error ratio exceeds 5% of total requests

Warning Alerts:

  • Request latency > 200ms (p99): Alert when > 10% of requests exceed threshold
  • Index rebuild time > 30s: Alert when index update exceeds expected duration

On-Call Escalation:

  • Critical alerts → On-call engineer within 5 minutes
  • Warning alerts → Team lead within 30 minutes

Alerting Rules:

# Example Prometheus alert rules
- alert: AutocompleteLatencyHigh
  expr: histogram_quantile(0.99, sum by (le) (rate(autocomplete_duration_bucket[5m]))) > 500
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "High latency in autocomplete service"
    description: "99th percentile latency exceeded 500ms for 2 minutes"

- alert: AutocompleteCacheMissHigh
  expr: rate(autocomplete.cache.misses[5m]) / (rate(autocomplete.cache.hits[5m]) + rate(autocomplete.cache.misses[5m])) > 0.3
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "High cache miss rate"
    description: "Cache miss rate exceeds 30% for 5 minutes"

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Query vs. suggestion prioritization: Should suggestions be ranked by popularity, recency, or user behavior? This requires alignment on whether to use ML models or simple ranking heuristics.
  • Fuzzy matching vs. exact matching: Should the system support typo tolerance and partial matches, or only exact prefix matching? This impacts both implementation complexity and user experience.
  • Real-time vs. batch indexing: Should auto-complete suggestions be updated in real-time as new data arrives, or periodically via batch jobs? Real-time requires more complex architecture but better UX.
  • Cross-domain vs. single-domain suggestions: Should the system provide suggestions across multiple categories (e.g., products, users, categories) or be scoped to a single domain?

Known trade-offs and justifications

Trade-offOption 1Option 2Justification
Indexing strategyIn-memory TriesDatabase-backed TriesIn-memory provides faster lookups but increases memory footprint and requires warm-up time. Database-backed is more scalable but introduces latency.
Caching strategyCache-aside with TTLWrite-through cachingCache-aside provides better control over cache lifecycle and avoids stale data in edge cases. Write-through simplifies consistency but increases write latency.
Data model flexibilityFixed schema with pre-defined fieldsDynamic schema with flexible fieldsFixed schema provides better performance and type safety but limits extensibility. Dynamic schema enables rapid feature iteration but increases complexity in validation and querying.
Suggestion freshnessBatch updates every hourReal-time updates on data changesHourly batch updates reduce system load and complexity but provide stale suggestions. Real-time updates ensure freshness but increase infrastructure cost and complexity.

Risks and mitigation strategies

  • High memory usage: Tries with large vocabularies can consume significant memory. Mitigation: Implement memory-aware sharding and use compressed data structures like succinct tries.
  • Latency spikes during hot queries: Popular search terms can cause cache misses and slow response times. Mitigation: Implement probabilistic early expiry and use distributed caching with consistent hashing.
  • Data inconsistency: Inconsistent suggestions between different data sources or indexing strategies. Mitigation: Implement strict validation and use outbox pattern for event-driven consistency.
  • Scalability bottlenecks: Single point of failure in trie construction or query processing. Mitigation: Use distributed architecture with sharding and horizontal scaling capabilities.
  • Security vulnerabilities: Sensitive data exposure in autocomplete suggestions. Mitigation: Implement proper access controls, PII masking, and input sanitization.

Technical debt acknowledged

  • Index rebuild complexity: Periodic reindexing of large datasets can cause temporary performance degradation and requires careful scheduling.
  • Query expansion complexity: Supporting complex query patterns (e.g., multi-word queries, phrase matching) increases system complexity and maintenance overhead.
  • ML model integration debt: If ML-based ranking is implemented, maintaining and updating models becomes ongoing technical debt with potential accuracy degradation over time.
  • Cross-domain suggestion complexity: Supporting suggestions across multiple domains increases coupling between services and makes the system harder to maintain.
  • Cache invalidation complexity: Complex cache invalidation logic for multiple data sources and types increases risk of stale suggestions and requires careful monitoring.

Was this article helpful?

Topics in this article

More from Backend LLD

  • DSA Problems

    Design Search Suggestions System

    Implementation of a search suggestions system with autocomplete functionality using trie data structure and priority queue for efficient result retrieval.

  • Frontend LLD

    Search with Suggestions Low-Level Design

    Comprehensive LLD for search functionality with real-time suggestions, autocomplete, and performance optimization patterns

  • Frontend HLD

    Search and Autocomplete Frontend System Design

    Comprehensive frontend high-level design for scalable search and autocomplete functionality with real-time suggestions and performance optimization

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.