After a year of writing Go, I’ve learned that the best interfaces are small. Really small.

The Go Way

In Go, interfaces are implicit. You don’t declare that a type implements an interface:

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

type File struct {
    // ...
}

func (f *File) Read(p []byte) (n int, err error) {
    // File automatically implements Reader
}

Keep Interfaces Small

The best interfaces have 1-2 methods:

// Good - single method
type Reader interface {
    Read(p []byte) (n int, err error)
}

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

// Compose them
type ReadWriter interface {
    Reader
    Writer
}

Accept Interfaces, Return Structs

// Good
func ProcessData(r io.Reader) (*Result, error) {
    // ...
}

// Bad
func ProcessData(f *os.File) (*Result, error) {
    // Too specific
}

This makes your code more testable and flexible.

Real Example

We refactored our storage layer:

Before:

type Storage struct {
    db *sql.DB
}

func (s *Storage) Save(data []byte) error {
    // ...
}

func ProcessAndSave(s *Storage, data []byte) error {
    processed := process(data)
    return s.Save(processed)
}

After:

type Saver interface {
    Save(data []byte) error
}

func ProcessAndSave(s Saver, data []byte) error {
    processed := process(data)
    return s.Save(processed)
}

Now we can easily test with a mock:

type MockSaver struct {
    saved []byte
}

func (m *MockSaver) Save(data []byte) error {
    m.saved = data
    return nil
}

func TestProcessAndSave(t *testing.T) {
    mock := &MockSaver{}
    ProcessAndSave(mock, []byte("test"))
    // Assert on mock.saved
}

Interface Segregation

Don’t create fat interfaces:

// Bad - too many methods
type UserService interface {
    Create(user *User) error
    Update(user *User) error
    Delete(id string) error
    FindByID(id string) (*User, error)
    FindByEmail(email string) (*User, error)
    List() ([]*User, error)
    Count() (int, error)
}

// Good - split by responsibility
type UserCreator interface {
    Create(user *User) error
}

type UserFinder interface {
    FindByID(id string) (*User, error)
    FindByEmail(email string) (*User, error)
}

type UserLister interface {
    List() ([]*User, error)
}

The Empty Interface

interface{} means “any type”. Use sparingly:

// Bad - loses type safety
func Process(data interface{}) {
    // Have to type assert
}

// Good - use generics (Go 1.18+) or specific types
func ProcessString(data string) {
    // Type safe
}

Common Patterns

1. io.Reader/Writer

func SaveToFile(r io.Reader, filename string) error {
    f, err := os.Create(filename)
    if err != nil {
        return err
    }
    defer f.Close()
    
    _, err = io.Copy(f, r)
    return err
}

// Works with files, network, strings, anything!
SaveToFile(strings.NewReader("data"), "file.txt")
SaveToFile(httpResponse.Body, "response.txt")

2. http.Handler

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

// Any type with ServeHTTP is a handler
type MyHandler struct{}

func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello"))
}

3. Stringer

type Stringer interface {
    String() string
}

type User struct {
    Name string
}

func (u *User) String() string {
    return u.Name
}

// Now fmt.Println(user) uses String()

Testing with Interfaces

Interfaces make testing easy:

type EmailSender interface {
    Send(to, subject, body string) error
}

type UserService struct {
    emailer EmailSender
}

// Production
type SMTPEmailer struct{}
func (s *SMTPEmailer) Send(to, subject, body string) error {
    // Send via SMTP
}

// Testing
type MockEmailer struct {
    sent []string
}
func (m *MockEmailer) Send(to, subject, body string) error {
    m.sent = append(m.sent, to)
    return nil
}

When NOT to Use Interfaces

Don’t create interfaces “just in case”:

// Bad - premature abstraction
type UserRepository interface {
    Save(user *User) error
}

type PostgresUserRepository struct{}

func (r *PostgresUserRepository) Save(user *User) error {
    // ...
}

// If you only have one implementation, you don't need the interface yet

Wait until you have a second implementation or need to mock for testing.

The Verdict

  • Keep interfaces small (1-2 methods)
  • Accept interfaces, return structs
  • Don’t create interfaces prematurely
  • Use standard library interfaces when possible
  • Interfaces make testing easy

Go’s implicit interfaces are one of my favorite features. They encourage small, focused abstractions.

Questions? Let me know!