DEV Community

Cover image for Clean, Performant, and Testable: Mastering Data Access in Go with Repositories & sqlc
Claret Ibeawuchi
Claret Ibeawuchi

Posted on

Clean, Performant, and Testable: Mastering Data Access in Go with Repositories & sqlc

Part 1: The Pursuit for Clean Data Access in Go: Understanding the Repository Pattern


Table of Contents

  1. Introduction: The Data Access Dilemma
  2. What Is the Repository Pattern?
  3. The Wild West: Life Without Repositories
  4. The Allure of ORMs: Convenience vs. Control
  5. Story Time: Three Teams, One Goal
  6. The Path Forward
  7. What's Next in This Series

Introduction: The Data Access Dilemma

By the end of this article, you'll understand why the Repository Pattern is essential for building maintainable, testable, and performant Go applications.

Picture this: You're three months into a Go project. What started as a simple service with a few database calls has grown into a complex system. Your HTTP handlers are bloated with SQL queries, your tests require a real database to run, and every schema change sends you on a hunt through dozens of files to update hardcoded queries.

Sound familiar? You're not alone.

In the world of backend development, especially with Go's emphasis on simplicity and performance, how we interact with databases is a critical architectural decision. It influences everything from code maintainability and testability to application performance and scalability.

This three-part series will guide you through a pragmatic and powerful approach: combining the time-tested Repository Pattern with sqlc, a modern tool that generates type-safe Go code from your SQL. By the end, you'll have the knowledge to build robust, clean, and efficient data access layers that scale with your application.

In this first part, we'll explore the foundational concepts, common pitfalls, and set the stage for the practical implementations to come.


What Is the Repository Pattern?

The Repository Pattern is a design pattern that creates an abstraction layer between your application's business logic and data access logic. Think of it as a specialized librarian who knows exactly where to find any book (data) you need, regardless of how the library is organized behind the scenes.

The Core Purpose: Decoupling

The Repository Pattern's primary goal is decoupling. Your application services interact with a well-defined interface, remaining blissfully unaware of:

  • The underlying data store (PostgreSQL, MySQL, MongoDB, or even in-memory collections)
  • How data is accessed (raw SQL, ORM, or sqlc)
  • Database-specific quirks and connection management

Key Characteristics

Characteristic Description Benefit
Interface-Driven Data operations are defined by Go interfaces (e.g., GetUserByID, CreateOrder) Enables easy mocking and testing
Encapsulation Hides low-level database interaction details Reduces complexity in business logic
Collection Semantics Presents data as if it were an in-memory collection Intuitive API for developers

The Payment Terminal Analogy

Imagine a cashier at a busy supermarket. The cashier (your service layer) focuses on checking out customers. When processing payment, they interact with a payment terminal (your repository interface) by simply selecting "Card Payment" and following prompts.

The cashier doesn't need to know:

  • Whether it's Visa, Mastercard, or Amex
  • Which bank processes the transaction
  • What encryption protocols are used
  • How the network communication works

The payment terminal (your repository implementation) handles all these complexities and returns a simple result: "Payment Approved" or "Payment Declined."

This abstraction is powerful. The store can switch payment processors, update security protocols, or change communication methods without retraining cashiers or modifying the checkout process.

A cartoon-style illustration showing a smiling cashier at a register, with a payment terminal, and behind the terminal

High-Level Architecture

Basic architecture diagram showing use of repository pattern


The Wild West: Life Without Repositories

Before we appreciate the Repository Pattern's elegance, let's examine the chaos of unstructured data access—what I call the "Wild West" approach.

The Tempting Simplicity

It's common, especially in smaller projects, to see database logic embedded directly in service functions or HTTP handlers. Here's what this looks like:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
}

type UserService struct {
    dbpool *pgxpool.Pool
}

func (s *UserService) CreateUserAndNotify(ctx context.Context, name, email string) (User, error) {
    var userID int64
    var createdAt time.Time

    // Direct SQL embedded in business logic
    query := "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, created_at"
    err := s.dbpool.QueryRow(ctx, query, name, email).Scan(&userID, &createdAt)
    if err != nil {
        log.Printf("Error creating user %s: %v", email, err)
        return User{}, fmt.Errorf("could not create user: %w", err)
    }

    newUser := User{ID: userID, Name: name, Email: email, CreatedAt: createdAt}

    // Business logic mixed with data access
    // - Send welcome email
    // - Log audit event
    // - Update cache

    log.Printf("User '%s' created with ID %d", newUser.Name, newUser.ID)
    return newUser, nil
}
Enter fullscreen mode Exit fullscreen mode

The Growing Pains

Problem Impact Example
Testing Nightmare Can't unit test business logic without database Mock pgxpool.Pool and all its methods
Responsibility Overload Services handle business logic AND data access Single function does SQL + validation + notifications
Code Duplication Same queries scattered across files SELECT * FROM users WHERE id = $1 repeated everywhere
Fragile Refactoring Schema changes require codebase-wide updates Rename a column? Hunt through 50 files
Tight Coupling Business logic tied to specific database/driver Want to add caching? Major rewrite needed

