← All Posts

Go Generics in Production: Patterns That Actually Scale

Matthias Bruns · · 9 min read
go generics backend performance

Go generics landed in version 1.18 with significant fanfare, but the real question isn’t whether they work—it’s how to use them effectively in production systems. After two years of real-world usage, clear patterns have emerged that separate toy examples from code that scales.

This isn’t about basic generic syntax. It’s about the architectural decisions, performance trade-offs, and design patterns that actually matter when you’re shipping code to production.

The Production Reality Check

Most generic code examples you’ll find online look like this:

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

That’s fine for tutorials, but production systems need more nuanced approaches. The Go team’s own guidance emphasizes a key principle: use generics when you need the same logic for multiple types, not just because you can.

In practice, this means focusing on three core areas where generics provide genuine value:

  • Type-safe collections and data structures
  • Algorithm implementations that work across multiple types
  • API boundaries that need compile-time type safety

Constraint Design Patterns That Scale

Behavioral Constraints Over Type Lists

The most maintainable generic code uses behavioral constraints rather than type unions. Instead of listing specific types, define what operations your code needs:

// Avoid: brittle type lists
type Numeric interface {
    int | int32 | int64 | float32 | float64
}

// Prefer: behavioral constraints
type Addable[T any] interface {
    Add(T) T
}

type Comparable[T any] interface {
    Compare(T) int
}

This approach from advanced production patterns makes your code more extensible. New types can satisfy your constraints without modifying the constraint definition.

Composite Constraints for Complex Operations

Real production code often needs multiple capabilities. Build composite constraints that express exactly what your algorithms require:

type Sortable[T any] interface {
    constraints.Ordered
    fmt.Stringer
}

type Cacheable[T any] interface {
    comparable
    encoding.BinaryMarshaler
    encoding.BinaryUnmarshaler
}

func SortAndCache[T Sortable[T] & Cacheable[T]](items []T) error {
    sort.Slice(items, func(i, j int) bool {
        return items[i] < items[j]
    })
    return cacheItems(items)
}

The intersection operator (&) lets you combine constraints precisely. This eliminates the need for runtime type assertions while keeping interfaces focused.

Method Set Constraints for Domain Logic

When building domain-specific generic code, define constraints that capture your business logic requirements:

type Processable[T any] interface {
    Validate() error
    Process() error
    GetID() string
}

type Processor[T Processable[T]] struct {
    items []T
    logger *log.Logger
}

func (p *Processor[T]) ProcessBatch() error {
    for _, item := range p.items {
        if err := item.Validate(); err != nil {
            p.logger.Printf("validation failed for %s: %v", item.GetID(), err)
            continue
        }
        if err := item.Process(); err != nil {
            return fmt.Errorf("processing failed for %s: %w", item.GetID(), err)
        }
    }
    return nil
}

This pattern lets you write processing logic once and apply it to any type that implements your domain interface.

Collection Patterns for Production Systems

Type-Safe Result Containers

One of the most valuable production patterns is building type-safe containers for common operations like result handling:

type Result[T any] struct {
    value T
    err   error
}

func NewResult[T any](value T, err error) Result[T] {
    return Result[T]{value: value, err: err}
}

func (r Result[T]) Unwrap() (T, error) {
    return r.value, r.err
}

func (r Result[T]) Map[U any](fn func(T) U) Result[U] {
    if r.err != nil {
        var zero U
        return Result[U]{value: zero, err: r.err}
    }
    return NewResult(fn(r.value), nil)
}

func (r Result[T]) FlatMap[U any](fn func(T) Result[U]) Result[U] {
    if r.err != nil {
        var zero U
        return Result[U]{value: zero, err: r.err}
    }
    return fn(r.value)
}

This eliminates repetitive error handling patterns while maintaining type safety throughout your call chains.

Generic Cache Implementation

Production systems often need caching with different key and value types. A well-designed generic cache handles this elegantly:

type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    items map[K]cacheItem[V]
    ttl   time.Duration
}

type cacheItem[V any] struct {
    value   V
    expires time.Time
}

func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
    c := &Cache[K, V]{
        items: make(map[K]cacheItem[V]),
        ttl:   ttl,
    }
    go c.cleanup()
    return c
}

func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = cacheItem[V]{
        value:   value,
        expires: time.Now().Add(c.ttl),
    }
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    item, exists := c.items[key]
    if !exists || time.Now().After(item.expires) {
        var zero V
        return zero, false
    }
    return item.value, true
}

The key insight here is using the comparable constraint for keys, which ensures they can be used in maps while keeping values completely generic.

Performance Considerations in Production

Compilation Time Impact

Generics increase compilation time, especially with complex constraint hierarchies. Production experience shows that deeply nested generic types can significantly slow builds.

Monitor your build times and consider these strategies:

  • Keep constraint hierarchies shallow (3 levels maximum)
  • Use type aliases for frequently-used constraint combinations
  • Profile compilation time when adding new generic code

Runtime Performance Characteristics

Go’s generic implementation uses compile-time monomorphization for most cases, but interface-constrained generics can introduce runtime overhead. Benchmark critical paths:

func BenchmarkGenericVsInterface(b *testing.B) {
    // Generic version
    b.Run("Generic", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            result := ProcessGeneric[int](42)
            _ = result
        }
    })
    
    // Interface version
    b.Run("Interface", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            result := ProcessInterface(42)
            _ = result
        }
    })
}

In most cases, generics perform identically to hand-written type-specific code. The exception is when constraints require interface satisfaction at runtime.

Memory Usage Patterns

