Implement repository pattern in Go

Implement the repository pattern in Go by defining an interface for data operations and creating a struct that implements it to abstract database interactions.

The boundary between logic and data

You're building a service that manages user accounts. Right now, your HTTP handler calls db.Query directly to fetch a user. It works fine for a week. Then you need to add a cache layer to reduce database load. Or maybe you want to write a test that runs without a real database. You look at the code and realize the database calls are glued to the handler. Changing the data source means rewriting the handler logic. That coupling makes the code brittle. The repository pattern solves this by introducing a clean boundary. It lets you swap the data source without touching the business logic.

What a repository actually does

A repository is a collection of data that looks like an in-memory collection to the caller. It hides the details of where the data comes from. Think of a repository like a waiter in a restaurant. You don't go into the kitchen to cook your own meal. You tell the waiter what you want. The waiter takes your order to the kitchen, gets the food, and brings it back. You don't care if the kitchen uses a gas stove or an electric oven. You just care that you get the food. In Go, the waiter is an interface. The kitchen is your database implementation. Your business logic talks to the waiter, never the kitchen directly.

This separation gives you two benefits. First, you can replace the kitchen with a mock kitchen for testing. Second, you can change the kitchen technology later without retraining the waiters. The interface stays the same.

Go has a strong convention for this pattern: accept interfaces, return structs. Your function signatures should take the repository interface as a parameter. The function returns concrete structs, not interfaces. This keeps the dependency flowing in the right direction. The business logic depends on the interface, but the interface is defined by the business logic's needs, not the database.

Minimal example

Here's the simplest repository: an interface with one method, and a struct that implements it using an in-memory map.

// User represents a domain entity with stable fields.
type User struct {
    ID   int
    Name string
}

// UserRepository defines the contract for user data access.
// The interface lives in the package that uses it, not the package that implements it.
// This keeps the dependency pointing inward toward the business logic.
type UserRepository interface {
    GetByID(id int) (*User, error)
}

// InMemoryUserRepository implements UserRepository for testing or prototyping.
type InMemoryUserRepository struct {
    // users stores data in memory to avoid database dependencies.
    users map[int]*User
}

// GetByID retrieves a user by ID from the in-memory map.
func (r *InMemoryUserRepository) GetByID(id int) (*User, error) {
    // Return a copy to prevent callers from mutating internal state.
    // The repository owns the map; callers get a snapshot.
    if u, ok := r.users[id]; ok {
        return &User{ID: u.ID, Name: u.Name}, nil
    }
    return nil, fmt.Errorf("user not found")
}

How implicit implementation works

Go doesn't have an implements keyword. A struct satisfies an interface simply by having the same methods. This is called structural typing. It means you can implement an interface in any package without importing the package that defines the interface. This reduces coupling. If you add a method to the interface and forget to implement it in a struct, the compiler rejects the program with does not implement UserRepository (missing GetByID method). This error happens at the call site, not the definition site, which forces you to fix the implementation where it's used.

The implicit nature also means you can create small, focused interfaces. You don't need to define a giant interface with twenty methods if your function only needs one. You can define a Reader interface with just GetByID, and any struct with that method will satisfy it. This flexibility encourages small interfaces that are easy to mock and easy to implement.

Realistic database example

In a real application, the repository wraps database calls and returns domain types. It handles errors so the caller doesn't see database-specific details. Functions that interact with I/O should take a context.Context as the first parameter. The convention is to name it ctx. This allows cancellation and deadlines to propagate through the call chain.

Here's a database-backed repository that respects context and wraps errors.

// PostgresUserRepository implements UserRepository using database/sql.
type PostgresUserRepository struct {
    // db provides the connection pool for PostgreSQL queries.
    db *sql.DB
}

// GetByID fetches a user from the database and returns a domain User.
func (r *PostgresUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
    // Use context to respect cancellation and deadlines.
    // QueryRowContext returns an error if the context is cancelled.
    var u User
    err := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id).Scan(&u.ID, &u.Name)
    if err != nil {
        // Wrap the error with context. The %w verb allows unwrapping later.
        // The caller sees a clean error message, not a driver-specific panic.
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return &u, nil
}

The repository returns a *User, not a *sql.Rows or a driver-specific struct. This keeps the database implementation hidden. The caller works with plain Go types. If you switch from PostgreSQL to MySQL, the caller doesn't change. Only the repository implementation changes.

Method receivers should be short, usually one or two letters matching the type. Use (r *PostgresUserRepository) not (this *PostgresUserRepository). This keeps the code readable and follows community style. The receiver name is a convention, not a language rule, but sticking to it makes code easier to scan.

Wiring the dependency

You define the interface and the implementation. Now you need to connect them. Go doesn't have a framework to do this automatically. You wire it up manually. This is often called dependency injection. You create the repository in your main function or a setup function, then pass it to the handler or service that needs it.

Here's how you wire a repository into an HTTP handler.

// NewHandler creates an HTTP handler with the repository dependency.
// The handler captures the repository closure.
// It calls the interface, never the concrete struct.
func NewHandler(repo UserRepository) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Pass the request context to the repository.
        // This allows the handler to cancel the query if the client disconnects.
        user, err := repo.GetByID(r.Context(), 1)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "User: %s", user.Name)
    })
}

The handler depends on UserRepository, not PostgresUserRepository. You can pass InMemoryUserRepository for tests. You can pass PostgresUserRepository for production. The handler code doesn't change. This makes testing trivial. You create a mock repository, set up some data, and call the handler. No database required.

Pitfalls and common errors

The repository pattern is simple, but it's easy to misuse. A common mistake is defining the interface in the same package as the implementation. If the interface lives in the database package, your business logic must import the database package to use the interface. That creates a dependency on the database. The interface should live in the package that uses it. This reverses the dependency. The database package depends on the interface, not the other way around.

Another issue is returning database types. If your repository returns sql.NullString or a driver-specific struct, the caller has to know about the database. Always return plain Go types like string or int. Convert database types inside the repository. The repository is the adapter. It translates between the database world and the domain world.

Interface bloat is a subtle problem. If you keep adding methods to the repository interface, it becomes hard to mock. A repository with twenty methods requires a mock with twenty methods. Tests become tedious. If an interface grows too large, split it. Create a UserReader interface for reads and a UserWriter interface for writes. Functions that only read can accept the smaller interface. This follows the interface segregation principle. Small interfaces are easier to use and easier to test.

If you try to pass a struct that doesn't match the interface, the compiler catches it. You'll see cannot use repo (variable of type *PostgresUserRepository) as UserRepository value in argument: *PostgresUserRepository does not implement UserRepository (wrong type for method GetByID). This happens if the method signature differs even slightly, like missing the context parameter or returning a different error type. The compiler enforces the contract strictly. Trust the error messages. They tell you exactly what's wrong.

Don't over-engineer. A repository is useful when you have multiple implementations or need to mock for tests. If you have a single script that reads a CSV and prints it, a repository adds boilerplate without value. Abstraction costs code. Pay the price only when you need flexibility.

When to use a repository

Use a repository when you need to swap data sources, such as moving from PostgreSQL to a mock for tests. Use a repository when your business logic depends on data access but shouldn't know how the data is stored. Use a direct database call when you have a simple script with no need for abstraction or testing. Use a repository when you want to centralize query logic so the same SQL isn't duplicated across handlers. Use a repository when you need to add caching or retry logic without changing the callers.

The repository is a boundary. Keep the kitchen out of the dining room.

Where to go next