The Maintenance Burden

As your application grows, this approach quickly becomes inconvenient:

  1. Testing becomes impossible without complex database setup
  2. Code reviews become exercises in spotting SQL typos
  3. Performance optimization requires hunting through business logic
  4. Transaction management becomes spaghetti code across functions

While direct database access might seem simpler initially, it often leads to what architects call a "Big Ball of Mud"—a system that's difficult to maintain, test, and evolve.


The Allure of ORMs: Convenience vs. Control

When developers encounter the pains of raw SQL mixed with business logic, Object-Relational Mappers (ORMs) often appear as a silver bullet. Popular Go ORMs include GORM, Bun, and SQLBoiler.

What ORMs Promise

ORMs aim to provide a higher-level abstraction for database interactions, mapping tables to Go structs and offering APIs for CRUD operations without raw SQL.

The Seductive Benefits

Benefit Description Example
Rapid Development Quick CRUD operations db.Create(&Product{Code: "D42", Price: 100})
Database Agnosticism Switch databases with configuration PostgreSQL to MySQL with a setting change
Less SQL Object-oriented query building db.Where("price > ?", 100).Find(&products)

The Go Philosophy Clash

However, ORMs come with trade-offs that can feel at odds with Go's emphasis on simplicity and explicitness:

1. The "Hidden Magic" Problem

// What SQL does this generate?
db.Preload("Orders.Items").Where("age > ?", 18).Find(&users)

// Could be efficient... or could be a performance disaster
Enter fullscreen mode Exit fullscreen mode

2. Complex Abstractions

Learning a full-featured ORM often requires as much effort as learning SQL itself, plus:

  • Struct tagging conventions
  • Query builder syntax
  • Transaction management
  • Association handling
  • Performance optimization strategies

3. Performance Overheads

The abstraction layer introduces overhead that can become noticeable in high-throughput applications.

4. Leaky Abstractions

Despite promises of abstracting away the database, you still need deep SQL knowledge for:

  • Performance optimization
  • Complex queries
  • Debugging
  • Advanced database features

When ORMs Shine vs. When They Struggle

Scenario ORM Fit Reason
Admin Panels ✅ Excellent Simple CRUD operations
Prototypes ✅ Good Rapid development
Complex Analytics ❌ Poor Need optimized SQL
High Performance ❌ Challenging Overhead and unpredictability
Database-Specific Features ❌ Limited Abstractions get in the way

Story Time: Three Teams, One Goal

To illustrate these different approaches in practice, let's follow three development teams building an invoicing application. Each team takes a different path up "Data Mountain."

The Teams

  • Team Alpha (Lead: Claret) - Chooses GORM
  • Team Beta (Lead: Musa) - Implements Repository Pattern manually
  • Team Gamma (Lead: Sofia) - Uses raw SQL in services

Act 1: The Easy Start

Team Alpha (GORM)

Claret's team hit the ground running:

type Invoice struct {
    gorm.Model
    ClientID uint
    Amount   float64
    Status   string
    Client   Client
    Items    []LineItem
}

// Magic happens
db.AutoMigrate(&Invoice{}, &Client{}, &LineItem{})
db.Create(&Invoice{ClientID: 1, Amount: 100.50, Status: "pending"})
Enter fullscreen mode Exit fullscreen mode

"GORM is amazing!" Claret exclaimed. Basic operations were up in days.

Team Beta (Repository Pattern)

Musa's team took a more methodical approach:

type InvoiceRepository interface {
    Create(ctx context.Context, invoice Invoice) (Invoice, error)
    GetByID(ctx context.Context, id int64) (Invoice, error)
    GetByClientID(ctx context.Context, clientID int64) ([]Invoice, error)
}

type postgresInvoiceRepo struct {
    db *pgxpool.Pool
}

func (r *postgresInvoiceRepo) Create(ctx context.Context, invoice Invoice) (Invoice, error) {
    query := `INSERT INTO invoices (client_id, amount, status) 
              VALUES ($1, $2, $3) RETURNING id, created_at`
    // ... implementation
}
Enter fullscreen mode Exit fullscreen mode

Setup took longer, but Musa felt confident: "Clean interfaces, explicit SQL, full control."

Team Gamma (Raw SQL)

Sofia embedded database calls directly:

func (h *InvoiceHandler) CreateInvoice(w http.ResponseWriter, r *http.Request) {
    // Parse request...

    var invoiceID int64
    err := h.dbpool.QueryRow(ctx, 
        "INSERT INTO invoices (client_id, amount) VALUES ($1, $2) RETURNING id",
        clientID, amount).Scan(&invoiceID)
    if err != nil {
        // Handle error...
    }

    // More business logic...
}
Enter fullscreen mode Exit fullscreen mode

"No extra layers, pure Go and SQL," Sofia reasoned.

Act 2: The Complex Feature Challenge

The teams needed to implement "Year-End Financial Reporting" - a feature requiring complex aggregations, joins, and calculations best performed in the database.

Team Alpha's Struggle

