Skip to content

Latest commit

 

History

History
750 lines (586 loc) · 17.1 KB

File metadata and controls

750 lines (586 loc) · 17.1 KB

Functional Go Patterns

Purpose: Advanced functional programming patterns in Go
Audience: AI coding agents
Focus: Dependency injection, higher-order functions, composition


Core Concept

Go supports first-class functions - functions can be:

  • Assigned to variables
  • Passed as arguments
  • Returned from functions
  • Stored in data structures

This enables powerful patterns for dependency injection and runtime behavior modification.


Pattern 1: Function Types for Dependency Injection

Basic Pattern

Instead of interfaces, use function types:

// Traditional interface approach
type EmbeddingGenerator interface {
    Generate(text string) ([]float32, error)
}

// Functional approach - simpler!
type EmbeddingFunc func(text string) ([]float32, error)

Real-World Example

// pkg/search/search.go

// EmbeddingFunc generates vector embeddings for text
type EmbeddingFunc func(text string) ([]float32, error)

// SearchEngine uses injected embedding function
type SearchEngine struct {
    embedder EmbeddingFunc  // ← Function as dependency
    index    *HNSWIndex
}

// NewSearchEngine creates engine with custom embedder
func NewSearchEngine(embedder EmbeddingFunc) *SearchEngine {
    return &SearchEngine{
        embedder: embedder,
        index:    NewHNSWIndex(),
    }
}

// Search uses the injected embedder
func (s *SearchEngine) Search(query string, limit int) ([]Result, error) {
    // Generate embedding using injected function
    embedding, err := s.embedder(query)
    if err != nil {
        return nil, err
    }
    
    // Search index
    return s.index.Search(embedding, limit)
}

Usage Examples

// Production: Use GPU-accelerated embeddings
gpuEmbedder := func(text string) ([]float32, error) {
    return gpu.GenerateEmbedding(text)
}
engine := NewSearchEngine(gpuEmbedder)

// Testing: Use mock embeddings
mockEmbedder := func(text string) ([]float32, error) {
    return []float32{0.1, 0.2, 0.3}, nil
}
testEngine := NewSearchEngine(mockEmbedder)

// Development: Use cached embeddings
cachedEmbedder := func(text string) ([]float32, error) {
    if cached, ok := cache.Get(text); ok {
        return cached.([]float32), nil
    }
    embedding, err := gpu.GenerateEmbedding(text)
    if err == nil {
        cache.Set(text, embedding)
    }
    return embedding, err
}
devEngine := NewSearchEngine(cachedEmbedder)

// Offline: Use pre-computed embeddings
offlineEmbedder := func(text string) ([]float32, error) {
    return loadPrecomputed(text)
}
offlineEngine := NewSearchEngine(offlineEmbedder)

Benefits

Simple - No interface boilerplate
Flexible - Easy to swap implementations
Testable - Trivial to inject mocks
Composable - Can wrap functions


Pattern 2: Higher-Order Functions

Function Decorators

Wrap functions to add behavior:

// Logging decorator
func WithLogging(fn EmbeddingFunc, logger *log.Logger) EmbeddingFunc {
    return func(text string) ([]float32, error) {
        logger.Printf("Generating embedding for: %s", text)
        start := time.Now()
        
        result, err := fn(text)
        
        duration := time.Since(start)
        if err != nil {
            logger.Printf("Error after %v: %v", duration, err)
        } else {
            logger.Printf("Success in %v, dims: %d", duration, len(result))
        }
        
        return result, err
    }
}

// Caching decorator
func WithCache(fn EmbeddingFunc, cache Cache) EmbeddingFunc {
    return func(text string) ([]float32, error) {
        // Check cache first
        if cached, ok := cache.Get(text); ok {
            return cached.([]float32), nil
        }
        
        // Generate and cache
        result, err := fn(text)
        if err == nil {
            cache.Set(text, result)
        }
        
        return result, err
    }
}

// Retry decorator
func WithRetry(fn EmbeddingFunc, maxRetries int) EmbeddingFunc {
    return func(text string) ([]float32, error) {
        var lastErr error
        
        for i := 0; i < maxRetries; i++ {
            result, err := fn(text)
            if err == nil {
                return result, nil
            }
            
            lastErr = err
            time.Sleep(time.Duration(i+1) * time.Second)
        }
        
        return nil, fmt.Errorf("failed after %d retries: %w", 
            maxRetries, lastErr)
    }
}

