DEV Community

Cover image for What Every PHP, Java, and C# Developer Gets Wrong About Go Interfaces
Gabriel Anhaia
Gabriel Anhaia

Posted on

What Every PHP, Java, and C# Developer Gets Wrong About Go Interfaces


A Java developer writes a Go interface. It has eight methods. Three of them are never called in production. Two exist because the mock had to return something in tests nobody reads. One was added because the team planned a second implementation that never happened. The Go code around it struggles because the interface was designed in a language that isn't there.

You have probably written one of these. If you came to Go from PHP, Java, or C#, you probably wrote it last week.

The interface is not the problem. The habit is. In the languages you came from, interfaces are contracts the provider declares. You open the file where the class lives, you write implements UserRepositoryInterface, you ship. In Go, that instinct is backwards, and the longer you keep it, the more your Go code will fight you.

classDiagram
    class IUserService {
        +CreateUser
        +UpdateUser
        +DeleteUser
        +ListUsers
        +FindByID
        +FindByEmail
        +ChangePassword
        +VerifyEmail
    }
    class UserController {
        -svc IUserService
    }
    UserController --> IUserService : depends on
Enter fullscreen mode Exit fullscreen mode

The Habit You Brought With You

Here is the shape every PHP, Java, and C# developer produces in their first Go service. Names change, but the anatomy is identical.

// package user
package user

type UserRepository interface {
    GetByID(ctx context.Context, id string) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    List(ctx context.Context, limit, offset int) ([]*User, error)
    Create(ctx context.Context, u *User) error
    Update(ctx context.Context, u *User) error
    Delete(ctx context.Context, id string) error
    Count(ctx context.Context) (int64, error)
    ExistsByEmail(ctx context.Context, email string) (bool, error)
}

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
    // ...
}
// ... seven more methods, all in the same file
Enter fullscreen mode Exit fullscreen mode

The interface lives next to the implementation. The implementation imports the interface. The package exports both. This is not a design accident. It is muscle memory from the languages that taught you what code is supposed to look like.

In Java or C#, this file is correct. The interface is the contract. The class declares it. A DI container wires it. Your tests pass a mock and you are done.

In Go, three things go wrong the moment you write this.

First, the interface lies about what the caller needs. Somewhere else in the codebase, a service uses one method from UserRepository. That service now has a test double that implements all eight. When a new method gets added to the interface, every mock in the codebase breaks, even the ones that never needed the method.

Second, the dependency arrow points the wrong way. The user package defines and exports the abstraction. Any consumer that wants to depend on the abstraction must import user. The domain now depends on the package that should have been a detail.

Third, you cannot stub a real implementation without knowing the interface exists. In Java this is enforced by implements. In Go, the implementation does not name the interface, so defining the interface on the producer side is pure ceremony. It buys you nothing the compiler needs.

Go Satisfies Interfaces Without Being Told

This is the language feature everyone knows and nobody uses correctly. A Go type satisfies an interface by having the right methods. There is no keyword. No registration. No cast.

// package postgres — the producer. No interface in sight.
package postgres

type UserRepo struct {
    db *sql.DB
}

func (r *UserRepo) GetByID(ctx context.Context, id string) (*User, error) {
    row := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = $1", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("fetching user %s: %w", id, err)
    }
    return &u, nil
}

