How to Implement the 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 interact with your data source.

The data boundary

You are building a service that manages users. You start with database/sql. The code works. You query the database, return the rows, and move on. Then requirements shift. You need to add a Redis cache layer. You need to write unit tests that don't touch a real database. You need to swap the PostgreSQL backend for a mock during CI runs.

Suddenly your service layer is tangled with SQL strings, connection pools, and driver-specific details. Changing the data source means rewriting the business logic. The coupling is too tight. You want a boundary where the service asks for data without knowing where it comes from. That boundary is the repository pattern. Go implements this pattern without frameworks, inheritance, or keywords. It uses interfaces and composition.

What a repository actually is

A repository is a collection of objects in memory that acts as a fake database for your business logic. It hides the details of storage. Your service talks to the repository. The repository talks to the database, the cache, or the file system. The service never sees SQL. It never sees a connection string. It sees a clean API that returns domain objects.

Think of a universal power adapter. Your device plugs into the adapter and gets power. The device does not care if the adapter is connected to a wall outlet, a battery pack, or a solar charger. The adapter handles the messy details of the power source. The repository is that adapter for your data. Your service plugs into the repository interface. The concrete implementation handles the storage.

Go interfaces make this pattern lightweight. You do not declare that a struct implements an interface. You just write the methods. If a struct has all the methods listed in the interface, it implements the interface. The compiler checks the contract at compile time. There is no implements keyword. There is no abstract class. Just methods and structs.

The minimal interface

Here is the skeleton. An interface defines the operations the service needs. A struct implements those operations using a database. The constructor returns the interface type, so the caller can treat the struct as an interface.

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

// UserRepository defines the data operations the service needs.
// The interface is small because Go favors composition over inheritance.
type UserRepository interface {
	GetByID(ctx context.Context, id int) (*User, error)
	Save(ctx context.Context, user *User) error
}

// sqlUserRepository implements UserRepository using database/sql.
// The type is unexported because callers should only interact via the interface.
type sqlUserRepository struct {
	db *sql.DB
}

// NewSQLUserRepository creates a repository backed by a SQL database.
// The return type is the interface, not the concrete struct.
func NewSQLUserRepository(db *sql.DB) UserRepository {
	return &sqlUserRepository{db: db}
}

The interface lists two methods. GetByID fetches a user. Save persists a user. Both take context.Context as the first argument. This is a hard convention in Go. Functions that perform I/O or long-running work must accept a context. The context carries cancellation signals, deadlines, and request-scoped values. If you skip context, you break the cancellation chain. The receiver name for the struct methods is r. One or two letters matching the type is the community standard. Never use self or this.

The constructor NewSQLUserRepository returns UserRepository. This follows the mantra "accept interfaces, return structs." The function returns a concrete pointer internally, but the signature promises an interface. The caller receives the interface. The implementation details are hidden. You can change the return type to a different implementation later without touching the caller.

Wiring the service layer

The service layer accepts the repository interface. It never imports database/sql. It never constructs the repository. It receives the repository via dependency injection. This keeps the service testable and decoupled.

// UserService contains business logic.
// It accepts the interface, so it never touches sql.DB directly.
type UserService struct {
	repo UserRepository
}

// NewUserService wires up the service with its dependencies.
func NewUserService(repo UserRepository) *UserService {
	return &UserService{repo: repo}
}

// GetUser fetches a user and applies business rules.
func (s *UserService) GetUser(ctx context.Context, id int) (*User, error) {
	// Pass context through to the repository for cancellation support.
	user, err := s.repo.GetByID(ctx, id)
	if err != nil {
		// Wrap the error to add context for the caller.
		return nil, fmt.Errorf("get user %d: %w", id, err)
	}
	return user, nil
}

The service struct holds a field of type UserRepository. The constructor takes UserRepository as an argument. The method GetUser calls s.repo.GetByID. It passes the context through. It checks the error. It wraps the error with fmt.Errorf and %w. Error wrapping is the Go way to preserve the error chain while adding context. The receiver is s. Short, descriptive, consistent.

The boilerplate if err != nil is verbose by design. The community accepts the repetition because it makes the unhappy path visible. You cannot accidentally ignore an error. The compiler forces you to handle it. This verbosity prevents silent failures in production.

Testing with a mock

Because the service depends on an interface, you can swap the database for a mock during tests. You write a struct that implements UserRepository but uses in-memory storage. No database connection required.

// mockUserRepository implements UserRepository for tests.
type mockUserRepository struct {
	users map[int]*User
}

// GetByID returns a user from the in-memory map.
func (m *mockUserRepository) GetByID(ctx context.Context, id int) (*User, error) {
	// Check context for cancellation even in mocks to match real behavior.
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}
	user, ok := m.users[id]
	if !ok {
		return nil, fmt.Errorf("user %d not found", id)
	}
	return user, nil
}

// Save adds a user to the in-memory map.
func (m *mockUserRepository) Save(ctx context.Context, user *User) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	m.users[user.ID] = user
	return nil
}

The mock struct has a map of users. The methods check ctx.Done() to simulate cancellation. This ensures the service handles cancellation correctly even when the underlying storage is fake. The mock returns errors that match the shape of real errors. The test can verify that the service wraps errors, handles missing users, and respects deadlines.

Public names start with a capital letter. Private names start with a lowercase letter. UserRepository is public because other packages use it. sqlUserRepository and mockUserRepository are private because they are implementation details. Go has no public or private keywords. Capitalization is the access control mechanism.

Pitfalls and compiler traps

The repository pattern is simple, but Go has specific traps.

If you forget to implement a method, the compiler rejects the program. You get an error like cannot use &sqlUserRepository{...} as UserRepository value in return: *sqlUserRepository does not implement UserRepository (missing Save method). The message tells you exactly which method is missing. Fix the struct by adding the method signature.

Fat interfaces are a common anti-pattern. If your repository interface has twenty methods, it is too big. Go favors small interfaces. io.Reader has one method. io.Writer has one method. Split a large repository into smaller interfaces. UserReader with GetByID. UserWriter with Save. Services that only read can accept UserReader. This reduces coupling and makes mocking easier.

Context leaks happen when a repository starts a goroutine and ignores the context. If the caller cancels the request, the goroutine keeps running. Always pass context to goroutines and select on ctx.Done(). The worst goroutine bug is the one that never logs and never stops.

Returning concrete types from constructors breaks the pattern. If NewSQLUserRepository returns *sqlUserRepository, the caller is locked into that implementation. Change the return type to UserRepository. The caller gets flexibility. The implementation stays hidden.

When to use a repository

Not every project needs a repository. Over-engineering adds complexity without value. Use the pattern when it solves a real problem.

Use a repository interface when you need to swap data sources for testing or caching without changing business logic. Use direct database/sql calls when the project is small and the interface adds no value. Use a data access object pattern only when you are porting code from Java and need to match existing mental models, though Go idioms usually favor the simpler repository interface. Use an ORM like GORM when you need rapid prototyping and can accept the performance cost and hidden SQL complexity. Use plain functions instead of a repository struct when the operations are stateless and do not share a database connection.

Interfaces are contracts. Keep them small. Context is plumbing. Run it through every long-lived call site. The repository is a boundary. Keep SQL out of your service.

Where to go next