// Timeout decorator
func WithTimeout(fn EmbeddingFunc, timeout time.Duration) EmbeddingFunc {
    return func(text string) ([]float32, error) {
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()
        
        resultChan := make(chan []float32)
        errChan := make(chan error)
        
        go func() {
            result, err := fn(text)
            if err != nil {
                errChan <- err
            } else {
                resultChan <- result
            }
        }()
        
        select {
        case result := <-resultChan:
            return result, nil
        case err := <-errChan:
            return nil, err
        case <-ctx.Done():
            return nil, fmt.Errorf("timeout after %v", timeout)
        }
    }
}

Composing Decorators

Stack multiple behaviors:

// Start with base embedder
baseEmbedder := gpu.GenerateEmbedding

// Add caching
cachedEmbedder := WithCache(baseEmbedder, cache)

// Add logging
loggedEmbedder := WithLogging(cachedEmbedder, logger)

// Add retry
resilientEmbedder := WithRetry(loggedEmbedder, 3)

// Add timeout
finalEmbedder := WithTimeout(resilientEmbedder, 30*time.Second)

// Use composed function
engine := NewSearchEngine(finalEmbedder)

// Execution flow:
// 1. Check timeout
// 2. Try up to 3 times
// 3. Log each attempt
// 4. Check cache
// 5. Call GPU embedder

Pattern 3: Function Factories

Parameterized Function Creation

// Factory creates configured embedding functions
func NewOllamaEmbedder(baseURL string, model string) EmbeddingFunc {
    client := &http.Client{Timeout: 30 * time.Second}
    
    return func(text string) ([]float32, error) {
        req := map[string]interface{}{
            "model":  model,
            "prompt": text,
        }
        
        body, _ := json.Marshal(req)
        resp, err := client.Post(
            baseURL+"/api/embeddings",
            "application/json",
            bytes.NewBuffer(body),
        )
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()
        
        var result struct {
            Embedding []float32 `json:"embedding"`
        }
        
        if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
            return nil, err
        }
        
        return result.Embedding, nil
    }
}

// Usage
embedder := NewOllamaEmbedder("http://localhost:11434", "mxbai-embed-large")
engine := NewSearchEngine(embedder)

Configuration-Based Factories

// EmbedderConfig defines embedding configuration
type EmbedderConfig struct {
    Provider   string // "ollama", "openai", "local"
    Model      string
    BaseURL    string
    APIKey     string
    Dimensions int
}

// NewEmbedderFromConfig creates embedder based on config
func NewEmbedderFromConfig(cfg EmbedderConfig) (EmbeddingFunc, error) {
    switch cfg.Provider {
    case "ollama":
        return NewOllamaEmbedder(cfg.BaseURL, cfg.Model), nil
        
    case "openai":
        return NewOpenAIEmbedder(cfg.APIKey, cfg.Model), nil
        
    case "local":
        return NewLocalGGUFEmbedder(cfg.Model, cfg.Dimensions)
        
    default:
        return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
    }
}

// Usage
config := EmbedderConfig{
    Provider: "ollama",
    Model:    "mxbai-embed-large",
    BaseURL:  "http://localhost:11434",
}

embedder, err := NewEmbedderFromConfig(config)
if err != nil {
    log.Fatal(err)
}

engine := NewSearchEngine(embedder)

Pattern 4: Function Pipelines

Sequential Processing

// ProcessFunc transforms data
type ProcessFunc func([]float32) ([]float32, error)

// Pipeline chains multiple processors
type Pipeline struct {
    steps []ProcessFunc
}

// NewPipeline creates a processing pipeline
func NewPipeline(steps ...ProcessFunc) *Pipeline {
    return &Pipeline{steps: steps}
}

// Execute runs all steps in sequence
func (p *Pipeline) Execute(input []float32) ([]float32, error) {
    result := input
    
    for i, step := range p.steps {
        var err error
        result, err = step(result)
        if err != nil {
            return nil, fmt.Errorf("step %d failed: %w", i, err)
        }
    }
    
    return result, nil
}

