DEV Community

Cover image for The Repository Pattern Everyone Gets Wrong in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Repository Pattern Everyone Gets Wrong in Go


An engineer I work with spent a full afternoon untangling an import cycle. The error message was cryptic. The fix was worse — a third package just to hold interfaces that two other packages needed. The whole mess started because the repository interface lived in the same package as its Postgres implementation.

That is the most common architectural mistake in Go codebases that were started by someone who learned design patterns in Java or C#. And the worst part: it compiles, it works, and it quietly makes every future change harder than it needs to be.

The Java-Style Repository

Here is what the pattern looks like when you bring it over from Java. You define a big interface in the repository package, right next to the struct that implements it.

package repository

import (
    "context"
    "database/sql"
    "fmt"
)

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) (int, error)
}
Enter fullscreen mode Exit fullscreen mode

The interface and the implementation sit side by side. The constructor and a representative method look like this:

type PostgresUserRepository struct {
    db *sql.DB
}

func NewPostgresUserRepository(
    db *sql.DB,
) *PostgresUserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) 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("getting user %s: %w", id, err)
    }
    return &u, nil
}

// ... six more methods follow the same pattern
Enter fullscreen mode Exit fullscreen mode

In Java, this is standard. The interface sits in the same package. The class explicitly implements it. Spring picks it up, wires it in, and everybody is happy.

In Go, this creates problems that compound as the codebase grows.

Problem 1: The Interface Is Too Big

Seven methods. Every consumer depends on all seven, even when it only calls one. Your notification service needs GetByEmail. The admin dashboard needs List and Count. The auth service calls GetByID. None of these consumers needs Delete. But they all depend on the full UserRepository interface.

That means every test double for every consumer implements seven methods. Six of them panic with "not implemented" or return zero values nobody checks. The test is lying about what it tests.

Rob Pike put it plainly in the Go proverbs: "The bigger the interface, the weaker the abstraction." Seven methods is not an abstraction. It is a copy of the implementation's method set wearing a different name.

Problem 2: Provider-Owned Contracts and Import Cycles

When the interface lives in the repository package, the provider dictates what every consumer depends on. Need to add a Search method for one consumer? You add it to the interface, and now every mock, every test double, every alternative implementation must add that method too. Even the consumers that will never call Search.

This is backwards. The consumer knows what it needs. The provider does not.

It gets worse when you wire things up. Your repository package imports your domain types. Your domain package needs the UserRepository interface to declare its dependencies. Now domain imports repository and repository imports domain. Go does not allow circular imports. You either merge the packages (defeating the purpose) or create a third interfaces package that both import.

That third package is a code smell. It exists to solve a problem that should not exist in the first place.

The Fix: Consumer-Owned Interfaces

Go interfaces are satisfied implicitly. A struct does not declare which interfaces it implements. It has methods. If those methods match, it fits. This changes everything about where interfaces belong.

The rule: define the interface in the package that calls the methods, not the package that provides them.

Here is the same codebase, restructured.

The provider package: no interface at all

The postgres package exports a concrete struct. No interface. Each method knows how to talk to the database and nothing else.

package postgres

import (
    "context"
    "database/sql"
    "fmt"

    "myapp/domain"
)

type UserRepo struct {
    db *sql.DB
}

func NewUserRepo(db *sql.DB) *UserRepo {
    return &UserRepo{db: db}
}
Enter fullscreen mode Exit fullscreen mode

The read methods follow a straightforward scan-and-return pattern:

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

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

Writes and deletes are the same shape — execute a statement, wrap the error:

