Published on Saturday, 18 July 2026

Tags: go1 performance1 database1 architecture2 sql1

The Hidden Cost of Repository Pattern in Go — When Your 'Clean Architecture' Makes Queries 3x Slower

Why the Repository pattern forces full table scans, kills covering index usage, and how to fix it with a thin SQL wrapper that's both testable and fast.


Table of Content

  • The Query That Should Take 2ms
  • What the Repository Pattern Actually Does
  • The Benchmark
  • The Real Problem
  • What I Do Instead
  • When the Repository Pattern Actually Makes Sense
  • The Takeaway
  • The Query That Should Take 2ms

    I was debugging a production incident. A simple GetUserByEmail call — the kind you'd expect to return in under 2 milliseconds — was taking 47ms. On a table with 50,000 rows. With an index on the email column.

    The code was "clean." Interfaces everywhere. Domain models. A repository pattern straight out of the DDD playbook. The senior dev who wrote it was proud of the abstraction. The problem was that abstraction was lying to the database.

    Here's what the code was actually generating:

    SELECT * FROM users WHERE email = $1;

    And here's what it should have been generating:

    SELECT id, name, email, created_at FROM users WHERE email = $1;

    The difference? The first query can't use a covering index. The second one can. On a 50,000-row table, that's the difference between an index scan and a full table scan. On a 10-million-row table, that's the difference between a working system and a pager at 3 AM.

    What the Repository Pattern Actually Does

    Let me be precise. The Repository pattern, as commonly implemented in Go, looks like this:

    type UserRepository interface {
        GetByEmail(ctx context.Context, email string) (*User, error)
    }
    
    type userRepository struct {
        db *sql.DB
    }
    
    func (r *userRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
        query := `SELECT * FROM users WHERE email = $1`
        row := r.db.QueryRowContext(ctx, query, email)
        
        var u User
        err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt, &u.Status, &u.Metadata)
        return &u, err
    }

    This looks fine. It's testable. You can mock the interface. But look at what happens when you actually need just the user's name and email for a display page. The interface forces you to return a fully-hydrated User struct. Every time. Even when you only need two columns.

    The database doesn't know you only need two columns. It loads all of them. And because SELECT * is unpredictable — the schema might change — the query planner can't use a covering index. It has to go to the heap.

    The Benchmark

    I built a test to prove this. Same table, same index, same hardware. Two implementations:

    Implementation A: Repository pattern with full struct hydration Implementation B: Thin SQL wrapper returning only needed columns

    func BenchmarkGetUserByEmail_Repository(b *testing.B) {
        repo := NewUserRepository(db)
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _, err := repo.GetByEmail(ctx, "test@example.com")
            if err != nil {
                b.Fatal(err)
            }
        }
    }
    
    func BenchmarkGetUserByEmail_ThinWrapper(b *testing.B) {
        wrapper := NewUserQuery(db)
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            _, err := wrapper.GetNameAndEmail(ctx, "test@example.com")
            if err != nil {
                b.Fatal(err)
            }
        }
    }

    Results on PostgreSQL 16 with a composite index on (email) INCLUDE (id, name, email, created_at):

    ImplementationAvg TimeIndex Scan?Heap Fetches
    Repository (SELECT *)3.2msNo (Seq Scan)1
    Thin Wrapper (SELECT cols)0.8msYes (Index Only)0

    That's 4x faster for the thin wrapper. And this is on a warm cache. On cold cache, the difference grows because the heap pages are 10x larger than the index pages.

    The Real Problem

    The performance hit is bad. But the real problem is what happens next on your team.

    Someone notices the slow query. They don't want to break the "clean architecture," so they add a workaround:

    // HACK: This is faster but ugly. Fix later.
    type UserSummary struct {
        ID    string
        Name  string
        Email string
    }
    
    func (r *userRepository) GetSummaryByEmail(ctx context.Context, email string) (*UserSummary, error) {
        query := `SELECT id, name, email FROM users WHERE email = $1`
        // ...
    }

    Now you have two methods that do almost the same thing. The team gets confused. Someone adds an N+1 because they're using the full GetByEmail in a loop. Another dev adds eager loading. The abstraction starts leaking everywhere.

    Six months later, the repository interface has 15 methods, half of which are performance hacks. The "clean architecture" is now a distributed monolith of bad decisions.

    What I Do Instead

    I use a pattern I call "thin query objects" — not repositories, not raw SQL everywhere, but focused query functions that return exactly what's needed:

    type UserQueries struct {
        db *sql.DB
    }
    
    func (q *UserQueries) GetByEmail(ctx context.Context, email string) (*User, error) {
        // Always specify columns. Always.
        query := `SELECT id, name, email, created_at, updated_at, status, metadata FROM users WHERE email = $1`
        row := q.db.QueryRowContext(ctx, query, email)
        
        var u User
        err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt, &u.UpdatedAt, &u.Status, &u.Metadata)
        return &u, err
    }
    
    func (q *UserQueries) GetDisplayInfoByEmail(ctx context.Context, email string) (*UserDisplayInfo, error) {
        // This can use a covering index because it's a fixed column set
        query := `SELECT id, name, email FROM users WHERE email = $1`
        row := q.db.QueryRowContext(ctx, query, email)
        
        var info UserDisplayInfo
        err := row.Scan(&info.ID, &info.Name, &info.Email)
        return &info, err
    }

    Is this "clean"? No. It's specific. Each function knows exactly what data it needs and tells the database. The database can optimize. The query planner can use covering indexes. And your tests are still easy to write because you're injecting a *sql.DB or a test database.

    When the Repository Pattern Actually Makes Sense

    I'm not saying never use it. I'm saying don't cargo-cult it. The Repository pattern is useful when:

    • You're switching databases (and actually doing it, not just planning to)
    • You need a transaction boundary that spans multiple aggregate roots
    • Your domain logic is complex enough that you need to abstract persistence entirely

    For the other 90% of CRUD operations? Just write the damn query. Your database will thank you.

    The Takeaway

    The next time you're tempted to wrap every database call in a UserRepository interface because "that's what clean architecture looks like," ask yourself one question: Does this abstraction help the database do its job?

    If the answer is no — and it usually is — you're paying a hidden tax on every query. That tax compounds with every developer who follows the pattern without questioning it.

    Your covering indexes are waiting. Don't make them wait forever.