Go Interfaces: Design Patterns & Best Practices
Introduction
Go interfaces represent one of the most elegant and powerful features of the language, fundamentally different from interfaces in traditional object-oriented languages. Unlike explicit interface implementation found in Java or C#, Go's implicit interface satisfaction creates a flexible system that promotes clean, decoupled code without the ceremony of inheritance hierarchies.
The simplicity of Go's interfaces is their strength: any type that implements the required methods automatically satisfies the interface. This encourages composition over inheritance and helps make your code modular, testable, and adaptable. But with this flexibility comes responsibility: badly designed interfaces lead to coupling, confusion, and brittle APIs.
In this article, I'll show you the design patterns and best practices that will help you leverage Go interfaces to their full potential. Whether you're building microservices, CLI tools, or complex distributed systems, understanding these patterns will make your Go code more robust and elegant.
Understanding Go Interface Fundamentals
Before going into design patterns, let's establish a solid foundation of how Go interfaces work and what makes them special.
The Implicit Interface System
Go's interface model uses structural typing: a type doesn't need to explicitly state it "implements" an interface. If it has the needed method signatures, it satisfies the interface.
package main
import "fmt"
// Writer interface defines a contract for writing
type Writer interface {
Write(data []byte) (int, error)
}
// FileWriter implements Writer implicitly
type FileWriter struct {
filename string
}
func (fw *FileWriter) Write(data []byte) (int, error) {
fmt.Printf("Writing %d bytes to file: %s\n", len(data), fw.filename)
return len(data), nil
}
// NetworkWriter also implements Writer implicitly
type NetworkWriter struct {
endpoint string
}
func (nw *NetworkWriter) Write(data []byte) (int, error) {
fmt.Printf("Sending %d bytes to endpoint: %s\n", len(data), nw.endpoint)
return len(data), nil
}
func processData(w Writer, data []byte) error {
_, err := w.Write(data)
return err
}
func main() {
data := []byte("Hello, Go interfaces!")
fileWriter := &FileWriter{filename: "output.txt"}
networkWriter := &NetworkWriter{endpoint: "api.example.com"}
processData(fileWriter, data)
processData(networkWriter, data)
}
In this example, processData
defines the interface it expects.
Types like FileWriter
and NetworkWriter
satisfy it automatically by implementing the Write
method, without any explicit declaration.
This pattern of defining interfaces on the consumer side fosters loose coupling.
Interface Values and Dynamic Dispatch
Under the hood, Go interface values contain two components: a type and a value. This enables dynamic dispatch while maintaining type safety:
package main
import (
"fmt"
"reflect"
)
type Greeter interface {
Greet() string
}
type EnglishGreeter struct {
name string
}
func (eg EnglishGreeter) Greet() string {
return fmt.Sprintf("Hello, %s!", eg.name)
}
type SpanishGreeter struct {
name string
}
func (sg SpanishGreeter) Greet() string {
return fmt.Sprintf("¡Hola, %s!", sg.name)
}
func main() {
var greeter Greeter
// Interface value is nil initially
fmt.Printf("Initial: %v, %T\n", greeter, greeter)
// Assign EnglishGreeter value
greeter = EnglishGreeter{name: "Aitana"}
fmt.Printf("English: %s, Type: %s\n", greeter.Greet(), reflect.TypeOf(greeter))
// Assign SpanishGreeter value
greeter = SpanishGreeter{name: "Àlex"}
fmt.Printf("Spanish: %s, Type: %s\n", greeter.Greet(), reflect.TypeOf(greeter))
}
In this example, the greeter
variable of type Greeter
can hold values of different concrete types (EnglishGreeter
and SpanishGreeter
).
When greeter
is assigned a value, the interface value holds both the concrete type and the value, allowing method calls to be dispatched to the correct implementation at runtime.
Understanding this dual nature of interface values is crucial for effective interface design and helps avoid common pitfalls like nil interface comparison issues.
Core Interface Design Patterns
Let's now learn about some of the fundamental patterns that form the building blocks of effective Go interface design.
1. The Strategy Pattern
The Strategy pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable Go interfaces make this pattern particularly elegant:
package main
import (
"fmt"
"sort"
)
// SortStrategy defines different sorting approaches
type SortStrategy interface {
Sort(data []string) []string
}
// AlphabeticalSort implements lexicographic sorting
type AlphabeticalSort struct{}
func (as AlphabeticalSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Strings(result)
return result
}
// LengthSort sorts by string length
type LengthSort struct{}
func (ls LengthSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Slice(result, func(i, j int) bool {
return len(result[i]) < len(result[j])
})
return result
}
// ReverseSort sorts in reverse alphabetical order
type ReverseSort struct{}
func (rs ReverseSort) Sort(data []string) []string {
result := make([]string, len(data))
copy(result, data)
sort.Sort(sort.Reverse(sort.StringSlice(result)))
return result
}
// DataProcessor uses a strategy to process data
type DataProcessor struct {
strategy SortStrategy
}
func (dp *DataProcessor) SetStrategy(strategy SortStrategy) {
dp.strategy = strategy
}
func (dp *DataProcessor) ProcessData(data []string) []string {
if dp.strategy == nil {
return data
}
return dp.strategy.Sort(data)
}
func main() {
data := []string{"banana", "apple", "cherry", "date", "elderberry"}
processor := &DataProcessor{}
// Use different strategies
strategies := map[string]SortStrategy{
"Alphabetical": AlphabeticalSort{},
"By Length": LengthSort{},
"Reverse": ReverseSort{},
}
for name, strategy := range strategies {
processor.SetStrategy(strategy)
result := processor.ProcessData(data)
fmt.Printf("%s: %v\n", name, result)
}
}
This pattern is particularly useful in configuration-driven applications where behavior needs to change based on user preferences or environment settings. When the application starts, a specific strategy can be selected based on configuration, allowing for flexible and maintainable code.
2. The Decorator Pattern
The decorator pattern allows you to add behavior to objects dynamically without altering their structure. Go's interface composition makes this pattern natural:
package main
import (
"fmt"
"strings"
"time"
)
// MessageProcessor defines the core interface
type MessageProcessor interface {
Process(message string) string
}
// BaseProcessor provides basic message processing
type BaseProcessor struct{}
func (bp BaseProcessor) Process(message string) string {
return message
}
// UppercaseDecorator converts messages to uppercase
type UppercaseDecorator struct {
processor MessageProcessor
}
func (ud UppercaseDecorator) Process(message string) string {
result := ud.processor.Process(message)
return strings.ToUpper(result)
}
// TimestampDecorator adds timestamps to messages
type TimestampDecorator struct {
processor MessageProcessor
}
func (td TimestampDecorator) Process(message string) string {
result := td.processor.Process(message)
timestamp := time.Now().Format("2006-01-02 15:04:05")
return fmt.Sprintf("[%s] %s", timestamp, result)
}
// PrefixDecorator adds a custom prefix
type PrefixDecorator struct {
processor MessageProcessor
prefix string
}
func (pd PrefixDecorator) Process(message string) string {
result := pd.processor.Process(message)
return fmt.Sprintf("%s: %s", pd.prefix, result)
}
func main() {
// Build a chain of decorators
base := BaseProcessor{}
// Add timestamp decorator
withTimestamp := TimestampDecorator{processor: base}
// Add uppercase decorator
withUppercase := UppercaseDecorator{processor: withTimestamp}
// Add prefix decorator
withPrefix := PrefixDecorator{
processor: withUppercase,
prefix: "LOG",
}
message := "User authentication successful to blog.marcnuri.com"
result := withPrefix.Process(message)
fmt.Println(result)
// Demonstrate different combinations
simpleChain := UppercaseDecorator{processor: base}
fmt.Println(simpleChain.Process("Simple message"))
}
This pattern excels in middleware systems, logging frameworks, and anywhere you need to add cross-cutting concerns without modifying core business logic.
The output of the previous example will look like this:
LOG: [2015-10-21 04:29:00] USER AUTHENTICATION SUCCESSFUL TO BLOG.MARCNURI.COM
SIMPLE MESSAGE
You can see how decorators can be stacked to create complex processing pipelines while keeping each component focused and reusable.
3. The Observer Pattern
The observer pattern defines a one-to-many dependency between objects, allowing multiple observers to be notified of state changes:
package main
import (
"fmt"
"sync"
"time"
)
// Observer defines the interface for objects that should be notified
type Observer interface {
Update(event Event)
}
// Event represents a notification with data
type Event struct {
Type string
Data interface{}
}
// Subject manages observers and notifications
type Subject interface {
Subscribe(observer Observer)
Unsubscribe(observer Observer)
Notify(event Event)
}
// EventBus implements a thread-safe subject
type EventBus struct {
observers []Observer
mutex sync.RWMutex
}
func (eb *EventBus) Subscribe(observer Observer) {
eb.mutex.Lock()
defer eb.mutex.Unlock()
eb.observers = append(eb.observers, observer)
}
func (eb *EventBus) Unsubscribe(observer Observer) {
eb.mutex.Lock()
defer eb.mutex.Unlock()
for i, obs := range eb.observers {
if obs == observer {
eb.observers = append(eb.observers[:i], eb.observers[i+1:]...)
break
}
}
}
func (eb *EventBus) Notify(event Event) {
eb.mutex.RLock()
defer eb.mutex.RUnlock()
for _, observer := range eb.observers {
go observer.Update(event) // Async notification
}
}
// Concrete observers
type EmailNotifier struct {
email string
}
func (en EmailNotifier) Update(event Event) {
fmt.Printf("Email to %s: %s event - %v\n", en.email, event.Type, event.Data)
}
type SMSNotifier struct {
phone string
}
func (sn SMSNotifier) Update(event Event) {
fmt.Printf("SMS to %s: %s event - %v\n", sn.phone, event.Type, event.Data)
}
type LogNotifier struct{}
func (ln LogNotifier) Update(event Event) {
fmt.Printf("LOG: %s event occurred with data: %v\n", event.Type, event.Data)
}
// UserService demonstrates the pattern in action
type UserService struct {
eventBus Subject
}
func (us *UserService) CreateUser(username string) {
// Simulate user creation
fmt.Printf("Creating user: %s\n", username)
// Notify observers
us.eventBus.Notify(Event{
Type: "USER_CREATED",
Data: map[string]string{"username": username},
})
}
func main() {
eventBus := &EventBus{}
userService := &UserService{eventBus: eventBus}
// Subscribe different types of observers
emailNotifier := EmailNotifier{email: "admin@example.com"}
smsNotifier := SMSNotifier{phone: "+1234567890"}
logNotifier := LogNotifier{}
eventBus.Subscribe(emailNotifier)
eventBus.Subscribe(smsNotifier)
eventBus.Subscribe(logNotifier)
// Trigger an event
userService.CreateUser("aitana")
// Give async notifications time to complete
time.Sleep(100 * time.Millisecond)
}
This pattern is essential for event-driven architectures, real-time notifications, and decoupled system components.
Advanced Interface Composition Patterns
Go's interface embedding and composition capabilities enable sophisticated design patterns that promote code reuse and maintainability.
Interface Segregation and Composition
The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they don't use. Go makes this natural through interface composition:
package main
import "fmt"
// Small, focused interfaces
type Printer interface {
Print(doc string)
}
type Scanner interface {
Scan() string
}
// Composed interfaces for specific use cases
type MultiFunctionDevice interface {
Printer
Scanner
}
// Concrete types
type SimplePrinter struct{}
func (p SimplePrinter) Print(doc string) {
fmt.Println("Printing:", doc)
}
type MultiFunctionMachine struct{}
func (m MultiFunctionMachine) Print(doc string) {
fmt.Println("Printing:", doc)
}
func (m MultiFunctionMachine) Scan() string {
return "Scanned document"
}
func main() {
var printer Printer = SimplePrinter{}
printer.Print("Hello, ISP!")
var mfm MultiFunctionDevice = MultiFunctionMachine{}
mfm.Print("Report.pdf")
fmt.Println(mfm.Scan())
}
In this example, Printer
and Scanner
are small, focused interfaces.
The MultiFunctionDevice
interface composes both, allowing clients to depend only on the functionality they need.
The SimplePrinter
type only implements the Printer
interface, while MultiFunctionMachine
implements both.
In the main
function, we demonstrate how different clients can use these interfaces without being forced to depend on unnecessary methods.
This approach creates flexible APIs where clients depend only on the functionality they actually need, making code more maintainable and testable.
The Adapter Pattern with Interfaces
The adapter pattern allows incompatible interfaces to work together. Go interfaces make this pattern particularly clean:
package main
import (
"fmt"
"log"
"os"
"strings"
)
// Modern interface our application expects
type Logger interface {
Info(message string)
Error(message string)
Debug(message string)
}
// Legacy logging system we need to integrate
type LegacyLogger struct {
prefix string
}
func (ll *LegacyLogger) LogMessage(level, message string) {
fmt.Printf("[%s] %s: %s\n", ll.prefix, strings.ToUpper(level), message)
}
// Adapter to make LegacyLogger implement Logger
type LegacyLoggerAdapter struct {
legacy *LegacyLogger
}
func (lla *LegacyLoggerAdapter) Info(message string) {
lla.legacy.LogMessage("info", message)
}
func (lla *LegacyLoggerAdapter) Error(message string) {
lla.legacy.LogMessage("error", message)
}
func (lla *LegacyLoggerAdapter) Debug(message string) {
lla.legacy.LogMessage("debug", message)
}
// Third-party library with different interface
type ThirdPartyLogger struct{}
func (tpl *ThirdPartyLogger) WriteLog(severity int, msg string) {
severityMap := map[int]string{
1: "DEBUG",
2: "INFO",
3: "ERROR",
}
fmt.Printf("[THIRD-PARTY] %s: %s\n", severityMap[severity], msg)
}
// Adapter for third-party logger
type ThirdPartyLoggerAdapter struct {
thirdParty *ThirdPartyLogger
}
func (tpla *ThirdPartyLoggerAdapter) Info(message string) {
tpla.thirdParty.WriteLog(2, message)
}
func (tpla *ThirdPartyLoggerAdapter) Error(message string) {
tpla.thirdParty.WriteLog(3, message)
}
func (tpla *ThirdPartyLoggerAdapter) Debug(message string) {
tpla.thirdParty.WriteLog(1, message)
}
// Standard library adapter
type StdLoggerAdapter struct {
logger *log.Logger
}
func (sla *StdLoggerAdapter) Info(message string) {
sla.logger.Printf("INFO: %s", message)
}
func (sla *StdLoggerAdapter) Error(message string) {
sla.logger.Printf("ERROR: %s", message)
}
func (sla *StdLoggerAdapter) Debug(message string) {
sla.logger.Printf("DEBUG: %s", message)
}
// Application service that uses the Logger interface
type UserService struct {
logger Logger
}
func (us *UserService) CreateUser(username string) error {
us.logger.Info(fmt.Sprintf("Creating user: %s", username))
// Simulate some processing
if username == "" {
us.logger.Error("Username cannot be empty")
return fmt.Errorf("invalid username")
}
us.logger.Debug(fmt.Sprintf("User %s created successfully", username))
return nil
}
func main() {
// Use different logger implementations through adapters
loggers := map[string]Logger{
"Legacy": &LegacyLoggerAdapter{
legacy: &LegacyLogger{prefix: "LEGACY"},
},
"ThirdParty": &ThirdPartyLoggerAdapter{
thirdParty: &ThirdPartyLogger{},
},
"Standard": &StdLoggerAdapter{
logger: log.New(os.Stdout, "STD ", log.LstdFlags),
},
}
for name, logger := range loggers {
fmt.Printf("\n--- Using %s Logger ---\n", name)
service := &UserService{logger: logger}
service.CreateUser("julia")
service.CreateUser("") // This will cause an error
}
}
In this example, we have a Logger
interface that our application expects.
We then create adapters for a legacy logging system, a third-party logger, and the standard library logger to conform to this interface.
Each adapter translates the calls from our application to the appropriate methods of the underlying logging system.
As you can see, the adapter pattern is particularly valuable when integrating with external libraries, legacy systems, or when you need to standardize interfaces across different implementations.
Interface Testing and Mocking Strategies
Testing is where Go interfaces truly shine. They enable easy mocking and dependency injection, making unit tests isolated and reliable.
Here's a comprehensive example of how to structure your production code for testability using interfaces:
package main
import (
"fmt"
"time"
)
// External dependencies as interfaces
type TimeProvider interface {
Now() time.Time
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type UserStore interface {
SaveUser(user *User) error
GetUser(email string) (*User, error)
}
// Domain model
type User struct {
Email string
Password string
IsVerified bool
CreatedAt time.Time
VerifiedAt *time.Time
}
// Service with dependencies injected as interfaces
type UserRegistrationService struct {
timeProvider TimeProvider
emailSender EmailSender
userStore UserStore
}
func NewUserRegistrationService(
timeProvider TimeProvider,
emailSender EmailSender,
userStore UserStore,
) *UserRegistrationService {
return &UserRegistrationService{
timeProvider: timeProvider,
emailSender: emailSender,
userStore: userStore,
}
}
func (s *UserRegistrationService) RegisterUser(email, password string) error {
// Check if user already exists
existing, _ := s.userStore.GetUser(email)
if existing != nil {
return fmt.Errorf("user already exists")
}
// Create user
user := &User{
Email: email,
Password: password, // In real code, this would be hashed
IsVerified: false,
CreatedAt: s.timeProvider.Now(),
}
// Save user
if err := s.userStore.SaveUser(user); err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
// Send verification email
subject := "Verify your account"
body := fmt.Sprintf("Please verify your account by clicking the link...")
if err := s.emailSender.SendEmail(email, subject, body); err != nil {
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func (s *UserRegistrationService) VerifyUser(email string) error {
user, err := s.userStore.GetUser(email)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.IsVerified {
return fmt.Errorf("user already verified")
}
now := s.timeProvider.Now()
user.IsVerified = true
user.VerifiedAt = &now
return s.userStore.SaveUser(user)
}
// Real implementations
type RealTimeProvider struct{}
func (r RealTimeProvider) Now() time.Time {
return time.Now()
}
type RealEmailSender struct{}
func (r RealEmailSender) SendEmail(to, subject, body string) error {
fmt.Printf("Sending email to %s: %s\n", to, subject)
return nil
}
type RealUserStore struct {
users map[string]*User
}
func NewRealUserStore() *RealUserStore {
return &RealUserStore{users: make(map[string]*User)}
}
func (r *RealUserStore) SaveUser(user *User) error {
r.users[user.Email] = user
return nil
}
func (r *RealUserStore) GetUser(email string) (*User, error) {
user, exists := r.users[email]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
func main() {
// Example usage with real implementations
timeProvider := RealTimeProvider{}
emailSender := RealEmailSender{}
userStore := NewRealUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
err := service.RegisterUser("alice@example.com", "password123")
if err != nil {
fmt.Printf("Registration failed: %v\n", err)
return
}
fmt.Println("User registered successfully!")
err = service.VerifyUser("alice@example.com")
if err != nil {
fmt.Printf("Verification failed: %v\n", err)
return
}
fmt.Println("User verified successfully!")
}
Now let's see how to write tests for this service using mock implementations of the interfaces:
package main
import (
"fmt"
"testing"
"time"
)
// Test mocks
type MockTimeProvider struct {
currentTime time.Time
}
func (m *MockTimeProvider) Now() time.Time {
return m.currentTime
}
type MockEmailSender struct {
sentEmails []EmailRecord
shouldFail bool
}
type EmailRecord struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
if m.shouldFail {
return fmt.Errorf("email sending failed")
}
m.sentEmails = append(m.sentEmails, EmailRecord{
To: to,
Subject: subject,
Body: body,
})
return nil
}
type MockUserStore struct {
users map[string]*User
shouldFail bool
}
func NewMockUserStore() *MockUserStore {
return &MockUserStore{users: make(map[string]*User)}
}
func (m *MockUserStore) SaveUser(user *User) error {
if m.shouldFail {
return fmt.Errorf("database error")
}
m.users[user.Email] = user
return nil
}
func (m *MockUserStore) GetUser(email string) (*User, error) {
if m.shouldFail {
return nil, fmt.Errorf("database error")
}
user, exists := m.users[email]
if !exists {
return nil, fmt.Errorf("user not found")
}
return user, nil
}
func TestUserRegistration(t *testing.T) {
// Setup mocks
fixedTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &MockTimeProvider{currentTime: fixedTime}
emailSender := &MockEmailSender{}
userStore := NewMockUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
// Test successful registration
err := service.RegisterUser("test@example.com", "password123")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
// Verify user was saved
savedUser, err := userStore.GetUser("test@example.com")
if err != nil {
t.Fatalf("User should be saved: %v", err)
}
if savedUser.Email != "test@example.com" {
t.Errorf("Expected email test@example.com, got %s", savedUser.Email)
}
if savedUser.IsVerified {
t.Error("User should not be verified initially")
}
if !savedUser.CreatedAt.Equal(fixedTime) {
t.Errorf("Expected created time %v, got %v", fixedTime, savedUser.CreatedAt)
}
// Verify email was sent
if len(emailSender.sentEmails) != 1 {
t.Fatalf("Expected 1 email sent, got %d", len(emailSender.sentEmails))
}
sentEmail := emailSender.sentEmails[0]
if sentEmail.To != "test@example.com" {
t.Errorf("Expected email to test@example.com, got %s", sentEmail.To)
}
// Test duplicate registration
err = service.RegisterUser("test@example.com", "password123")
if err == nil {
t.Error("Expected error for duplicate registration")
}
}
func TestUserVerification(t *testing.T) {
// Setup
fixedTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
timeProvider := &MockTimeProvider{currentTime: fixedTime}
emailSender := &MockEmailSender{}
userStore := NewMockUserStore()
service := NewUserRegistrationService(timeProvider, emailSender, userStore)
// Register user first
service.RegisterUser("test@example.com", "password123")
// Verify user
verifyTime := fixedTime.Add(time.Hour)
timeProvider.currentTime = verifyTime
err := service.VerifyUser("test@example.com")
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
// Check verification
user, _ := userStore.GetUser("test@example.com")
if !user.IsVerified {
t.Error("User should be verified")
}
if user.VerifiedAt == nil || !user.VerifiedAt.Equal(verifyTime) {
t.Errorf("Expected verified time %v, got %v", verifyTime, user.VerifiedAt)
}
// Test double verification
err = service.VerifyUser("test@example.com")
if err == nil {
t.Error("Expected error for double verification")
}
}
This approach provides several benefits:
- Isolated Testing: Each test focuses on the service logic without external dependencies.
- Controlled Environment: Mock implementations allow you to simulate various scenarios.
- Predictable Behavior: Fixed time and controlled responses make tests deterministic.
- Easy Maintenance: Changes to external services don't break your tests.
Interface Best Practices and Anti-Patterns
Let's examine the do's and don'ts of interface design in Go.
Best Practices
1. Keep Interfaces Small and Focused
Follow the principle of interface segregation:
// ✅ Good: Small, focused interfaces
type Reader interface {
Read([]byte) (int, error)
}
type Writer interface {
Write([]byte) (int, error)
}
// Compose when needed
type ReadWriter interface {
Reader
Writer
}
// ❌ Bad: Large, monolithic interface
type BadFileManager interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Seek(int64, int) (int64, error)
Close() error
Sync() error
Truncate(int64) error
Stat() (FileInfo, error)
Chmod(FileMode) error
// ... many more methods
}
2. Define Interfaces Where They're Used
Place interfaces in the consuming package, not the implementing package:
package user_processor
// In the consumer package
type UserValidator interface {
ValidateUser(user *User) error
}
func ProcessUser(validator UserValidator, user *User) error {
if err := validator.ValidateUser(user); err != nil {
return err
}
// Process user...
return nil
}
3. Use Descriptive Interface Names
Interface names should clearly convey their purpose:
// ✅ Good: Clear, descriptive names
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type PriceCalculator interface {
CalculatePrice(item *Item) (float64, error)
}
// ❌ Bad: Generic or unclear names
type Manager interface {
Do(interface{}) interface{}
}
Common Anti-Patterns to Avoid
Let's now look at some common pitfalls in interface design.
1. Interface Pollution
Don't create interfaces for everything:
// ❌ Bad: Unnecessary interface for single implementation
type UserService interface {
CreateUser(name string) *User
}
type UserServiceImpl struct{}
func (us *UserServiceImpl) CreateUser(name string) *User {
return &User{Name: name}
}
// ✅ Good: Use concrete type directly when there's only one implementation
type UserService struct{}
func (us *UserService) CreateUser(name string) *User {
return &User{Name: name}
}
Having to append "Impl" to the struct name is a clear sign that the interface is unnecessary. If you only have one implementation and no immediate plans for others, use the concrete type directly.
2. Fat Interfaces
Avoid interfaces with too many methods:
// ❌ Bad: Interface trying to do too much
type SuperService interface {
CreateUser(name string) error
DeleteUser(id int) error
SendEmail(to, subject, body string) error
LogMessage(level, message string)
CacheData(key string, value interface{})
ValidateInput(input string) bool
// ... more methods
}
// ✅ Good: Separate concerns into focused interfaces
type UserManager interface {
CreateUser(name string) error
DeleteUser(id int) error
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}
type Logger interface {
LogMessage(level, message string)
}
3. Premature Interface Abstraction
Don't create interfaces until you actually need the abstraction:
// ❌ Bad: Creating interface before it's needed
type DatabaseConnection interface {
Query(sql string) ([]Row, error)
}
type MySQLConnection struct {}
func (mc *MySQLConnection) Query(sql string) ([]Row, error) {
// Implementation
}
// If you only have one implementation and no immediate plans for others,
// use the concrete type directly
// ✅ Good: Start with concrete implementation
type MySQLConnection struct {}
func (mc *MySQLConnection) Query(sql string) ([]Row, error) {
// Implementation
}
// Add interface later when you need to support PostgreSQL, etc.
Modern Interface Patterns with Generics
Go 1.18+ introduced generics, which work beautifully with interfaces for type-safe, flexible designs:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
// Generic interface with type parameters
type Store[T any] interface {
Save(item T) error
Get(id string) (T, error)
}
// Generic interface with constraints
type Calculator[T constraints.Ordered] interface {
Add(a, b T) T
}
// Simple implementation
type User struct {
ID string
Name string
}
type MemoryStore[T any] struct {
data map[string]T
}
func (m *MemoryStore[T]) Save(item T) error {
if m.data == nil {
m.data = make(map[string]T)
}
m.data["key"] = item
return nil
}
func (m *MemoryStore[T]) Get(id string) (T, error) {
item, exists := m.data[id]
if !exists {
var zero T
return zero, fmt.Errorf("not found")
}
return item, nil
}
// Numeric calculator with constraints
type NumberCalc[T constraints.Ordered] struct{}
func (nc NumberCalc[T]) Add(a, b T) T {
return a + b
}
func main() {
// Generic store with User type
userStore := &MemoryStore[User]{}
user := User{ID: "1", Name: "Aitana"}
userStore.Save(user)
fmt.Println("Generic store working!")
// Generic calculator with int type
calc := NumberCalc[int]{}
sum := calc.Add(10, 20)
fmt.Printf("10 + 20 = %d\n", sum)
}
This modern approach combines the flexibility of interfaces with the type safety of generics, providing the best of both worlds.
If you want to learn more about generics in Go, check out my comprehensive guide: Go Generics Tutorial: A Complete Introduction to Type Parameters in Go 1.18+.
Conclusion
Go interfaces represent a paradigm shift from traditional object-oriented programming, offering a more flexible and maintainable approach to software design. By embracing implicit interface satisfaction, composition over inheritance, and focused interface design, you can build systems that are both powerful and easy to understand.
The key principles to remember are:
- Start Simple: Begin with concrete types and introduce interfaces when you need the abstraction.
- Keep Interfaces Small: Follow the single responsibility principle at the interface level.
- Define Interfaces Where They're Used: Place interfaces in consuming packages to promote loose coupling.
- Embrace Composition: Use interface embedding to build complex behaviors from simple components.
- Test with Interfaces: Leverage interfaces for dependency injection and easy mocking in tests.
Whether you're building microservices, CLI tools, or complex distributed systems, mastering Go interface patterns will make you a more effective Go developer and help you write code that stands the test of time.