Mastering Go Interfaces: From Basics to Best Practices

Abu Bakar
4 min readJan 24, 2025

--

Have you ever wondered why Go developers are so excited about interfaces? Unlike traditional object-oriented languages, Go takes a unique approach to interfaces that’s both powerful and flexible. Let’s explore what makes Go interfaces special and how to use them to write better code.

What Makes Go Interfaces Different?

In Go, interfaces are implicit. It means you don’t need to declare that a type implements an interface explicitly, it just needs the right methods. This simple idea leads to incredibly flexible and maintainable code.

type User struct {
Name string
Age int
}

// String() mthod here satisfies fmt.Stringer implicitly
func (u User) String() string {
return fmt.Sprintf("%s (%d years old)", u.Name, u.Age)
}

func main() {
user := User{Name: "Alice", Age: 28}
fmt.Println(user) // Uses fmt.Stringer's String() method
}

The Power of Small Interfaces

Go’s standard library is built on small, focused interfaces. Let’s see why this is powerful:

// function works with any type that has a Read() method (io.Reader)
func processData(r io.Reader) error {
data := make([]byte, 1024)
_, err := r.Read(data)
return err
}

func main() {
// below types have a Read method, so they implement io.Reader

// files
file, _ := os.Open("data.txt")
processData(file)

// network connections
conn, _ := net.Dial("tcp", "example.com:80")
processData(conn)

// in-memory buffers
buf := bytes.NewBuffer([]byte("Hello"))
processData(buf)
}

Building Composable Interfaces

One of Go’s strengths is interface composition. Let’s see it in action:

type DataProcessor interface {
Process(data []byte) error
}

type DataValidator interface {
Validate(data []byte) error
}

type DataHandler interface {
DataProcessor
DataValidator
}

type JSONHandler struct{}

func (j JSONHandler) Process(data []byte) error {
fmt.Println("Processing JSON data...")
return nil
}

func (j JSONHandler) Validate(data []byte) error {
fmt.Println("Validating JSON format...")
return nil
}

Interface Best Practices: A Real-World Example

Let’s build a flexible logging system using interfaces:

type Logger interface {
Log(level string, message string)
}

type FileLogger struct {
file *os.File
}

func (f FileLogger) Log(level, message string) {
fmt.Fprintf(f.file, "[%s] %s: %s\n",
time.Now().Format(time.RFC3339),
level,
message)
}

type ConsoleLogger struct {
prefix string
}

func (c ConsoleLogger) Log(level, message string) {
fmt.Printf("%s [%s] %s\n",
c.prefix,
level,
message)
}

// service using the logger
type UserService struct {
logger Logger
}

func (s UserService) CreateUser(name string) error {
s.logger.Log("INFO", fmt.Sprintf("Creating user: %s", name))
return nil
}

func main() {
fileLogger := FileLogger{file: createLogFile()}
userService := UserService{logger: fileLogger}
userService.CreateUser("Alice")

consoleLogger := ConsoleLogger{prefix: "USER-SERVICE"}
userService.logger = consoleLogger
userService.CreateUser("Bob")
}

The Empty Interface and Type Assertions

Go’s empty interface (interface{} or any In Go 1.18+) can hold any value, but using it requires careful type assertions:

func printAny(v any) {
switch v := v.(type) {
case string:
fmt.Printf("String: %s\n", v)
case int:
fmt.Printf("Integer: %d\n", v)
case bool:
fmt.Printf("Boolean: %v\n", v)
default:
fmt.Printf("Unknown type: %T\n", v)
}

if str, ok := v.(string); ok {
fmt.Printf("Got a string: %s\n", str)
}
}

func main() {
printAny("hello")
printAny(42)
printAny(true)
}

Testing with Interfaces

Interfaces make testing a breeze. Let’s see how:

type User struct {
ID string
Name string
}

type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}

type UserService struct {
repo UserRepository
}

func (s UserService) GetUserByID(id string) (*User, error) {
return s.repo.GetUser(id)
}

// Mock implementation
type MockUserRepo struct {
users map[string]*User
}

func (m *MockUserRepo) GetUser(id string) (*User, error) {
user, exists := m.users[id]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}

func (m *MockUserRepo) SaveUser(user *User) error {
m.users[user.ID] = user
return nil
}

func TestUserService(t *testing.T) {
mockRepo := &MockUserRepo{
users: map[string]*User{
"1": {ID: "1", Name: "Test User"},
},
}

service := UserService{repo: mockRepo}
user, err := service.GetUserByID("1")

if err != nil {
t.Fatal("Unexpected error:", err)
}
if user.Name != "Test User" {
t.Errorf("Expected 'Test User', got %s", user.Name)
}
}

Common Interface Pitfalls and How to Avoid Them

Interface Pollution

// DON'T: Too many methods
type BigInterface interface {
DoThis()
DoThat()
DoSomethingElse()
// ... bla bla
}

// DO: Break it down
type Doer interface {
DoThis()
DoThat()
}

type SomethingElseDoer interface {
DoSomethingElse()
}

Returning Interfaces

// DON'T: Return interfaces
func NewWriter() io.Writer {
return &bytes.Buffer{}
}

// DO: Return concrete type
func NewWriter() *bytes.Buffer {
return &bytes.Buffer{}
}

Best Practices Summary

  1. Keep interfaces small and focused
  2. Let clients define interfaces (interface segregation)
  3. Use composition to build larger interfaces
  4. Use interfaces for testing and flexibility
  5. Accept interfaces, return concrete types
  6. Don’t export interfaces for types that satisfy just one interface

Conclusion

Go’s interface system might differ from what you’re used to, but this difference makes it powerful. You can write more flexible, testable, and maintainable code by embracing implicit interfaces and composition.

Remember:

  • Interfaces should be small and focused
  • Let behavior drive interface design
  • Use composition over inheritance
  • Interfaces make testing easier
  • Always favor the smallest interface possible

Now go forth and interface with confidence!

Further Reading

--

--

Abu Bakar
Abu Bakar

Written by Abu Bakar

Polyglot Software Engineer | Building end-to-end, turnkey solutions for web | Designer who loves minimalism

Responses (4)