How to Implement the Repository Pattern with database/sql

Implement the Repository Pattern in Go by defining an interface for data access and a concrete struct using database/sql to handle queries.

The tangled handler problem

You're building a service. The HTTP handler has SQL queries inside it. The unit test needs a real database. You change the query, and now you have to update three places. The code is leaking implementation details everywhere. You want to swap the database for a mock, or maybe a cache, without rewriting the handler. That's the problem the repository pattern solves.

The repository pattern creates a boundary between your domain logic and your data storage. It hides how data is retrieved and saved. Your handlers talk to a repository. The repository talks to the database. If you switch from PostgreSQL to DynamoDB, you rewrite the repository, not the handler. If you need to test the handler, you inject a fake repository that returns deterministic data.

The kitchen analogy

Think of a restaurant kitchen. The waiter takes an order. The waiter doesn't walk into the walk-in freezer, grab a chicken, and season it. The waiter gives the ticket to the kitchen. The kitchen handles the storage, retrieval, and prep. The waiter just gets the plate.

The repository is the kitchen interface. The waiter doesn't care if the chicken came from a freezer, a farm, or a mock generator for a training exercise. The waiter only knows how to ask for a plate. In Go, the waiter is your handler or service layer. The kitchen is the repository. The plate is the data structure.

The skeleton

Here's the core structure. You define an interface for the operations you need, then you build a concrete struct that implements those operations using database/sql.

// UserRepository defines the contract for user data operations.
// Methods accept context.Context to support cancellation and deadlines.
type UserRepository interface {
    GetByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
}

// sqlUserRepository holds the database connection pool.
// The struct is unexported to enforce the interface boundary.
type sqlUserRepository struct {
    db *sql.DB
}

// NewUserRepository returns a UserRepository backed by database/sql.
// Returning the interface allows callers to swap implementations later.
func NewUserRepository(db *sql.DB) UserRepository {
    return &sqlUserRepository{db: db}
}

The interface is public. The implementation struct is private. This follows the Go mantra: accept interfaces, return structs. The constructor returns UserRepository, not *sqlUserRepository. Callers depend on the interface, which keeps them decoupled from the SQL details.

Run gofmt on save. The community doesn't argue about indentation; the tool decides. Most editors run it automatically. Trust the formatter and focus on logic.

How the pieces fit

context.Context is always the first parameter. Name it ctx. Context is plumbing. It carries deadlines, cancellation signals, and request-scoped values. Every function that touches the database takes ctx so the driver can stop work if the client disconnects or the timeout expires.

The receiver is r, a short name matching the type. Go convention favors one or two letters for receivers, not this or self. The receiver holds *sql.DB. This is a connection pool, not a single connection. database/sql manages a pool of connections internally. You pass the pool around, and the driver checks out connections as needed.

The compiler checks interface satisfaction implicitly. You don't need to declare that sqlUserRepository implements UserRepository. If the method signatures match, the compiler accepts it. If you change the interface and forget to update the struct, the compiler rejects the code with sqlUserRepository does not implement UserRepository (wrong type for method GetByID). This is a compile-time safety net.

Real implementation

Here's the implementation with actual queries. Notice the error handling and context usage.

// GetByID fetches a user by ID from the database.
// It returns nil for both the user and error if no row exists.
func (r *sqlUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
    // QueryRowContext sends the query and returns a single row scanner.
    // The context allows the driver to cancel the query if the caller gives up.
    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)

    var user User
    // Scan copies the row data into the struct fields.
    // Arguments must be pointers to the destination values.
    err := row.Scan(&user.ID, &user.Name, &user.Email)

    if err != nil {
        // sql.ErrNoRows indicates the query returned zero rows.
        // This is a valid result, not a system error.
        if err == sql.ErrNoRows {
            return nil, nil
        }
        // Wrap the error to preserve the call stack and add context.
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &user, nil
}

QueryRowContext returns a *sql.Row. You call Scan on it to map columns to struct fields. If the query returns no rows, Scan returns sql.ErrNoRows. This is distinct from a connection error or a syntax error. You must check for sql.ErrNoRows explicitly. If you don't, the error bubbles up as a generic scan failure, and the caller can't tell if the user is missing or the database is down.

Error wrapping uses fmt.Errorf with %w. This preserves the original error type while adding context. Callers can use errors.Is to check for specific errors like sql.ErrNoRows even after wrapping. The verbose if err != nil pattern is intentional. It makes the unhappy path visible. The community accepts the boilerplate because hidden errors cause production fires.

Here's the save operation.

// Save inserts a new user record.
// It returns an error if the insert fails or the context is cancelled.
func (r *sqlUserRepository) Save(ctx context.Context, user *User) error {
    // ExecContext runs the query without returning rows.
    // The first return value is Result metadata, which we discard here.
    _, err := r.db.ExecContext(ctx, "INSERT INTO users (name, email) VALUES ($1, $2)", user.Name, user.Email)
    if err != nil {
        // Wrap errors to include the operation name for easier debugging.
        return fmt.Errorf("save user %s: %w", user.Name, err)
    }
    return nil
}

ExecContext runs the query and returns a Result. The Result contains metadata like the number of affected rows. If you don't need the metadata, discard it with _. The underscore says "I considered this return value and chose to drop it." Don't use _ for errors. Always handle errors.

Pitfalls and traps

The transaction trap is the hardest part of repositories. If Save needs to be atomic with UpdateProfile, passing *sql.DB isn't enough. You might need to pass *sql.Tx or use a context key to share a transaction across multiple repository calls. This adds complexity. Decide if your domain needs multi-repo transactions before you commit to the pattern.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In repositories, leaks usually come from queries that hang. If the context is cancelled, the driver should abort the query. If you forget to pass ctx to the database call, the query runs until it finishes or times out, even if the client gave up.

If you forget to import a package, you get undefined: pkg from the compiler. If you import a package and don't use it, you get imported and not used. Go is strict about imports. Unused imports are compile errors. This keeps the dependency graph clean.

Public names start with a capital letter. Private names start lowercase. There are no keywords like public or private. Visibility is controlled by capitalization. The interface is public so other packages can depend on it. The struct is private so only this package can implement it.

When to use the pattern

Use a repository interface when you need to swap data sources, like testing with a mock or switching from PostgreSQL to DynamoDB. Use a repository when your domain logic is complex and you want to keep SQL queries isolated from business rules. Use direct database/sql calls when the project is small and the overhead of an interface adds no value. Use an ORM like GORM when you prioritize rapid development over explicit control of queries and performance.

Accept interfaces, return structs. Let the caller decide how to use the data.

Context is plumbing. Run it through every long-lived call site.

The repository is a boundary, not a magic box. Keep it thin.

Where to go next