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

  1. Why is embedding interfaces without clear intent problematic?
  2. What principle do overly broad interfaces violate?
  3. Why is exporting an interface with one implementation redundant?
  4. What’s the main issue with using interface{}?
  5. What happens when a function returns an interface instead of a concrete type?

Answers

  1. Bloats interfaces, reduces flexibility
  2. Interface Segregation Principle
  3. Adds complexity, no polymorphism
  4. Lacks type safety
  5. Limits caller access