Service Layer: Business Logic & Orchestration

Domain Logic & Transaction Boundaries

Services encapsulate business rules, coordinate DAOs, manage caching and transactions. They are the heart of the application logic, independent of HTTP transport.

📚 What is this & why does it exist?

Internal service layer for backend-only operations: indexing pipelines, embedding generation, retrieval, and chunk management. These services are not exposed to end-user APIs — they are called by the task executor and other internal handlers.

💡 Real-world analogy

Like the back-of-house operations in a restaurant — prep cooks, dishwashers, inventory managers. Customers never see them, but without them the visible kitchen would grind to a halt within an hour. These services are the invisible machinery powering the user-facing API.

Overview

Services translate high-level operations (like "search for documents") into concrete database calls and caching strategies. They enforce invariants, manage distributed transactions, and are easily unit-testable because they depend only on DAOs and caches, not HTTP.

Golden rule: Services call DAOs; handlers call services; nothing calls handlers except Gin.

Dependency Injection Pattern

Every service receives its dependencies in a constructor:

type ChatService struct {
    dao         *DAO
    cache       Cache
    llmClient   LLMClient
    logger      Logger
}

func NewChatService(dao *DAO, cache Cache, llm LLMClient, log Logger) *ChatService {
    return &ChatService{dao: dao, cache: cache, llmClient: llm, logger: log}
}

func (s *ChatService) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) {
    // uses s.dao, s.cache, s.llmClient
}

This enables easy mocking in tests:

mockDAO := &MockDAO{}  // implement DAO interface
mockCache := NewMemoryCache()
svc := NewChatService(mockDAO, mockCache, mockLLM, logger)

// Test runs without database or network calls
result, err := svc.Chat(ctx, &ChatRequest{...})

Core Services

ServiceResponsibilitiesKey Methods
ChatServiceConversation management, context, token budgetingChat, StreamChat, GetHistory, DeleteConversation
SearchServiceQuery expansion, ranking, deduplicationSearch, SemanticSearch, RankedSearch, GetStats
KBServiceKnowledge base lifecycle, document indexingCreateKB, DeleteKB, AddDocument, GetStats
DocumentServiceDocument processing pipeline, chunkingUpload, Reprocess, Delete, GetChunks
LLMServiceLLM provider abstraction, rate limitingChat, Embed, Tokenize, CheckQuota
EmbeddingServiceVector storage, similarity searchStore, Search, Upsert, Delete

Transaction Management

Services coordinate multi-step operations as transactions:

func (s *KBService) AddDocument(ctx context.Context, kbID, docPath string) error {
    tx := s.dao.db.BeginTx(ctx, nil)
    
    // Step 1: Create document record
    doc, err := s.dao.CreateDocument(tx, kbID, docPath)
    if err != nil {
        tx.Rollback()
        return err
    }
    
    // Step 2: Process document (extract text)
    chunks, err := s.processDocument(doc)
    if err != nil {
        tx.Rollback()
        return err
    }
    
    // Step 3: Create chunks and embeddings
    for _, chunk := range chunks {
        err := s.dao.CreateChunk(tx, chunk)
        if err != nil {
            tx.Rollback()
            return err
        }
    }
    
    // Step 4: Update KB index
    err = s.dao.UpdateKBStats(tx, kbID)
    if err != nil {
        tx.Rollback()
        return err
    }
    
    tx.Commit()
    return nil
}

Caching Strategy

Embedding Cache

Services maintain an LRU cache of embedding vectors to avoid repeated API calls:

func (s *SearchService) Search(ctx context.Context, query string) ([]Result, error) {
    // Try cache first
    embedding := s.cache.Get(query)
    if embedding == nil {
        // Cache miss: call LLM
        var err error
        embedding, err = s.llmService.Embed(ctx, query)
        if err != nil {
            return nil, err
        }
        s.cache.Set(query, embedding)
    }
    
    // Use embedding to search vectors in database
    results, err := s.dao.SearchVectors(ctx, embedding, topK=10)
    return results, err
}

KB Metadata Cache

Cache knowledge base metadata (document count, last updated) with TTL:

func (s *SearchService) GetKBStats(ctx context.Context, kbID string) (*KBStats, error) {
    key := fmt.Sprintf("kb_stats:%s", kbID)
    stats := s.cache.Get(key)
    if stats != nil {
        return stats, nil
    }
    
    stats, err := s.dao.GetKBStats(ctx, kbID)
    if err != nil {
        return nil, err
    }
    
    s.cache.SetWithTTL(key, stats, 5*time.Minute)
    return stats, nil
}