// Trying to express complex SQL with GORM
result := db.Model(&Invoice{}).
    Select("SUM(CASE WHEN status = 'paid' AND paid_date BETWEEN ? AND ? THEN amount ELSE 0 END) as total_paid").
    Joins("JOIN clients ON invoices.client_id = clients.id").
    Where("clients.region = ?", region).
    Group("clients.region")
Enter fullscreen mode Exit fullscreen mode

Claret frowned: "The generated SQL is inefficient, and debugging this chain of methods is painful."

Team Beta's Control

func (r *postgresReportRepo) GetYearEndReport(ctx context.Context, year int) (Report, error) {
    query := `
        SELECT 
            c.region,
            SUM(CASE WHEN i.status = 'paid' AND i.paid_date BETWEEN $1 AND $2 
                THEN i.amount ELSE 0 END) as total_paid,
            COUNT(i.id) as total_invoices
        FROM invoices i
        JOIN clients c ON i.client_id = c.id
        WHERE EXTRACT(year FROM i.created_at) = $3
        GROUP BY c.region
        ORDER BY total_paid DESC`

    // Explicit, optimized, debuggable
    rows, err := r.db.Query(ctx, query, startDate, endDate, year)
    // ... scanning logic
}
Enter fullscreen mode Exit fullscreen mode

Musa smiled: "Perfect SQL, but managing the scanning is tedious."

Team Gamma's Multiplication

Sofia found herself copying similar complex queries across multiple handlers, each with slight variations. Maintaining consistency became a nightmare.

Act 3: The Production Crisis

A critical bug emerged: recently paid invoices were still showing as 'overdue' in the nightly batch job.

Team Alpha's Mystery

The bug was hidden in GORM's lifecycle hooks and implicit update behavior. Tracing the exact point where the status field was incorrectly handled required digging through GORM's internals.

"There's too much magic!" a frustrated developer exclaimed.

Team Beta's Clarity

Musa's team traced the issue to their InvoiceRepository.UpdateStatus method. The explicit SQL made debugging straightforward:

func (r *postgresInvoiceRepo) UpdateStatus(ctx context.Context, id int64, status string) error {
    query := `UPDATE invoices SET status = $1, updated_at = NOW() WHERE id = $2`
    _, err := r.db.Exec(ctx, query, status, id)
    return err
}
Enter fullscreen mode Exit fullscreen mode

Team Gamma's Hunt

Sofia found multiple places where invoice statuses were updated, each with slightly different SQL. The lack of centralization made the bug hard to pin down and fix consistently.

The Scorecard

Aspect Team Alpha (GORM) Team Beta (Repository) Team Gamma (Raw SQL)
Initial Speed 🚀 Very Fast 🛠️ Moderate ⚡ Instant
Complex Queries 😰 Struggled 😊 Controlled 😅 Repetitive
Debugging 🔍 Opaque 🎯 Clear 🕵️ Scattered
Maintainability 📉 Degraded 📈 Improved 📉 Chaotic
Testing 🧪 Database-dependent ✅ Mockable 🚫 Impossible

The Path Forward

Our story reveals that each approach has its place, but the Repository Pattern offers the best balance of control, maintainability, and testability for most Go applications.

However, Team Beta's experience highlights a remaining challenge: implementing repositories manually involves significant boilerplate—writing SQL strings, managing Scan() calls, and handling type mapping.

This is where sqlc enters the picture. It bridges the gap between the Repository Pattern's architectural benefits and the developer experience of working with raw SQL.

What Makes a Great Data Access Strategy?

Based on our analysis, an ideal approach should provide:

  1. Clear separation of concerns ✅ Repository Pattern
  2. Explicit, optimized SQL ✅ Raw SQL / sqlc
  3. Type safety ✅ sqlc
  4. Testability ✅ Repository Pattern
  5. Minimal boilerplate ✅ sqlc
  6. Performance transparency ✅ Raw SQL / sqlc

The Repository Pattern + sqlc combination delivers on all these requirements.


What's Next in This Series

In Part 2: Implementing Effective Repositories in Go & Confronting the Critiques, we'll dive into practical implementation:

  1. Build a complete repository - First manually with pgx, then enhanced with sqlc
  2. Demonstrate testing strategies - Mock repositories, integration tests, and best practices
  3. Address common criticisms - "Over-engineering," "Just use an ORM," and other concerns
  4. Show real-world patterns - Transaction management, caching, and performance optimization

Conclusion

The Repository Pattern isn't just another abstraction layer—it's a strategic architectural decision that pays dividends as your application grows. By decoupling business logic from data access concerns, you create code that's more testable, maintainable, and adaptable to changing requirements.

The choice isn't between raw SQL and ORMs—it's between chaos and clarity, between fighting your tools and working with them. The Repository Pattern, especially when combined with sqlc, offers the clarity and control that Go developers value while maintaining the performance characteristics that make Go shine in backend development.

Ready to see this pattern in action? Join me in Part 2, where I'll build a production-ready repository implementation that will transform how you think about data access in Go.


Are you currently struggling with data access patterns in your Go applications? What challenges have you faced with ORMs or raw SQL? Share your experiences in the comments below—your insights help the entire Go community learn and grow.

Top comments (0)