DEV Community

Cover image for Why Interfaces Change Less Frequently Than Implementations
Kenta Takeuchi
Kenta Takeuchi

Posted on • Originally published at bmf-tech.com

Why Interfaces Change Less Frequently Than Implementations

This article was originally published on bmf-tech.com.

Reading Clean Code Cookbook: Recipes for Improving Code Design and Quality, I was intrigued by the claim that interfaces change less frequently than implementations, so I decided to articulate it.

Interfaces as "Contracts" and "Abstractions"

Interfaces represent a "contract" that states:

"This feature can be used in this way."

In contrast, implementations represent:

"The specific method of how it operates."

Both have different roles and varying degrees of resistance to change.

Layer Role Susceptibility to Change
Abstraction (interface) Purpose/Promise More stable
Concrete (implementation) Method/Means More changeable

Contracts Are Shared Externally, So They Can't Be Changed Recklessly

Changing an interface breaks all calling code that uses it.

Example:

type UserRepository interface {
    Find(id int) (*User, error)
}
Enter fullscreen mode Exit fullscreen mode

Changing it to:

type UserRepository interface {
    FindByID(ctx context.Context, id int) (*User, error)
}
Enter fullscreen mode Exit fullscreen mode

Requires all calling locations to be updated.

// Before change
user, err := repo.Find(123)

// After change
ctx := context.Background()
user, err := repo.FindByID(ctx, 123)
Enter fullscreen mode Exit fullscreen mode

Interface changes have a wide impact (i.e., they are fragile), so they are approached cautiously.

As a result, they are designed to change rarely.

Implementations Are Behind the Scenes, So They Can Be Changed Freely

Implementations are not called directly from outside.

Internal logic, caching methods, algorithms, and storage can be changed without affecting users.

type userRepository struct {
    db    *sql.DB
    cache map[int]*User // Added cache
    mu    sync.RWMutex  // For concurrency safety
}

func (r *userRepository) Find(id int) (*User, error) {
    // The interface remains unchanged, but internal implementation can be freely modified

    // Version 1: Direct DB search
    // return r.findFromDB(id)

    // Version 2: Search with cache
    r.mu.RLock()
    user, exists := r.cache[id]
    r.mu.RUnlock()

    if exists {
        return user, nil
    }

    user, err := r.findFromDB(id)
    if err == nil {
        r.mu.Lock()
        r.cache[id] = user
        r.mu.Unlock()
    }
    return user, err
}

func (r *userRepository) findFromDB(id int) (*User, error) {
    // Database access logic
    // Changing from PostgreSQL to MySQL does not affect the outside
    var user User
    err := r.db.QueryRow("SELECT * FROM users WHERE id = $1", id).Scan(&user.ID, &user.Name)
    return &user, err
}
Enter fullscreen mode Exit fullscreen mode

Implementations are targets for internal improvement, optimization, and refactoring.

Thus, they are "layers that can change frequently without breaking."

The Higher the Level of Abstraction, the More Resistant to Change

Abstraction expresses "requirements (what to do)."

Implementation expresses "means (how to do it)."

Means change, but purposes are less likely to change.

// Abstract layer (stable)
type NotificationService interface {
    Send(message string, recipient string) error
}

// Implementation layer (prone to change)
type emailNotifier struct{}
func (e *emailNotifier) Send(message, recipient string) error {
    // SMTP → SendGrid → AWS SES, etc., implementations change
}

type slackNotifier struct{}
func (s *slackNotifier) Send(message, recipient string) error {
    // Slack API implementation
}

type smsNotifier struct{}
func (s *smsNotifier) Send(message, recipient string) error {
    // Twilio → AWS SNS, etc., implementations change
}
Enter fullscreen mode Exit fullscreen mode
  • "Want to send notifications" (abstraction) is less likely to change
  • "Send via Email/Slack/SMS" (implementation) changes frequently

Therefore, interfaces as abstractions are more stable.

Relation to Go's Design Philosophy

In Go, it is customary to keep interfaces small and define them on the consumer side.

This means:

Usage (contract) is stable,
but implementation (internal logic) can be freely changed.

// Small interface (stable)
// The standard library's io.Reader has only one method
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Various implementations (prone to change)
type fileReader struct { /* File reading */ }
type networkReader struct { /* Network reading */ }
type compressedReader struct { /* Compressed file reading */ }
Enter fullscreen mode Exit fullscreen mode

Since Go lacks explicit implements declarations (structural subtyping), implementers can implement multiple interfaces without awareness, and consumers can define only the contracts they need.

This characteristic naturally leads to designing with dependency direction from "stable → unstable".

Summary

Aspect Interface Implementation
Role Function promise (contract) Actual operation (means)
Usage Exposed externally Internal only
Change Impact Large (fragile) Small (self-contained)
Result Hard to change (stable) Easy to change (frequent)
Essence "Purpose" doesn't change "Method" changes

Conclusion

Interfaces are "contracts with users," and once a contract is established, it cannot be easily changed.

On the other hand, implementations can be freely changed as long as they adhere to the contract.

Therefore, interfaces change less frequently.

Top comments (0)