Skip to content
Go

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.

GoBackend.dev9 min read
GoInterfacesTesting

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 OrderService embeds a *sql.DB directly, 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 *PostgresUserRepository instead of a UserRepository interface 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:

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.

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.