Error Handling & Retries

Services handle transient errors gracefully:

func (s *EmbeddingService) GenerateEmbedding(ctx context.Context, text string) ([]float32, error) {
    var result []float32
    var lastErr error
    
    for attempt := 0; attempt < 3; attempt++ {
        result, err := s.llmClient.Embed(ctx, text)
        if err == nil {
            return result, nil
        }
        
        lastErr = err
        if !isTransient(err) {
            return nil, err  // Don't retry permanent errors
        }
        
        backoff := time.Duration(1<

Quota & Rate Limiting

Services enforce user quotas:

func (s *SearchService) Search(ctx context.Context, userID string, query string) ([]Result, error) {
    // Check quota
    quota, err := s.dao.GetUserQuota(ctx, userID)
    if err != nil {
        return nil, err
    }
    
    if quota.SearchCount >= quota.SearchLimit {
        return nil, fmt.Errorf("quota exceeded: %d/%d", quota.SearchCount, quota.SearchLimit)
    }
    
    // Execute search
    results, err := s.doSearch(ctx, query)
    if err != nil {
        return nil, err
    }
    
    // Update quota
    s.dao.IncrementSearchCount(ctx, userID)
    
    return results, nil
}

Submodules

Key Concepts

Dependency Injection

Services receive all dependencies as constructor parameters, not as global singletons. This enables testing and makes dependencies explicit.

Interface-Based Design

Services depend on interfaces (DAO, Cache, Logger), not concrete types. This allows mock implementations in tests:

type DAO interface {
    GetKnowledgeBase(ctx context.Context, id string) (*KB, error)
    CreateDocument(ctx context.Context, doc *Document) error
    SearchVectors(ctx context.Context, embedding []float32, k int) ([]Result, error)
}

Context Propagation

Every method receives context.Context, enabling cancellation and timeout:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := service.Search(ctx, query)  // times out after 5s

Common Patterns

Option Functions (Builder Pattern)

Services can accept optional configurations:

type SearchOptions struct {
    TopK      int
    Filter    string
    SortBy    string
    Timeout   time.Duration
}

func (s *SearchService) SearchWithOptions(ctx context.Context, query string, opts *SearchOptions) ([]Result, error) {
    if opts == nil {
        opts = &SearchOptions{TopK: 10}
    }
    // use opts.TopK, opts.Filter, etc.
}

Advanced Patterns

Circuit Breaker Pattern

If an external API (LLM, vector DB) fails repeatedly, stop calling it temporarily to avoid cascading failures.

type CircuitBreaker struct {
    failures int
    lastErr time.Time
    state string  // "closed", "open", "half-open"
}

func (cb *CircuitBreaker) Call(fn func() error) error {
    if cb.state == "open" && time.Since(cb.lastErr) < 30*time.Second {
        return fmt.Errorf("circuit open")
    }
    
    err := fn()
    if err != nil {
        cb.failures++
        cb.lastErr = time.Now()
        if cb.failures > 5 {
            cb.state = "open"
        }
        return err
    }
    
    cb.failures = 0
    cb.state = "closed"
    return nil
}

Bulkhead Pattern

Isolate critical resources. For example, use a separate thread pool for LLM calls so they don't starve database queries.

type Services struct {
    llmPool   *WorkerPool  // max 10 concurrent LLM calls
    dbPool    *WorkerPool  // max 50 concurrent DB calls
}

func (s *Services) Embed(ctx context.Context, text string) ([]float32, error) {
    result := make(chan []float32, 1)
    err := s.llmPool.Submit(func() {
        emb, _ := s.llmClient.Embed(ctx, text)
        result <- emb
    })
    
    select {
    case emb := <-result:
        return emb, nil
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

Observability Patterns

Every service method should emit metrics and logs for troubleshooting.

func (s *SearchService) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    start := time.Now()
    defer func() {
        duration := time.Since(start)
        s.metrics.SearchLatency.Observe(duration.Seconds())
        s.logger.Info("search completed", "kb_id", req.KnowledgeBaseID, "ms", duration.Milliseconds())
    }()
    
    // ... search logic ...
}

Further Reading