func (r *UserRepo) Create(
    ctx context.Context, u *domain.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

The remaining methods (List, Count, Delete, Update) follow the same pattern. The point is that the postgres package is an adapter. It knows how to talk to Postgres. That is its only job. No interface here.

Each consumer defines exactly what it needs

The notification service only needs to look up users by email:

package notification

import (
    "context"
    "fmt"

    "myapp/domain"
)

type UserByEmailFinder interface {
    GetByEmail(
        ctx context.Context, email string,
    ) (*domain.User, error)
}
Enter fullscreen mode Exit fullscreen mode

The service struct accepts that narrow interface and nothing more:

type Service struct {
    users UserByEmailFinder
}

func New(users UserByEmailFinder) *Service {
    return &Service{users: users}
}

func (s *Service) NotifyUser(
    ctx context.Context, email, msg string,
) error {
    u, err := s.users.GetByEmail(ctx, email)
    if err != nil {
        return fmt.Errorf(
            "notifying %s: %w", email, err,
        )
    }
    return sendEmail(u.Name, u.Email, msg)
}
Enter fullscreen mode Exit fullscreen mode

One method in the interface. One method to mock in tests. The postgres.UserRepo satisfies UserByEmailFinder without importing it, without knowing it exists.

The auth service needs something different:

package auth

import (
    "context"
    "fmt"

    "myapp/domain"
)

type UserFinder interface {
    GetByID(
        ctx context.Context, id string,
    ) (*domain.User, error)
}
Enter fullscreen mode Exit fullscreen mode

And the service wires up accordingly:

type Service struct {
    users UserFinder
}

func New(users UserFinder) *Service {
    return &Service{users: users}
}

func (s *Service) Authenticate(
    ctx context.Context, userID, token string,
) (*domain.User, error) {
    u, err := s.users.GetByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf(
            "authenticating user %s: %w",
            userID, err,
        )
    }
    if err := verifyToken(u, token); err != nil {
        return nil, err
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

Same underlying Postgres struct satisfies both. No shared interface package. No import cycle.

The admin dashboard needs listing and counting:

package admin

import (
    "context"

    "myapp/domain"
)

type UserLister interface {
    List(
        ctx context.Context, limit, offset int,
    ) ([]*domain.User, error)
    Count(ctx context.Context) (int, error)
}
Enter fullscreen mode Exit fullscreen mode

Two methods, because this consumer genuinely needs both. Still narrower than seven.

type Dashboard struct {
    users UserLister
}

func NewDashboard(users UserLister) *Dashboard {
    return &Dashboard{users: users}
}

func (d *Dashboard) UsersPage(
    ctx context.Context, page, size int,
) ([]*domain.User, int, error) {
    total, err := d.users.Count(ctx)
    if err != nil {
        return nil, 0, err
    }
    offset := (page - 1) * size
    users, err := d.users.List(ctx, size, offset)
    if err != nil {
        return nil, 0, err
    }
    return users, total, nil
}
Enter fullscreen mode Exit fullscreen mode

The Testing Payoff

This is where the pattern earns its keep. Here is the test double for the notification service:

// in notification_test.go
package notification_test

type stubUserFinder struct {
    user *domain.User
    err  error
}

func (s *stubUserFinder) GetByEmail(
    _ context.Context, _ string,
) (*domain.User, error) {
    return s.user, s.err
}
Enter fullscreen mode Exit fullscreen mode

Five lines of actual logic. No mocking framework. No generated code. The test reads like documentation:

func TestNotifyUser_UserNotFound(t *testing.T) {
    stub := &stubUserFinder{
        err: domain.ErrUserNotFound,
    }
    svc := notification.New(stub)

    err := svc.NotifyUser(
        context.Background(),
        "missing@example.com",
        "hello",
    )

    if !errors.Is(err, domain.ErrUserNotFound) {
        t.Fatalf(
            "expected ErrUserNotFound, got %v", err,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Compare that to mocking a seven-method interface where six methods are irrelevant stubs. The signal-to-noise ratio is as high as it gets.

When You Do Need a Bigger Interface

Sometimes a consumer genuinely needs three or four methods from the same dependency. That is fine. Define the three-method interface at the call site. The point is not "all interfaces must have one method." The point is "each interface is shaped by what the consumer needs, not what the provider offers."

If two consumers in the same package both need GetByID and Create, you can define a composed interface:

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

type UserWriter interface {
    Create(ctx context.Context, u *domain.User) error
}

type UserStore interface {
    UserReader
    UserWriter
}
Enter fullscreen mode Exit fullscreen mode

Composition over inheritance. Small pieces you can combine. Each piece testable on its own.

The Refactoring Checklist

If you are refactoring a Go codebase that has Java-style repository interfaces, here is the mechanical process:

  1. Find every consumer of the big interface. Note which methods each consumer actually calls.
  2. Define a small interface in each consumer package containing only the methods that consumer uses.
  3. Delete the big interface from the provider package. Keep the concrete struct and its methods.
  4. Update the wiring (usually in main or a wire package) to pass the concrete struct where each consumer expects its small interface.
  5. Delete the test doubles that implemented seven methods. Write new ones that implement one or two.

The concrete struct already satisfies every consumer-side interface implicitly. No code changes needed on the implementation. The compiler confirms the contract for you.

The Standard Library Already Does This

This is not a niche pattern. It is how Go's standard library is designed.

io.Reader has one method. io.Writer has one method. io.ReadWriter composes both. The os.File struct satisfies all three without declaring any of them. The http.Handler interface has one method. Most HTTP middleware in Go composes because of that single-method interface.

The standard library does not define a FileInterface next to os.File. It defines io.Reader where reading happens, io.Writer where writing happens. The consumer owns the contract. The provider has methods.

Your repository layer should work the same way.


If this post saved you an afternoon

Interface placement and dependency direction are two chapters of my book Hexagonal Architecture in Go. It goes deeper into port design, adapter testing, and the patterns that keep Go services clean as they grow. If you are building services where the domain should not know about Postgres, Kafka, or any other infrastructure detail, the book walks through production-grade examples of exactly that.

I also build Hermes IDE — an IDE for developers who ship with Claude Code and other AI coding tools. If you spend your days in a terminal writing Go, it might be worth a look.

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

Top comments (0)