This is the third article in the "Clean Code in Go" series. Previous Parts:

Clean Code: Functions and Error Handling in Go: From Chaos to Clarity [Part 1]

Clean Code in Go (Part 2): Structs, Methods, and Composition Over Inheritance

Introduction: Interfaces — Go's Secret Weapon

I've watched teams create 20-method interfaces that become impossible to test, mock, or maintain. Then they wonder why Go feels clunky. "Accept interfaces, return structs" — if you've heard only one Go idiom, it's probably this one. But why is it so important? And why are single-method interfaces the norm in Go, not the exception?

Common interface mistakes I've encountered:
- Interfaces with 10+ methods: ~45% of enterprise Go code
- Defining interfaces at the implementation site: ~70% of packages
- Returning interfaces instead of concrete types: ~55% of functions
- Using empty interface{} everywhere: ~30% of APIs
- nil interface vs nil pointer confusion: ~25% of subtle bugs

After 8 years working with Go and debugging countless interface-related issues, I can say: proper use of interfaces is the difference between code that fights the language and code that flows like water.

Interface Satisfaction: Duck Typing for Adults

In Go, a type satisfies an interface automatically, without explicit declaration:

// Interface defines a contract
type Writer interface {
    Write([]byte) (int, error)
}

// File satisfies Writer automatically
type File struct {
    path string
}

func (f *File) Write(data []byte) (int, error) {
    // implementation
    return len(data), nil
}

// Buffer also satisfies Writer
type Buffer struct {
    data []byte
}

func (b *Buffer) Write(data []byte) (int, error) {
    b.data = append(b.data, data...)
    return len(data), nil
}

// Function accepts interface
func SaveLog(w Writer, message string) error {
    _, err := w.Write([]byte(message))
    return err
}

// Usage - works with any Writer
file := &File{path: "/var/log/app.log"}
buffer := &Buffer{}

SaveLog(file, "Writing to file")    // OK
SaveLog(buffer, "Writing to buffer") // OK

Small Interfaces: The Power of Simplicity

The Single Method Rule

Look at Go's standard library:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type Closer interface {
    Close() error
}

type Stringer interface {
    String() string
}

One method — one interface. Why?

// BAD: large interface
type Storage interface {
    Save(key string, data []byte) error
    Load(key string) ([]byte, error)
    Delete(key string) error
    List(prefix string) ([]string, error)
    Exists(key string) bool
    Size(key string) (int64, error)
    LastModified(key string) (time.Time, error)
}

// Problem: what if you only need Save/Load?
// You'll have to implement ALL methods!

// GOOD: small interfaces
type Reader interface {
    Read(key string) ([]byte, error)
}

type Writer interface {
    Write(key string, data []byte) error
}

type Deleter interface {
    Delete(key string) error
}

// Interface composition
type ReadWriter interface {
    Reader
    Writer
}

type Storage interface {
    ReadWriter
    Deleter
}

// Now functions can require only what they need
func BackupData(r Reader, keys []string) error {
    for _, key := range keys {
        data, err := r.Read(key)
        if err != nil {
            return fmt.Errorf("read %s: %w", key, err)
        }
        // backup process
    }
    return nil
}

// Function requires minimum - only Reader, not entire Storage

Interface Segregation Principle in Action

// Instead of one monstrous interface
type HTTPClient interface {
    Get(url string) (*Response, error)
    Post(url string, body []byte) (*Response, error)
    Put(url string, body []byte) (*Response, error)
    Delete(url string) (*Response, error)
    Head(url string) (*Response, error)
    Options(url string) (*Response, error)
    Patch(url string, body []byte) (*Response, error)
}

// Create focused interfaces
type Getter interface {
    Get(url string) (*Response, error)
}

type Poster interface {
    Post(url string, body []byte) (*Response, error)
}

// Function requires only what it uses
func FetchUser(g Getter, userID string) (*User, error) {
    resp, err := g.Get("/users/" + userID)
    if err != nil {
        return nil, fmt.Errorf("fetch user %s: %w", userID, err)
    }
    // parse response
    return parseUser(resp)
}

// Testing becomes easier
type mockGetter struct {
    response *Response
    err      error
}

func (m mockGetter) Get(url string) (*Response, error) {
    return m.response, m.err
}

// Only need to mock one method, not entire HTTPClient!

Accept Interfaces, Return Structs

Why This Matters

// BAD: returning interface
func NewLogger() Logger { // Logger is interface
    return &FileLogger{
        file: os.Stdout,
    }
}

// Problems:
// 1. Hides actual type
// 2. Loses access to type-specific methods
// 3. Complicates debugging

// GOOD: return concrete type
func NewLogger() *FileLogger { // concrete type
    return &FileLogger{
        file: os.Stdout,
    }
}

// But ACCEPT interface
func ProcessData(logger Logger, data []byte) error {
    logger.Log("Processing started")
    // processing
    logger.Log("Processing completed")
    return nil
}

Practical Example

// Repository returns concrete types
type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(id string) (*User, error) {
    // SQL query
    return &User{}, nil
}

func (r *UserRepository) Save(user *User) error {
    // SQL query
    return nil
}

// Service accepts interfaces
type UserFinder interface {
    FindByID(id string) (*User, error)
}

type UserSaver interface {
    Save(user *User) error
}

type UserService struct {
    finder UserFinder
    saver  UserSaver
}

