The tangled handler problem
You're building a service that manages user accounts. Your HTTP handler calls db.Query directly to fetch a user. Then you need to add caching. Then you need to write tests without spinning up a database. Suddenly your handler is a mess of SQL strings, mock setup, cache logic, and business rules all tangled together. You want to swap the database for Redis or a mock without touching the handler code.
The repository pattern solves this by isolating data access behind a clean interface. Your business logic talks to an abstraction, not a database driver. You define what operations are available, and the implementation handles the storage details. This keeps your domain pure and makes testing straightforward.
The waiter analogy and Go's structural interfaces
Think of a repository like a waiter in a restaurant. You don't walk into the kitchen to cook your own meal. You tell the waiter what you want, and the waiter handles the interaction with the kitchen. The waiter is the repository. Your code is the customer. The database is the kitchen. You define what the waiter can do through a menu, and the waiter figures out how to get the food.
In Go, interfaces are structural. You don't declare that a struct implements an interface. The compiler checks if the struct has the methods defined in the interface. If the method signatures match, the struct satisfies the interface. This implicit satisfaction means you can define the interface in your domain package and the implementation in a separate repository package without circular dependencies. The repository package imports the domain package, but the domain package never imports the repository.
Interfaces are contracts. Keep them small and focused on the domain.
Minimal repository skeleton
Here's the skeleton. Define the interface in your domain package, then build a struct that implements it. The interface captures the operations your business logic needs. The struct handles the database interaction.
// domain/user.go
// UserRepository defines the contract for user data operations.
type UserRepository interface {
// GetByID retrieves a user by their unique identifier.
GetByID(id int) (*User, error)
// Save persists a user to the underlying storage.
Save(u *User) error
}
// repo/sql_user_repo.go
// SQLUserRepo implements UserRepository using a SQL database.
type SQLUserRepo struct {
db *sql.DB
}
// GetByID fetches a user from the database by ID.
func (r *SQLUserRepo) GetByID(id int) (*User, error) {
var u User
// Scan maps columns directly to struct fields.
err := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id).Scan(&u.ID, &u.Name)
if err != nil {
return nil, err
}
return &u, nil
}
// Save inserts a new user into the database.
func (r *SQLUserRepo) Save(u *User) error {
_, err := r.db.Exec("INSERT INTO users (name) VALUES (?)", u.Name)
return err
}
Implicit satisfaction keeps packages decoupled. The domain package defines the shape; the repo package fills it.
How the compiler checks the contract
The compiler verifies interface satisfaction by comparing method sets. If SQLUserRepo has GetByID and Save with signatures matching UserRepository, the compiler allows the assignment. You don't write implements. This design prevents boilerplate and makes refactoring safer. If you add a method to the interface, the compiler rejects any struct that doesn't implement it.
If you rename a method in the struct but forget to update the interface, or vice versa, the compiler catches the mismatch immediately. You'll see an error like cannot use repo (variable of type *SQLUserRepo) as domain.UserRepository value in argument: *SQLUserRepo does not implement domain.UserRepository (missing method Save). This error message tells you exactly which method is missing.
The compiler is your contract enforcer. Trust the error messages.
Wiring it up in a real handler
In a real application, you wire the dependency at startup. The handler depends on the interface, not the concrete repository. This follows the "accept interfaces, return structs" convention. The handler accepts a UserRepository interface. The repository returns a *User struct. This direction of dependency keeps the domain layer free of infrastructure concerns.
Real repositories also accept context.Context as the first parameter. This allows callers to cancel long-running queries or enforce deadlines. The context flows through every layer.
// domain/user.go
// UserRepository defines the contract for user data operations.
type UserRepository interface {
// GetByID retrieves a user by their unique identifier.
GetByID(ctx context.Context, id int) (*User, error)
// Save persists a user to the underlying storage.
Save(ctx context.Context, u *User) error
}
// handler/user_handler.go
// UserHandler handles HTTP requests for user operations.
type UserHandler struct {
repo domain.UserRepository
}
// NewUserHandler creates a handler with the provided repository.
func NewUserHandler(repo domain.UserRepository) *UserHandler {
return &UserHandler{repo: repo}
}
// GetHandler returns a user by ID.
func (h *UserHandler) GetHandler(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// The handler calls the interface method with context.
user, err := h.repo.GetByID(r.Context(), id)
if err != nil {
http.Error(w, "user not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
Dependency injection flows down. Handlers depend on abstractions, never on concrete implementations.
Testing with a mock implementation
The real power of the repository pattern appears in tests. You can swap the SQL repository for a mock that uses an in-memory map. The handler code doesn't change. Tests run fast and deterministically without a database connection.
The mock struct implements the same interface. It stores data in a map and returns errors on demand. This lets you test edge cases like "user not found" or "save failed" without touching the database.
// test/mock_user_repo.go
// MockUserRepo implements UserRepository for testing.
type MockUserRepo struct {
Users map[int]*domain.User
}
// GetByID returns a user from the in-memory map.
func (m *MockUserRepo) GetByID(ctx context.Context, id int) (*domain.User, error) {
u, ok := m.Users[id]
if !ok {
return nil, fmt.Errorf("user %d not found", id)
}
return u, nil
}
// Save adds a user to the in-memory map.
func (m *MockUserRepo) Save(ctx context.Context, u *domain.User) error {
m.Users[u.ID] = u
return nil
}
Mocks make tests fast and deterministic. You control the data and the errors.
Pitfalls and compiler errors
Leaking infrastructure types into the interface breaks the abstraction. If UserRepository returns *sql.Rows or sql.NullString, you've tied the domain to the database driver. The interface should only return domain types like *User. If you need to represent nullable data, use a domain type like *string or a custom type, not sql.NullString.
Another pitfall is creating god interfaces. If your repository interface has twenty methods, it's doing too much. Split it into smaller interfaces based on cohesion. A UserRepository might handle user CRUD, while a UserRoleRepository handles role assignments. Small interfaces are easier to implement and mock.
The if err != nil boilerplate is verbose by design. The Go community accepts this pattern because it makes the unhappy path visible. Don't swallow errors in the repository. Return them to the caller. The caller decides whether to log, retry, or propagate the error.
Leak infrastructure types and you lose the abstraction. Keep the interface pure.
When to use a repository
Use a repository interface when you need to swap data sources without changing business logic. Use a repository interface when you want to unit test handlers with mock implementations. Use direct database calls when the application is small and the data access layer won't change. Use a service layer with repositories when business rules involve multiple data sources or complex transactions. Use plain structs without interfaces when you are building a script or a tool with a single dependency.
Interfaces cost maintenance. Pay the tax only when you need the flexibility.