Accept interfaces, return structs

There’s a Go proverb you might have heard: accept interfaces, return structs. It sounds straightforward, but when I first started with Go, it took me a while before the benefits really clicked.

The problem: tight coupling

Let’s say you’re building a user service. You have a database package that handles persistence:

 1package db
 2
 3import "database/sql"
 4
 5type DB struct {
 6    conn *sql.DB
 7}
 8
 9func NewDB(conn *sql.DB) *DB {
10    return &DB{conn: conn}
11}
12
13type User struct {
14    ID       string
15    Username string
16    Email    string
17}
18
19func (db *DB) CreateUser(ctx context.Context, user *User) error {
20    // database implementation
21}
22
23func (db *DB) GetUserByID(ctx context.Context, userID string) (*User, error) {
24    // database implementation
25}

And a user service that uses it:

 1package user
 2
 3import "yourapp/db"
 4
 5type Service struct {
 6    db *db.DB
 7}
 8
 9func NewService(database *db.DB) *Service {
10    return &Service{db: database}
11}
12
13func (s *Service) RegisterUser(ctx context.Context, username, email string) (*db.User, error) {
14    user := &db.User{
15        ID:       generateID(),
16        Username: username,
17        Email:    email,
18    }
19
20    if err := s.db.CreateUser(ctx, user); err != nil {
21        return nil, err
22    }
23
24    return user, nil
25}

This looks reasonable at first, but there are problems:

  1. Misplaced domain model: User is in the db package, but it’s a domain concept, not a database concern. It feels wrong there.
  2. Can’t test without a database: To test user.Service, you need a real *db.DB. No mocks, no in-memory stores.
  3. Tight coupling: The service is tied to the exact database implementation.

The typical workaround is moving User to a shared models package. But now you have a grab-bag package that everyone imports, and you still can’t test without the database.

What does “accept interfaces, return structs” actually mean?

The idea is straightforward:

Here’s the same example, redesigned. The user service defines what it needs:

 1package user
 2
 3type User struct {
 4    ID       string
 5    Username string
 6    Email    string
 7}
 8
 9type userStore interface {
10    CreateUser(ctx context.Context, user *User) error
11    GetUserByID(ctx context.Context, userID string) (*User, error)
12    UpdateUser(ctx context.Context, user *User) error
13    DeleteUser(ctx context.Context, userID string) error
14}
15
16type Service struct {
17    userStore userStore
18}
19
20func NewService(userStore userStore) *Service {
21    return &Service{userStore: userStore}
22}
23
24func (s *Service) RegisterUser(ctx context.Context, username, email string) (*User, error) {
25    user := &User{
26        ID:       generateID(),
27        Username: username,
28        Email:    email,
29    }
30
31    if err := s.userStore.CreateUser(ctx, user); err != nil {
32        return nil, err
33    }
34
35    return user, nil
36}

Notice what changed:

No circular dependencies, no shared models package.

Why accept interfaces?

When you define an interface at the point of use, you’re being explicit about your actual requirements. The user.Service above doesn’t need a full database connection. It needs four methods. That’s it.

Since your code depends on an interface, you can swap in a mock for tests:

 1package user
 2
 3type mockUserStore struct {
 4    user *User
 5}
 6
 7func (m *mockUserStore) CreateUser(ctx context.Context, user *User) error {
 8    return nil
 9}
10
11func (m *mockUserStore) GetUserByID(ctx context.Context, id string) (*User, error) {
12    return m.user, nil
13}
14
15func (m *mockUserStore) UpdateUser(ctx context.Context, user *User) error {
16    return nil
17}
18
19func (m *mockUserStore) DeleteUser(ctx context.Context, id string) error {
20    return nil
21}

Now you can test your service logic without touching a database:

 1func TestService_GetUser(t *testing.T) {
 2    expectedUser := &User{ID: "user-123", Username: "testuser"}
 3
 4    store := &mockUserStore{
 5        user: expectedUser,
 6    }
 7
 8    service := NewService(store)
 9
10    user, err := service.GetUser(ctx, "user-123")
11
12    assert.NoError(t, err)
13    assert.Equal(t, expectedUser, user)
14}

No database setup, no cleanup. Just fast, deterministic unit tests.

The same interface can be satisfied by completely different implementations:

Your service doesn’t know or care which one it gets.

Why return structs?

When you return a concrete type, the caller knows exactly what they’re getting. They can see all the methods, all the fields (if exported), and the compiler can help them use it correctly.

1package db
2
3func NewUserStore(pool *pgxpool.Pool) *UserStore {
4    return &UserStore{
5        pool: pool,
6    }
7}

Anyone calling db.NewUserStore knows they’re getting a *db.UserStore. No guessing, no type assertions needed.

In Go, interfaces are satisfied implicitly - there’s no implements keyword. This means the consumer of a package should define what interface they need, not the producer.

Your db.UserStore doesn’t need to know it implements user.userStore. It just has methods. The user.Service defines the interface it requires, and Go’s type system figures out the rest.

If you return an interface from your constructor, you’re forcing all callers to use that interface. But different callers might need different subsets of functionality. By returning a struct, each consumer can define their own interface with just the methods they actually use.

Putting it together

Here’s how this looks in practice. You have a database package with a concrete store:

 1package db
 2
 3import "yourapp/user"
 4
 5type UserStore struct {
 6    pool *pgxpool.Pool
 7}
 8
 9func NewUserStore(pool *pgxpool.Pool) *UserStore {
10    return &UserStore{pool: pool}
11}
12
13func (s *UserStore) CreateUser(ctx context.Context, u *user.User) error {
14    // actual database implementation
15}
16
17func (s *UserStore) GetUserByID(ctx context.Context, userID string) (*user.User, error) {
18    // actual database implementation
19}
20
21// ... more methods

And a service package that defines what it needs:

 1package user
 2
 3type userStore interface {
 4    CreateUser(ctx context.Context, user *User) error
 5    GetUserByID(ctx context.Context, userID string) (*User, error)
 6    UpdateUser(ctx context.Context, user *User) error
 7    DeleteUser(ctx context.Context, userID string) error
 8}
 9
10type Service struct {
11    userStore userStore
12}
13
14func NewService(userStore userStore) *Service {
15    return &Service{userStore: userStore}
16}

In your main package, you wire them together:

1pool := connectToDatabase()
2userStore := db.NewUserStore(pool)
3userService := user.NewService(userStore)

The db.UserStore satisfies the user.userStore. The service is testable, the store is reusable, and the dependency flows in one direction.

To summarize

This pattern gives you three things that are hard to get any other way:

  1. Your domain logic lives where it belongs - User is in the user package, not scattered across db or models
  2. Testing becomes trivial - swap implementations without mocking frameworks or test databases
  3. Dependencies flow one direction - high-level packages define interfaces, low-level packages implement them

It’s not about being dogmatic. Sometimes you need to return an interface - particularly when you’re building a library and need to hide implementation details. But as a default? Accept interfaces, return structs. Your future self will thank you.