// Common processing steps
func Normalize() ProcessFunc {
    return func(v []float32) ([]float32, error) {
        var sum float32
        for _, val := range v {
            sum += val * val
        }
        
        magnitude := float32(math.Sqrt(float64(sum)))
        if magnitude == 0 {
            return v, nil
        }
        
        result := make([]float32, len(v))
        for i, val := range v {
            result[i] = val / magnitude
        }
        
        return result, nil
    }
}

func Quantize(bits int) ProcessFunc {
    return func(v []float32) ([]float32, error) {
        levels := float32(1 << bits)
        result := make([]float32, len(v))
        
        for i, val := range v {
            quantized := math.Round(float64(val * levels))
            result[i] = float32(quantized) / levels
        }
        
        return result, nil
    }
}

func Truncate(dims int) ProcessFunc {
    return func(v []float32) ([]float32, error) {
        if len(v) <= dims {
            return v, nil
        }
        return v[:dims], nil
    }
}

// Usage
pipeline := NewPipeline(
    Normalize(),
    Quantize(8),
    Truncate(512),
)

processed, err := pipeline.Execute(embedding)

Pattern 5: Functional Options

Flexible Configuration

// Option configures SearchEngine
type Option func(*SearchEngine)

// WithCache adds caching
func WithCache(cache Cache) Option {
    return func(s *SearchEngine) {
        s.cache = cache
    }
}

// WithLogger adds logging
func WithLogger(logger *log.Logger) Option {
    return func(s *SearchEngine) {
        s.logger = logger
    }
}

// WithMaxResults sets result limit
func WithMaxResults(max int) Option {
    return func(s *SearchEngine) {
        s.maxResults = max
    }
}

// NewSearchEngine creates engine with options
func NewSearchEngine(embedder EmbeddingFunc, opts ...Option) *SearchEngine {
    engine := &SearchEngine{
        embedder:   embedder,
        maxResults: 10,  // default
    }
    
    // Apply options
    for _, opt := range opts {
        opt(engine)
    }
    
    return engine
}

// Usage
engine := NewSearchEngine(
    embedder,
    WithCache(cache),
    WithLogger(logger),
    WithMaxResults(50),
)

Pattern 6: Middleware Pattern

Request/Response Wrapping

// Middleware wraps execution with additional behavior
type Middleware func(ExecuteFunc) ExecuteFunc

// ExecuteFunc executes a query
type ExecuteFunc func(ctx context.Context, query string) (*Result, error)

// LoggingMiddleware logs execution
func LoggingMiddleware(logger *log.Logger) Middleware {
    return func(next ExecuteFunc) ExecuteFunc {
        return func(ctx context.Context, query string) (*Result, error) {
            logger.Printf("Executing: %s", query)
            start := time.Now()
            
            result, err := next(ctx, query)
            
            duration := time.Since(start)
            if err != nil {
                logger.Printf("Error after %v: %v", duration, err)
            } else {
                logger.Printf("Success in %v, rows: %d", duration, len(result.Rows))
            }
            
            return result, err
        }
    }
}

// MetricsMiddleware tracks metrics
func MetricsMiddleware(metrics *Metrics) Middleware {
    return func(next ExecuteFunc) ExecuteFunc {
        return func(ctx context.Context, query string) (*Result, error) {
            start := time.Now()
            
            result, err := next(ctx, query)
            
            duration := time.Since(start)
            metrics.RecordQuery(duration, err == nil)
            
            return result, err
        }
    }
}

// CachingMiddleware adds caching
func CachingMiddleware(cache Cache) Middleware {
    return func(next ExecuteFunc) ExecuteFunc {
        return func(ctx context.Context, query string) (*Result, error) {
            // Check cache
            if cached, ok := cache.Get(query); ok {
                return cached.(*Result), nil
            }
            
            // Execute
            result, err := next(ctx, query)
            if err == nil {
                cache.Set(query, result)
            }
            
            return result, err
        }
    }
}

// Chain applies middleware in order
func Chain(execute ExecuteFunc, middleware ...Middleware) ExecuteFunc {
    // Apply in reverse order so first middleware is outermost
    for i := len(middleware) - 1; i >= 0; i-- {
        execute = middleware[i](execute)
    }
    return execute
}

