Go Interfaces: Design Small, Testable Components
Design small, focused Go interfaces for dependency injection, mocking, and testable backend code, with realistic service and repository examples.
TL;DR
Go interfaces work best when they're small, defined in the package that consumes them, and satisfied implicitly — narrow interfaces at real seams make a service layer testable with hand-written fakes instead of a database.
What You'll Learn
- Why Go interfaces should be defined next to the consumer, not the implementation
- How implicit, structural interface satisfaction works in Go
- Why narrow, single-purpose interfaces compose and test better than wide ones
- How to write hand-written fakes instead of relying on a mocking framework
- Common interface design mistakes, including premature interfaces and returning interfaces from constructors
Prerequisites
The problem
A service layer that talks directly to *sql.DB, an HTTP client, or a
third-party SDK is hard to test and hard to change. Every unit test that
exercises the service ends up needing a real database or a mocked network
call, and swapping PostgreSQL for a different store — or adding a caching
layer in front of it — means touching every place that constructed the
concrete type.
Go's interfaces solve this without a DI framework or code generation, but only if they're designed the way the language expects: small, defined where they're consumed, and satisfied implicitly.
Why it matters in production
- Untestable services slow everyone down. If
OrderServiceembeds a*sql.DBdirectly, testing its business logic means either standing up a real database or skipping the test. - Wide interfaces resist change. An interface with fifteen methods forces every fake and every alternate implementation to satisfy all fifteen, even when a caller only needs one.
- Concrete dependencies leak implementation details upward. A service
that accepts
*PostgresUserRepositoryinstead of aUserRepositoryinterface can never be backed by anything else — including a fake in a test — without changing the service's signature.
The fix is not "use interfaces everywhere." It's using narrow interfaces at the specific seams where substitution — for testing, or for a future implementation — actually matters.
Implicit implementation and interfaces at the point of use
Go interfaces are satisfied structurally: a type implements an interface by
having the right methods, with no implements keyword and no import
dependency from the implementation back to the interface. That property is
what makes it idiomatic to define the interface next to the code that
consumes it, not next to the type that implements it.
// package service
type UserRepository interface {
FindByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, u *User) error
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Register(ctx context.Context, email, passwordHash string) (*User, error) {
existing, err := s.repo.FindByEmail(ctx, email)
if err != nil && !errors.Is(err, ErrUserNotFound) {
return nil, fmt.Errorf("check existing user: %w", err)
}
if existing != nil {
return nil, ErrEmailTaken
}
u := &User{Email: email, PasswordHash: passwordHash}
if err := s.repo.Create(ctx, u); err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}UserRepository lives in the service package — the consumer — and lists
only the two methods UserService actually calls. The concrete repository
lives in a separate package and never imports service at all:
// package postgres
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*service.User, error) {
const query = `SELECT id, email, password_hash FROM users WHERE email = $1`
var u service.User
err := r.db.QueryRowContext(ctx, query, email).Scan(&u.ID, &u.Email, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return nil, service.ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("query user by email: %w", err)
}
return &u, nil
}
func (r *UserRepository) Create(ctx context.Context, u *service.User) error {
const query = `INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)`
_, err := r.db.ExecContext(ctx, query, u.ID, u.Email, u.PasswordHash)
if err != nil {
return fmt.Errorf("insert user: %w", err)
}
return nil
}postgres.UserRepository satisfies service.UserRepository automatically
because it has matching methods — there's no explicit declaration linking
the two. Wiring happens in main, the only place that needs to know both
concrete types exist:
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
repo := postgres.NewUserRepository(db)
userService := service.NewUserService(repo)This is the opposite of interfaces in Java or C#, where the interface is usually declared alongside — or above — the implementation and the implementation explicitly opts in. In Go, the consumer owns the interface and the implementation opts in by accident of having the right method set.
Why small interfaces compose better
A one- or two-method interface is trivial to implement, trivial to fake in a
test, and trivial to compose. The standard library's io.Reader and
io.Writer are the canonical examples: one method each, and half of
io, bufio, and net/http is built by combining them.
The same principle applies to application code. Instead of one broad
UserStore interface with a dozen methods used by five different callers,
prefer several narrow interfaces, each scoped to what one caller needs:
type EmailChecker interface {
FindByEmail(ctx context.Context, email string) (*User, error)
}
type UserCreator interface {
Create(ctx context.Context, u *User) error
}A caller that only needs to check for an existing email — say, a signup form
validator — can depend on EmailChecker alone, and a fake implementing it
needs to satisfy exactly one method. If a caller genuinely needs both
behaviors, as UserService does above, it can still depend on a combined
UserRepository interface; there's no requirement to fragment every
interface, only to avoid interfaces that are wider than any real caller
needs.
Mocking with hand-written fakes
Go doesn't need a mocking framework for this to work well. A hand-written fake implementing a narrow interface is usually less code than configuring a generated mock, and it's easier to read at the call site — the same approach used for testing Go backend applications without standing up real dependencies:
type fakeUserRepository struct {
users map[string]*service.User
}
func newFakeUserRepository() *fakeUserRepository {
return &fakeUserRepository{users: make(map[string]*service.User)}
}
func (f *fakeUserRepository) FindByEmail(_ context.Context, email string) (*service.User, error) {
u, ok := f.users[email]
if !ok {
return nil, service.ErrUserNotFound
}
return u, nil
}
func (f *fakeUserRepository) Create(_ context.Context, u *service.User) error {
f.users[u.Email] = u
return nil
}Because service.UserRepository only has two methods, this fake is under
twenty lines and gives tests full control over its behavior — including
error paths that would be awkward to trigger against a real database.
Production considerations
Accept interfaces, return concrete types. NewUserRepository returns
*postgres.UserRepository, not service.UserRepository. Callers that need
the interface get it for free through implicit satisfaction; callers that
need repository-specific methods (a migration helper, a metrics wrapper)
aren't blocked by an interface that hides them.
Don't add a method to an interface until a real caller needs it. Growing
UserRepository to include Delete, UpdateLastLogin, and ListByRole
because "the repository will probably need it eventually" widens the
interface for every existing fake and every existing implementation, for
speculative future callers.
Keep interfaces in the package that calls them, not in a shared
interfaces package. A dedicated package of interfaces divorced from
their callers tends to accumulate wide, aspirational interfaces nobody
actually depends on narrowly — the same consumer-owned boundary that makes
a Go API built with clean architecture
easy to test and change.
Common mistakes
- Defining the interface next to the implementation instead of the consumer — this is the most common carryover from other languages, and it tends to produce one wide interface per concrete type instead of several narrow ones scoped to actual callers.
- Interfaces with getters and setters instead of behavior — an interface
like
GetName() string; SetName(string)usually means a concrete struct would have worked fine; interfaces earn their keep by abstracting behavior, not by wrapping field access. - Premature interfaces. Introducing an interface for a dependency with exactly one implementation and no test double is pure indirection. Wait until there's a second implementation, or a real need to fake it in a test.
- Returning an interface from a constructor instead of the concrete type, which hides useful methods from callers that need them and provides no benefit — the caller already knows the concrete type at the call site.
Testing
The fake above makes UserService.Register fully testable without a
database, including the duplicate-email path that's awkward to set up
against real Postgres:
func TestUserService_Register(t *testing.T) {
tests := []struct {
name string
seedEmail string
email string
wantErr error
}{
{name: "new email succeeds", email: "new@example.com"},
{name: "duplicate email rejected", seedEmail: "taken@example.com", email: "taken@example.com", wantErr: service.ErrEmailTaken},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newFakeUserRepository()
if tt.seedEmail != "" {
repo.users[tt.seedEmail] = &service.User{Email: tt.seedEmail}
}
svc := service.NewUserService(repo)
_, err := svc.Register(context.Background(), tt.email, "hashed")
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Register() error = %v, want %v", err, tt.wantErr)
}
})
}
}Because UserService depends on the UserRepository interface rather than
*postgres.UserRepository, this test runs in milliseconds with no database,
network, or test container involved.
Summary
Go interfaces work best when they're small, defined in the package that consumes them, and satisfied implicitly by whatever concrete type happens to have the right methods. Designing for this from the start — narrow interfaces at real seams, concrete types everywhere else — is what makes a service layer testable with hand-written fakes instead of a database, and adaptable to a new implementation without touching its callers.
Interview Questions
Key Takeaways
- Define the interface in the consuming package, not beside its implementation
- Keep interfaces narrow and scoped to what one real caller needs, rather than one wide interface per concrete type
- Accept interfaces as parameters, but return concrete types from constructors
- Don't add a method to an interface until a real caller needs it
- Avoid premature interfaces for a dependency with exactly one implementation and no test double
Go Fundamentals Series
Article 7 of 13
- 1.Go Modules and Packages: Setting Up and Structuring a Go Project
- 2.Slices, Arrays, and Maps in Go: How They Actually Work
- 3.Pointers in Go: When to Use *T and Why
- 4.Structs, Methods, and Composition in Go
- 5.Error Handling in Go: Custom Errors, Wrapping, and errors.Is/As
- 6.Defer, Panic, and Recover in Go
- 7.Go Interfaces: Design Small, Testable Components
- 8.Goroutines and Channels in Go: A Fresher-Friendly Guide
- 9.Understanding HTTP Servers in Go: From TCP Listen to Your First Handler
- 10.sync.WaitGroup, sync.Mutex, and the Race Detector in Go
- 11.Context in Go: Cancellation, Deadlines and Request Propagation
- 12.Building Background Workers in Go with Goroutines and Channels
- 13.Graceful Shutdown in Go HTTP Servers
funcRelated()[]Article
Structs, Methods, and Composition in Go
How to define your own types with structs, attach behavior with methods, and build up functionality through embedding instead of inheritance — with diagrams and real examples.
Testing Go Backend Applications: Unit, Integration and HTTP Tests
A practical testing strategy for Go backend services: table-driven unit tests, httptest for handlers, mocks for services, and integration tests against a real PostgreSQL database.
Building a Go API with Clean Architecture Without Overengineering
A practical take on Clean Architecture in Go: handler/service/repository separation, dependency direction, and knowing when to stop adding abstractions.