Generic types can impact memory usage in subtle ways. Each instantiation creates a new type, which affects reflection and runtime type information:

// Each instantiation creates separate runtime types
cache1 := NewCache[string, User]()     // Cache[string, User]
cache2 := NewCache[string, Product]()  // Cache[string, Product] 
cache3 := NewCache[int, User]()        // Cache[int, User]

This usually isn’t a problem, but be aware when using reflection-heavy code or when instantiating many generic types with different parameters.

Architectural Patterns for Scale

Generic Middleware Chains

Building middleware systems with generics provides type safety while maintaining flexibility:

type Middleware[T any] func(T, func(T) error) error

type Pipeline[T any] struct {
    middlewares []Middleware[T]
}

func NewPipeline[T any]() *Pipeline[T] {
    return &Pipeline[T]{}
}

func (p *Pipeline[T]) Use(middleware Middleware[T]) {
    p.middlewares = append(p.middlewares, middleware)
}

func (p *Pipeline[T]) Execute(ctx T, handler func(T) error) error {
    if len(p.middlewares) == 0 {
        return handler(ctx)
    }
    
    return p.executeMiddleware(0, ctx, handler)
}

func (p *Pipeline[T]) executeMiddleware(index int, ctx T, handler func(T) error) error {
    if index >= len(p.middlewares) {
        return handler(ctx)
    }
    
    middleware := p.middlewares[index]
    return middleware(ctx, func(ctx T) error {
        return p.executeMiddleware(index+1, ctx, handler)
    })
}

This pattern eliminates the need for interface{} casting while providing a clean, composable architecture.

Repository Pattern with Generic Constraints

Database access layers benefit significantly from generic patterns that enforce consistent interfaces:

type Entity interface {
    GetID() string
    SetID(string)
    Validate() error
}

type Repository[T Entity] interface {
    Create(ctx context.Context, entity T) error
    GetByID(ctx context.Context, id string) (T, error)
    Update(ctx context.Context, entity T) error
    Delete(ctx context.Context, id string) error
    List(ctx context.Context, filter Filter) ([]T, error)
}

type BaseRepository[T Entity] struct {
    db    *sql.DB
    table string
}

func NewRepository[T Entity](db *sql.DB, table string) Repository[T] {
    return &BaseRepository[T]{
        db:    db,
        table: table,
    }
}

func (r *BaseRepository[T]) Create(ctx context.Context, entity T) error {
    if err := entity.Validate(); err != nil {
        return fmt.Errorf("validation failed: %w", err)
    }
    
    // Generate ID if not set
    if entity.GetID() == "" {
        entity.SetID(generateID())
    }
    
    // Database insertion logic here
    return r.insertEntity(ctx, entity)
}

This approach enforces consistent behavior across all repository implementations while maintaining type safety.

Migration Strategies from Existing Code

Gradual Interface Replacement

Don’t rewrite everything at once. Best practices suggest migrating incrementally:

// Phase 1: Keep existing interface, add generic alternative
type ProcessorInterface interface {
    Process(interface{}) error
}

type GenericProcessor[T any] interface {
    Process(T) error
}

// Phase 2: Implement both in new code
type DataProcessor[T any] struct {
    // implementation
}

func (p *DataProcessor[T]) Process(data T) error {
    // type-safe implementation
}

func (p *DataProcessor[T]) ProcessLegacy(data interface{}) error {
    typed, ok := data.(T)
    if !ok {
        return fmt.Errorf("invalid type")
    }
    return p.Process(typed)
}

// Phase 3: Deprecate interface{} methods after migration

Type Assertion Elimination

Replace runtime type assertions with compile-time safety:

// Before: runtime type assertions
func ProcessData(data interface{}) error {
    switch v := data.(type) {
    case User:
        return processUser(v)
    case Product:
        return processProduct(v)
    default:
        return fmt.Errorf("unsupported type")
    }
}

// After: compile-time type safety
type Processable interface {
    Process() error
}

func ProcessData[T Processable](data T) error {
    return data.Process()
}

This eliminates entire classes of runtime errors while improving performance.

Common Pitfalls and Solutions

Over-Constraining Types

Avoid creating constraints that are too specific. This makes your code less reusable:

// Too specific
type DatabaseUser interface {
    GetDatabaseID() int64
    GetDatabaseTable() string
    SaveToDatabase() error
}

// Better: focus on behavior
type Identifiable interface {
    GetID() string
}

type Persistable interface {
    Save() error
}

Generic Type Explosion

Resist the urge to make everything generic. The Go team’s guidance is clear: start with concrete types, then generalize when you have multiple implementations.

Constraint Interface Pollution

Don’t add methods to constraints just because you might need them:

// Bad: kitchen sink constraint
type Everything[T any] interface {
    String() string
    Validate() error
    Process() error
    Save() error
    Load() error
    // ... more methods
}

// Good: focused, composable constraints
type Validator interface {
    Validate() error
}

type Processor interface {
    Process() error
}

type ValidatingProcessor interface {
    Validator
    Processor
}

The Production Verdict

Go generics work best when they solve real type safety problems, not when they’re used for academic elegance. Focus on eliminating interface{} usage, building reusable collections, and creating type-safe APIs.

The patterns that scale in production share common characteristics: they reduce runtime errors, improve code clarity, and maintain Go’s emphasis on simplicity. Use them judiciously, measure their impact, and always prioritize readability over cleverness.

After two years in production, generics have proven their value in specific domains while confirming that Go’s original design principles still matter most. They’re a powerful tool, but like any tool, their value comes from knowing when and how to use them effectively.

Reader settings

Font size