Go's interfaces are implicit and structural — a type satisfies an interface simply by implementing its methods, with no declaration required. This is one of Go's most powerful features, and also one of its most misused.
The standard library sets the example: io.Reader has one method. io.Writer has one method. io.Closer has one method. You compose them when you need more.
A good interface describes a single capability, not a whole object.
// ✅ Focused — each interface has one job
type UserFinder interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type UserSaver interface {
Save(ctx context.Context, user *User) error
}
// Compose when you need both
type UserRepository interface {
UserFinder
UserSaver
}// ❌ Bloated — a new concrete type must implement 10 methods just to satisfy this
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
FindAll(ctx context.Context, filter UserFilter) ([]*User, error)
Save(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
Count(ctx context.Context) (int, error)
Exists(ctx context.Context, id string) (bool, error)
FindByOrg(ctx context.Context, orgID string) ([]*User, error)
Archive(ctx context.Context, id string) error
}When a test only needs FindByID, it still has to implement all 10 methods. When a new developer wants to understand what a function needs, they have to read a 10-method interface instead of a 1-method one.
This is the most important and most violated rule in Go interface design.
// ✅ The HTTP handler defines the interface it needs — in its own package
// internal/users/adapters/http/handler.go
package http
type userService interface {
Register(ctx context.Context, email, name string) (*domain.User, error)
GetByID(ctx context.Context, id string) (*domain.User, error)
}
type Handler struct {
svc userService
}// ❌ The interface is defined in the implementation package
// internal/users/adapters/postgres/repository.go
package postgres
// Why does the postgres package define this? It forces the http handler
// to import the postgres package just to get the interface type.
type UserRepository interface { ... }When the interface lives at the consumer, you can have:
- Different consumers with different interface shapes (a handler that only needs
FindByID, a background job that only needsSave). - Zero coupling between packages — the postgres package has no idea the http package exists.
- Easy mocking in tests — generate a mock for the interface your package actually needs.
Functions that accept interfaces are flexible — any type satisfying the interface can be passed. Functions that return concrete structs are predictable — the caller gets full access to all methods and fields without needing a type assertion.
// ✅ Accept interface, return struct
func NewUserService(repo UserRepository, cache UserCache) *UserService {
return &UserService{repo: repo, cache: cache}
}// ❌ Returning an interface forces callers to type-assert to access
// methods not on the interface, and hides what they actually get
func NewUserService(repo UserRepository) UserService {
return &userServiceImpl{...}
}Exception: returning an interface is appropriate when you deliberately want to hide the concrete type (e.g., a factory that can return different implementations). This should be rare.
Because you define interfaces at the consumer, every dependency can be replaced with a mock in tests — without any changes to the production code.
// The domain service depends on an interface
type Service struct {
repo UserRepository
}
// In tests, pass an in-memory implementation
func TestService_Register_EmailAlreadyTaken(t *testing.T) {
repo := memory.NewUserRepository()
repo.Seed(&domain.User{Email: "existing@example.com"})
svc := domain.NewService(repo)
_, err := svc.Register(ctx, "existing@example.com", "Alice")
assert.ErrorIs(t, err, domain.ErrEmailTaken)
}No mocking framework needed for domain tests — just pass the in-memory adapter.
Use interface embedding to compose larger interfaces from smaller ones:
type Reader interface {
Read(ctx context.Context, id string) (*User, error)
}
type Writer interface {
Save(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
// ReadWriter is only used where both capabilities are needed
type ReadWriter interface {
Reader
Writer
}Functions that only need reading accept Reader. Functions that need both accept ReadWriter. This makes dependencies explicit and minimal.
Interfaces add indirection. Don't create one unless you have a clear reason:
| Good reason for an interface | Not a good reason |
|---|---|
| You have (or expect) multiple implementations | "It might be useful someday" |
| You need to mock it in tests | It's a concrete type with one implementation that won't change |
| You want to decouple two packages | You're following a pattern from Java/C# where everything is an interface |
| You want to expose a subset of a type's capabilities | The type is already simple |
A UserService struct with no alternative implementations does not need a UserService interface — until your HTTP handler needs to be tested without a real database, at which point you define a narrow interface at the handler.
// internal/users/domain/service.go
type UserService interface { // Only one implementation exists and will ever exist
Register(...)
GetByID(...)
}
type userServiceImpl struct { ... }
func (s *userServiceImpl) Register(...) { ... }You've added an interface for no reason. This pattern comes from Java habits. In Go, define the interface where it's consumed.
type Config interface { GetDatabaseURL() string }
type Logger interface { Log(msg string) }
type Clock interface { Now() time.Time }If these types have one implementation and you never mock them in tests, these interfaces provide no value. Add them when you need them, not speculatively.
// The function only uses FindByID, but now callers must satisfy all 10 methods
func DoSomething(repo UserRepository) error {
user, err := repo.FindByID(ctx, "123")
...
}Fix: Accept only what you need: func DoSomething(finder UserFinder) error.
func NewCache() CacheInterface {
return &redisCache{}
}Now the caller has to type-assert to access Redis-specific methods. If you want to hide the implementation, the package boundary already does that. Return the concrete *RedisCache and let the caller's interface (defined at the consumer) provide the abstraction.