func NewUserService(finder UserFinder, saver UserSaver) *UserService {
    return &UserService{
        finder: finder,
        saver:  saver,
    }
}

// Easy to test - can substitute mocks
type mockFinder struct {
    user *User
    err  error
}

func (m mockFinder) FindByID(id string) (*User, error) {
    return m.user, m.err
}

func TestUserService(t *testing.T) {
    mock := mockFinder{
        user: &User{Name: "Test"},
    }
    
    service := NewUserService(mock, nil)
    // test with mock
}

Interface Composition

Embedding Interfaces

// Base interfaces
type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type Closer interface {
    Close() error
}

// Composition through embedding
type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

// Or more explicitly
type ReadWriteCloser interface {
    Read([]byte) (int, error)
    Write([]byte) (int, error)
    Close() error
}

Type Assertions and Type Switches

// Type assertion - check concrete type
func ProcessWriter(w io.Writer) {
    // Check if Writer also supports Closer
    if closer, ok := w.(io.Closer); ok {
        defer closer.Close()
    }
    
    // Check for buffering
    if buffered, ok := w.(*bufio.Writer); ok {
        defer buffered.Flush()
    }
    
    w.Write([]byte("data"))
}

// Type switch - handle different types
func Describe(i interface{}) string {
    switch v := i.(type) {
    case string:
        return fmt.Sprintf("String of length %d", len(v))
    case int:
        return fmt.Sprintf("Integer: %d", v)
    case fmt.Stringer:
        return fmt.Sprintf("Stringer: %s", v.String())
    case error:
        return fmt.Sprintf("Error: %v", v)
    default:
        return fmt.Sprintf("Unknown type: %T", v)
    }
}

nil Interfaces: The Gotchas

// WARNING: classic mistake
type MyError struct {
    msg string
}

func (e *MyError) Error() string {
    return e.msg
}

func doSomething() error {
    var err *MyError = nil
    // some logic
    return err // RETURNING nil pointer
}

func main() {
    err := doSomething()
    if err != nil { // TRUE! nil pointer != nil interface
        fmt.Println("Got error:", err)
    }
}

// CORRECT: explicitly return nil
func doSomething() error {
    var err *MyError = nil
    // some logic
    if err == nil {
        return nil // return nil interface
    }
    return err
}

Checking for nil

// Safe nil check for interface
func IsNil(i interface{}) bool {
    if i == nil {
        return true
    }
    
    // Check if value inside interface is nil
    value := reflect.ValueOf(i)
    switch value.Kind() {
    case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func:
        return value.IsNil()
    }
    return false
}

Real Examples From Standard Library

io.Reader/Writer — Foundation of Everything

// Copy between any Reader and Writer
func Copy(dst io.Writer, src io.Reader) (int64, error)

// Works with files
file1, _ := os.Open("input.txt")
file2, _ := os.Create("output.txt")
io.Copy(file2, file1)

// Works with network
conn, _ := net.Dial("tcp", "example.com:80")
io.Copy(conn, strings.NewReader("GET / HTTP/1.0\r\n\r\n"))

// Works with buffers
var buf bytes.Buffer
io.Copy(&buf, file1)

http.Handler — Web Server in One Method

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

// Any type with ServeHTTP method can be a handler
type MyAPI struct {
    db Database
}

func (api MyAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.URL.Path {
    case "/users":
        api.handleUsers(w, r)
    case "/posts":
        api.handlePosts(w, r)
    default:
        http.NotFound(w, r)
    }
}

// HandlerFunc - adapter for regular functions
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r) // call the function
}

// Now regular function can be a handler!
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
})

Patterns and Anti-Patterns

Pattern: Conditional Interface Implementation

// Optional interfaces for extending functionality
type Optimizer interface {
    Optimize() error
}

func ProcessData(w io.Writer, data []byte) error {
    // Basic functionality
    if _, err := w.Write(data); err != nil {
        return err
    }
    
    // Optional optimization
    if optimizer, ok := w.(Optimizer); ok {
        return optimizer.Optimize()
    }
    
    return nil
}

Anti-Pattern: Overly Generic Interfaces

// BAD: interface{} everywhere
func Process(data interface{}) interface{} {
    // type assertions everywhere
    switch v := data.(type) {
    case string:
        return len(v)
    case []byte:
        return len(v)
    default:
        return nil
    }
}

// GOOD: specific interfaces
type Sized interface {
    Size() int
}

func Process(s Sized) int {
    return s.Size()
}

Practical Tips

  1. Define interfaces on the consumer side, not implementation
  2. Prefer small interfaces to large ones
  3. Use embedding for interface composition
  4. Don't return interfaces without necessity
  5. Remember nil interface vs nil pointer
  6. Use type assertions carefully
  7. interface{} is a last resort, not a first

Interface Checklist

- Interface has 1-3 methods maximum
- Interface defined near usage
- Functions accept interfaces, not concrete types
- Functions return concrete types, not interfaces
- No unused methods in interfaces
- Type assertions handle both cases (ok/not ok)
- interface{} used only where necessary

Conclusion

Interfaces are the glue that holds Go programs together. They enable flexible, testable, and maintainable code without complex inheritance hierarchies. Remember: in Go, interfaces are implicit, small, and composable.

In the next article, we'll discuss packages and dependencies: how to organize code so the import graph is flat and dependencies are unidirectional.

What's your take on interface design? How small is too small? How do you decide when to create an interface vs using a concrete type? Share your experience in the comments!