// Usage
baseExecute := func(ctx context.Context, query string) (*Result, error) {
    // Core execution logic
    return executor.Execute(ctx, query, nil)
}

// Wrap with middleware
execute := Chain(
    baseExecute,
    LoggingMiddleware(logger),
    MetricsMiddleware(metrics),
    CachingMiddleware(cache),
)

// Execute with all middleware
result, err := execute(ctx, "MATCH (n) RETURN n")

Real-World Examples from Codebase

Example 1: Storage Interface

// pkg/storage/types.go

// Engine defines storage operations
type Engine interface {
    CreateNode(node *Node) error
    GetNode(id NodeID) (*Node, error)
    UpdateNode(node *Node) error
    DeleteNode(id NodeID) error
    // ... more methods
}

// Multiple implementations:
// - MemoryEngine (testing)
// - BadgerEngine (production)
// - CachedEngine (with caching layer)

// Usage with dependency injection
type Executor struct {
    storage Engine  // ← Interface, not concrete type
}

func NewExecutor(storage Engine) *Executor {
    return &Executor{storage: storage}
}

// Testing with mock
func TestExecutor(t *testing.T) {
    mockStorage := &MockEngine{
        nodes: make(map[NodeID]*Node),
    }
    
    executor := NewExecutor(mockStorage)
    
    // Test with mock storage
    result, err := executor.Execute(ctx, "CREATE (n:Test)", nil)
    assert.NoError(t, err)
}

Example 2: Embedding Provider

// pkg/embed/embed.go

// EmbedFunc generates embeddings
type EmbedFunc func(text string) ([]float32, error)

// Provider wraps embedding function with metadata
type Provider struct {
    Name       string
    Dimensions int
    Embed      EmbedFunc
}

// NewOllamaProvider creates Ollama-based embedder
func NewOllamaProvider(baseURL, model string, dims int) *Provider {
    embedFunc := func(text string) ([]float32, error) {
        // Call Ollama API
        return callOllamaAPI(baseURL, model, text)
    }
    
    return &Provider{
        Name:       "ollama/" + model,
        Dimensions: dims,
        Embed:      embedFunc,
    }
}

// NewLocalProvider creates local GGUF-based embedder
func NewLocalProvider(modelPath string, dims int) (*Provider, error) {
    model, err := loadGGUFModel(modelPath)
    if err != nil {
        return nil, err
    }
    
    embedFunc := func(text string) ([]float32, error) {
        return model.GenerateEmbedding(text)
    }
    
    return &Provider{
        Name:       "local/" + filepath.Base(modelPath),
        Dimensions: dims,
        Embed:      embedFunc,
    }, nil
}

// Usage - swap providers at runtime
var embedder EmbedFunc

if config.UseLocal {
    provider, _ := NewLocalProvider(config.ModelPath, 1024)
    embedder = provider.Embed
} else {
    provider := NewOllamaProvider(config.OllamaURL, config.Model, 1024)
    embedder = provider.Embed
}

// Add caching
embedder = WithCache(embedder, cache)

// Use in search engine
engine := NewSearchEngine(embedder)

Benefits Summary

Compared to Interfaces

Function Types: ✅ Less boilerplate
✅ Easier to compose
✅ Simpler mocking
✅ More flexible

Interfaces: ✅ Multiple methods
✅ Stronger contracts
✅ Better for complex APIs

When to Use Each

Use function types when:

  • Single method interface
  • Need runtime swapping
  • Want easy composition
  • Mocking is important

Use interfaces when:

  • Multiple related methods
  • Complex contracts
  • Need type safety
  • Building large APIs

Quick Reference

Function Type Template

// Define function type
type ProcessFunc func(input Data) (output Data, err error)

// Use in struct
type Processor struct {
    process ProcessFunc
}

// Factory function
func NewProcessor(fn ProcessFunc) *Processor {
    return &Processor{process: fn}
}

// Decorator
func WithLogging(fn ProcessFunc) ProcessFunc {
    return func(input Data) (Data, error) {
        log.Println("Processing...")
        return fn(input)
    }
}

// Usage
processor := NewProcessor(
    WithLogging(myProcessFunc),
)

Remember: Functional patterns make code more flexible and testable. Use them when you need runtime behavior modification or easy dependency injection.