func (r *UserRepo) Create(ctx context.Context, u *User) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
        u.ID, u.Name, u.Email)
    if err != nil {
        return fmt.Errorf("creating user: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

That is the whole file. Two methods. No interface declaration. No implements. You do not need to predict every future consumer or stamp a contract into the package. The type has methods. Any consumer that needs a subset of those methods can declare its own interface, and this type will satisfy it.

This is not a stylistic preference. It is how the language wants to be used. The moment you accept it, the code you write starts shrinking.

Accept Interfaces, Return Structs

The Go proverb is five words long and it encodes the whole idea. Functions accept interfaces from callers, because callers might want to pass different concrete types. Functions return concrete structs, because return values are specific things, not abstractions.

Applied to the archetype above, the consumer looks like this.

// package order — the consumer. Defines what it needs, nothing more.
package order

type userFinder interface {
    GetByID(ctx context.Context, id string) (*user.User, error)
}

type Service struct {
    users userFinder
}

func NewService(users userFinder) *Service {
    return &Service{users: users}
}

func (s *Service) PlaceOrder(ctx context.Context, userID string, items []Item) (*Order, error) {
    u, err := s.users.GetByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("placing order: %w", err)
    }
    if !u.Active {
        return nil, ErrUserInactive
    }
    // ...
    return &Order{}, nil
}
Enter fullscreen mode Exit fullscreen mode

The order package defines userFinder with exactly one method. It does not know or care that postgres.UserRepo has seven others. It does not import any interface from the user or postgres packages. It gets a thing with GetByID, and Go wires the rest.

Test doubles collapse to what they needed to be the whole time.

type fakeUserFinder struct {
    users map[string]*user.User
}

func (f *fakeUserFinder) GetByID(_ context.Context, id string) (*user.User, error) {
    u, ok := f.users[id]
    if !ok {
        return nil, user.ErrNotFound
    }
    return u, nil
}

func TestPlaceOrder_InactiveUser(t *testing.T) {
    fake := &fakeUserFinder{users: map[string]*user.User{
        "u1": {ID: "u1", Active: false},
    }}
    svc := order.NewService(fake)

    _, err := svc.PlaceOrder(ctx, "u1", nil)
    if !errors.Is(err, order.ErrUserInactive) {
        t.Fatalf("want ErrUserInactive, got %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

No mock generator. No expects(...).times(1). A struct with one method and a map. The test runs in microseconds because there is nothing there to slow it down.

Why Small Interfaces Win

The second Go proverb worth tattooing: the bigger the interface, the weaker the abstraction.

When your interface has eight methods, it tells the reader very little about what a given call site actually uses. Is PlaceOrder going to write to the database? Read from it? Both? You cannot answer without reading the function body. The interface has stopped being a contract and started being a grab bag.

When your interface has one method, the call site tells you everything. userFinder reads. userCreator writes. userLister paginates. A function that takes a userFinder cannot write, because writing is not in the type. The compiler enforces the thing your documentation was trying to say.

Interface segregation, the I in SOLID, is something you had to teach yourself in Java. In Go you fall into it by accident the first time you let a consumer define its own interface. It is not a principle you apply. It is what the idiomatic path produces.

When a consumer genuinely needs two capabilities, you compose.

type userFinder interface {
    GetByID(ctx context.Context, id string) (*user.User, error)
}

type userUpdater interface {
    Update(ctx context.Context, u *user.User) error
}

type userStore interface {
    userFinder
    userUpdater
}
Enter fullscreen mode Exit fullscreen mode

The composed type reads like an English sentence. A service that needs both takes a userStore. A service that only reads takes a userFinder. The same postgres.UserRepo satisfies all three without knowing any of them exist.

The Hexagonal Architecture Connection

If you have read anything about hexagonal architecture, you know the vocabulary. Ports and adapters. The domain defines ports. Infrastructure provides adapters. Dependencies point inward.

In Java, this is a project. You add an interface layer. You wire a DI container. You write configuration. You argue about whether the interface goes in the application package or the domain package. You debate whether @Component or @Service is correct. The architecture ends up being real, but every line of it was paid for.

In Go, consumer-defined interfaces are ports. That is the whole mapping.

Your domain package defines the tiny interfaces it needs to do its work. Those interfaces are ports. A postgres package, an http client package, a kafka producer package sit in infrastructure and provide types that satisfy those interfaces. Those types are adapters. The wiring happens in main.go, where concrete adapters get passed into domain constructors. There is no container. There is no framework. The compiler does the work.

// main.go
func main() {
    db := mustOpenDB()
    users := &postgres.UserRepo{DB: db}   // adapter
    svc := order.NewService(users)        // domain accepts the port it defined
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The dependency arrow you were trying to enforce with annotations in Java is the default here. The domain does not import postgres. The postgres package does not import any interface from the domain. They meet at the boundary where main does the wiring, and that is the only place coupling lives.

When You Should Define an Interface on the Producer Side

There is a caveat the Go community does not repeat enough, and pretending it does not exist is how you end up with the opposite problem: a codebase of one-method interfaces scattered so widely you cannot find the real API.

Define an interface on the producer side when the producer is genuinely polymorphic. io.Reader, io.Writer, http.Handler, sql.Driver. These live with the producer because there are many real implementations and the interface is the point of the package. If your package is shipping a family of things that all do the same job, the interface goes with them.

The rule is not "never define interfaces on the producer side." The rule is: you need a reason. One current implementation and one planned consumer is not a reason. Two current implementations with different capabilities is a reason. A public package where users will bring their own type is a reason.

If you cannot name the reason, the interface belongs to the caller.

What Changes in Your Head

The shift is smaller than it sounds, and it takes longer than it should. You stop asking "what contract does this module expose" and start asking "what does this function need from its caller." You stop writing interfaces in the file where the struct lives. You stop generating mocks. Your test files get shorter. Your package graph gets flatter. go doc starts returning things worth reading.

If your Go service has an internal/interfaces directory, you have imported a Java habit that does not pay rent here. Delete it. Push the types into the packages that consume them. Keep the concrete structs where they are. Let the compiler satisfy the abstractions it was built to satisfy.

A Java developer writes a Go interface. It has eight methods. After a month in Go, the same developer writes one method. After a year, they write none, and wait for a second consumer before adding an abstraction at all. That is the progression.

You can skip most of the year by unlearning the first file you wrote.


classDiagram
    class UserFinder {
        <<interface>>
        +FindByID id string
    }
    class PasswordHandler {
        <<interface>>
        +VerifyPassword
    }
    class AuthController {
        -finder UserFinder
        -pw PasswordHandler
    }
    class UserRepo {
        +FindByID
        +VerifyPassword
        +ListUsers
        +CreateUser
    }
    AuthController --> UserFinder
    AuthController --> PasswordHandler
    UserRepo ..> UserFinder : satisfies
    UserRepo ..> PasswordHandler : satisfies
Enter fullscreen mode Exit fullscreen mode

If this was useful

Consumer-defined ports are the foundation of hexagonal architecture in Go, and the mapping is tight enough that the two topics are hard to separate. Hexagonal Architecture in Go is the book that makes the connection explicit: how interfaces become ports, how ports stay small, how adapters stay dumb, and how the whole thing stays testable without a framework. This post is Chapter 5 in condensed form. The book walks through a real service end to end, with the wiring, the trade-offs, and the patterns the short version leaves out.

Hexagonal Architecture in Go — the book

Thinking in Go — 2-book series on Go programming and hexagonal architecture

Top comments (0)