VOOZH about

URL: https://dzone.com/articles/clean-code-go-interfaces-small

⇱ Interfaces in Go — Why Small Is Beautiful, Part 3


Related

  1. DZone
  2. Coding
  3. Languages
  4. Clean Code: Interfaces in Go — Why Small Is Beautiful, Part 3

Clean Code: Interfaces in Go — Why Small Is Beautiful, Part 3

Keep interfaces to 1–3 methods, define them on the consumer side, accept interfaces but return concrete types, and never confuse nil pointer with nil interface.

Likes
Comment
Save
2.0K Views

Join the DZone community and get the full member experience.

Join For Free

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 eight years of 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:

Go
// 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


Small Interfaces: The Power of Simplicity

The Single Method Rule

Look at Go's standard library:

Go
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?

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
// 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

Go
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

Go
// 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

Go
// 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 and 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!

Go (programming language) Interface (computing)

Opinions expressed by DZone contributors are their own.

Related

  • Contexts in Go: A Comprehensive Guide
  • Event-Driven Pipelines With Apache Pulsar and Go
  • Beyond SOLID: Embracing CUPID for Modern Software Craftsmanship
  • Beyond Conversation: Mastering Context with Claude Code Skills and Agents

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

Let's be friends: