Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.
Interfaces in Go are a powerful feature, letting you define behavior without tying yourself to specific implementations.
But they’re easy to misuse, leading to code that’s harder to maintain or understand.
In this post, I’ll walk through five common Go interface mistakes, explain why they’re problematic, and show you how to fix them with runnable code examples.
Each section includes a table summarizing the issue and fix, plus a link to relevant documentation where it makes sense. Let’s dive in.
1. Embedding Interfaces Without Clear Intent
Why It’s a Problem
Embedding one interface into another can seem like a clean way to combine behaviors, but doing so without a clear purpose can bloat interfaces and make them harder to implement.
It can also lead to unintended dependencies between interfaces, reducing flexibility.
Bad Example
Here’s an interface that embeds another without clear justification:
package main
import "fmt"
type Reader interface {
Read(id string) (string, error)
}
type Writer interface {
Write(data string) error
}
// Embeds Reader unnecessarily
type ReadWriter interface {
Reader
Write(data string) error
}
type FileStore struct{}
func (fs FileStore) Read(id string) (string, error) {
return "data", nil
}
func (fs FileStore) Write(data string) error {
fmt.Println("Writing:", data)
return nil
}
func main() {
var store ReadWriter = FileStore{}
data, _ := store.Read("1")
store.Write(data)
fmt.Println("Read:", data)
}
Issue: Embedding Reader
in ReadWriter
forces implementers to provide Read
, even if the consumer only needs Write
.
This bloats the interface and reduces its reusability.
Better Approach
Define interfaces independently and combine them only when necessary, or use composition explicitly in the consumer.
package main
import "fmt"
type Reader interface {
Read(id string) (string, error)
}
type Writer interface {
Write(data string) error
}
type FileStore struct{}
func (fs FileStore) Read(id string) (string, error) {
return "data", nil
}
func (fs FileStore) Write(data string) error {
fmt.Println("Writing:", data)
return nil
}
// Consumer uses only what it needs
func ProcessData(w Writer, r Reader) {
data, _ := r.Read("1")
w.Write(data)
fmt.Println("Read:", data)
}
func main() {
store := FileStore{}
ProcessData(store, store)
}
Why It’s Better: Keeping Reader
and Writer
separate allows consumers to use only the interfaces they need. ProcessData
can accept any type that satisfies Writer
or Reader
, improving flexibility.
Summary
Aspect | Bad Practice | Better Practice |
---|---|---|
Interface Design | Embeds interfaces unnecessarily | Defines independent interfaces |
Flexibility | Forces unneeded methods | Allows selective use of interfaces |
Clarity | Obscures intent of interface | Clear purpose for each interface |
Further Reading: Effective Go - Interface Embedding
2. Creating Overly Broad Interfaces
Why It’s a Problem
Interfaces with too many methods are hard to implement and harder to test.
They violate the Interface Segregation Principle, forcing implementers to provide methods they don’t need.
Bad Example
Here’s an interface with too many responsibilities:
package main
import "fmt"
type Item struct {
ID string
Name string
}
type Repository interface {
Create(item Item) error
Read(id string) (Item, error)
Update(item Item) error
Delete(id string) error
List() ([]Item, error)
}
type InMemoryRepo struct {
items map[string]Item
}
func (r *InMemoryRepo) Create(item Item) error {
r.items[item.ID] = item
return nil
}
func (r *InMemoryRepo) Read(id string) (Item, error) {
item, ok := r.items[id]
if !ok {
return Item{}, fmt.Errorf("item not found")
}
return item, nil
}
// ... other methods (Update, Delete, List) ...
func main() {
repo := &InMemoryRepo{items: make(map[string]Item)}
repo.Create(Item{ID: "1", Name: "Item1"})
item, _ := repo.Read("1")
fmt.Println(item)
}
Issue: If a function only needs to read items, the Repository
interface forces you to implement all methods, even unused ones like Delete
or List
.
Better Approach
Split the interface into smaller, focused interfaces that serve specific purposes.
package main
import "fmt"
type Item struct {
ID string
Name string
}
type Creator interface {
Create(item Item) error
}
type Reader interface {
Read(id string) (Item, error)
}
// Combine interfaces if needed
type ReadWriter interface {
Creator
Reader
}
type InMemoryRepo struct {
items map[string]Item
}
func (r *InMemoryRepo) Create(item Item) error {
r.items[item.ID] = item
return nil
}
func (r *InMemoryRepo) Read(id string) (Item, error) {
item, ok := r.items[id]
if !ok {
return Item{}, fmt.Errorf("item not found")
}
return item, nil
}
func ProcessItem(c Creator, r Reader) {
c.Create(Item{ID: "1", Name: "Item1"})
item, _ := r.Read("1")
fmt.Println(item)
}
func main() {
repo := &InMemoryRepo{items: make(map[string]Item)}
ProcessItem(repo, repo)
}
Why It’s Better: Smaller interfaces (Creator
, Reader
) are easier to implement and test.
You can combine them (ReadWriter
) when needed.
Summary
Aspect | Bad Practice | Better Practice |
---|---|---|
Interface Size | Many methods, hard to implement | Few methods, focused purpose |
Testability | Requires mocking all methods | Mock only what’s needed |
Flexibility | Forces unneeded methods | Allows selective implementation |
Further Reading: Effective Go - Interfaces
3. Exporting Interfaces with One Implementation
Why It’s a Problem
Exporting an interface with only one implementation is often redundant. It adds complexity without providing abstraction benefits, as there’s no polymorphism.
Bad Example
Here’s an exported interface with a single implementation:
package main
import "fmt"
// Exported interface
type Logger interface {
Log(message string)
}
// Only one implementation
type ConsoleLogger struct{}
func (cl ConsoleLogger) Log(message string) {
fmt.Println(message)
}
func main() {
var logger Logger = ConsoleLogger{}
logger.Log("Hello, world!")
}
Issue: Since ConsoleLogger
is the only implementation, the Logger
interface doesn’t add value. It just obscures the concrete type.
Better Approach
Use the concrete type directly unless you expect multiple implementations.
package main
import "fmt"
type ConsoleLogger struct{}
func (cl ConsoleLogger) Log(message string) {
fmt.Println(message)
}
func ProcessLog(l ConsoleLogger, message string) {
l.Log(message)
}
func main() {
logger := ConsoleLogger{}
ProcessLog(logger, "Hello, world!")
}
Why It’s Better: Skipping the interface reduces code and makes the type’s behavior clear. If you need an interface later (e.g., for testing), define it where it’s consumed.
Summary
Aspect | Bad Practice | Better Practice |
---|---|---|
Interface Usage | Exported for single implementation | Use concrete type directly |
Code Clarity | Obscures implementation | Clear and direct |
Maintenance | Extra layer to update | Simpler to modify |
4. Overusing Empty Interfaces
Why It’s a Problem
Using interface{}
to accept any type sacrifices type safety, leading to runtime errors and unclear code. It’s a last resort, not a default choice.
Bad Example
Here’s a function using an empty interface:
package main
import "fmt"
func PrintValue(v interface{}) {
fmt.Println(v)
}
func main() {
PrintValue("Hello")
PrintValue(42)
PrintValue([]int{1, 2, 3})
}
Issue: While flexible, PrintValue
can’t enforce type constraints, and you might need type assertions for specific operations, risking panics.
Better Approach
Use generics (introduced in Go 1.18) for type-safe flexibility.
package main
import "fmt"
func PrintValue[T any](v T) {
fmt.Println(v)
}
func main() {
PrintValue("Hello")
PrintValue(42)
PrintValue([]int{1, 2, 3})
}
Why It’s Better: Generics provide the flexibility of interface{}
with compile-time type checking, reducing runtime errors.
Summary
Aspect | Bad Practice | Better Practice |
---|---|---|
Type Safety | None, relies on assertions | Compile-time checks with generics |
Code Clarity | Unclear what types are expected | Explicit type constraints |
Error Handling | Runtime panics possible | Errors caught at compile time |
Further Reading: Go Generics Documentation
5. Returning Interfaces Instead of Concrete Types
Why It’s a Problem
Returning an interface from a function hides the concrete type, limiting what callers can do with the result. It also makes the API less predictable.
Bad Example
Here’s a function returning an interface:
package main
import "fmt"
type Service interface {
Start() string
}
type serviceImpl struct{}
func (s *serviceImpl) Start() string {
return "Service started"
}
func (s *serviceImpl) Stop() string {
return "Service stopped"
}
func NewService() Service {
return &serviceImpl{}
}
func main() {
svc := NewService()
fmt.Println(svc.Start())
// svc.Stop() // Error: Service has no Stop method
}
Issue: The caller can’t call Stop
because it’s not part of the Service
interface, even though the underlying type supports it.
Better Approach
Return the concrete type and let consumers define interfaces if needed.
package main
import "fmt"
type serviceImpl struct{}
func (s *serviceImpl) Start() string {
return "Service started"
}
func (s *serviceImpl) Stop() string {
return "Service stopped"
}
func NewService() *serviceImpl {
return &serviceImpl{}
}
type Starter interface {
Start() string
}
func StartService(s Starter) {
fmt.Println(s.Start())
}
func main() {
svc := NewService()
StartService(svc)
fmt.Println(svc.Stop())
}
Why It’s Better: Returning *serviceImpl
gives callers full access to its methods. Consumers can define interfaces like Starter
for specific needs.
Summary
Aspect | Bad Practice | Better Practice |
---|---|---|
Return Type | Interface limits access | Concrete type provides full access |
Flexibility | Restricts caller’s options | Allows callers to define interfaces |
API Clarity | Obscures implementation details | Clear what’s returned |
Final Thoughts
Go interfaces are a lightweight way to define behavior, but they’re easy to overuse or misuse.
By avoiding unnecessary embedding, keeping interfaces small, avoiding unnecessary exports, using generics over empty interfaces, and returning concrete types, you’ll write clearer, more maintainable Go code.
Got a favorite Go interface tip or a mistake you’ve made? Drop it in the comments—I’d love to hear about it.
A Quiz To Help You Recap
Questions
- Why is embedding interfaces without clear intent problematic?
- What principle do overly broad interfaces violate?
- Why is exporting an interface with one implementation redundant?
- What’s the main issue with using
interface{}
? - What happens when a function returns an interface instead of a concrete type?
Answers
- Bloats interfaces, reduces flexibility
- Interface Segregation Principle
- Adds complexity, no polymorphism
- Lacks type safety
- Limits caller access