DEV Community

Cover image for The Repository Pattern Done Right: Consumer-Defined Interfaces in Go
Ivan Korostenskij
Ivan Korostenskij

Posted on

The Repository Pattern Done Right: Consumer-Defined Interfaces in Go

The Repository Pattern

We’ve all inherited it: a critical 500-line function with SQL Jenga blocks precariously placed between error handling, business logic, and an API call. You feel a natural instinct to refactor it, separate concerns; that’s the right call! However, the pattern most tutorials teach you to accomplish this just creates a different kind of mess.

I’m talking about the repository pattern: an approach to separate your business layer (business logic) from your data layer (persistence and retrieval from a database).

Understanding what drives this pattern - and where the standard implementation goes wrong - will permanently change how you structure database logic. The end result is maintainable, testable, and readable code.

Martin Fowler describes this as mediating interaction, “… between the domain and data mapping layers using a collection-like interface.” Let’s break down what that actually means, and how it’s been so misinterpreted.

The (short) theory

The fundamentals are exactly the same between good and bad approaches.

1) The “collections-like interface” is this: an interface that defines database methods:

type UserRepository interface {
    GetUserBirthdayByID(ctx context.Context, userId uint) (*time.Time, error)
    CreateUser(ctx context.Context, p *User) error
    DeleteUser(ctx context.Context, p *User) error
    ...
}
Enter fullscreen mode Exit fullscreen mode

2) The implementation is a struct with a single Unit of Work (database session) attribute.

I highly recommend this article for a deep dive on the Unit of Work





type UserStore struct { // implements UserRepository
   uow *persistence.UnitofWork
}
func (us *UserStore) GetUserBirthdayByID(ctx context.Context, userId uint) (*time.Time, error) {
        var bDay time.Time
        err := us.uow.Session.WithContext(ctx).Table("users").
        Select("id", "birth_date").Where("id = ?", userId).
        Scan(&bDay).Error
    return bDay, err
}
func (us *UserStore) CreateUser(ctx context.Context, p *User) error {
        ...
}
func (us *UserStore) DeleteUser(ctx context.Context, p *User) error {
        ...
}
Enter fullscreen mode Exit fullscreen mode

A single decision decides whether this approach becomes pure tech debt or maintainable, readable code: where you put it.

The Bad Way

To create a code version of the Atlantic garbage patch, follow outdated tutorials and define a single, giant interface of database methods per table.

// Lives in: src/everything/anything_to_do_with_users_repository_and_motorcycle_parts_slash_comic_book_store.go

type UserRepository interface {
    GetUserBirthdayByID(ctx context.Context, userId uint) (*time.Time, error)
    CreateUser(ctx context.Context, p *User) error
    DeleteUser(ctx context.Context, p *User) error
    // ... 50 more methods for every niche edge case
}
Enter fullscreen mode Exit fullscreen mode

This now serves as mini dumping grounds of every method any service needs, across your application.

This is a producer-defined interface — the repository declares everything it can do, and every consumer must accept the whole thing. Any test that touches this needs to mock all 50+ methods.

Let’s see how this approach scales, starting fresh.

Two engineers - Sarah and Mike - start developing separate features, working with user data. Sarah needs a AddOrUpgradeUserSubscriptionTier database method to upgrade paying users (or add them if they’re on a Free account).

type UserRepository interface {
    AddOrUpgradeUserSubscriptionTier(ctx context.Context, userId uint, t Tier) error
}
Enter fullscreen mode Exit fullscreen mode

Mike now needs a method that adds time, in days, to a user’s subscription. It’s somewhat related, but he doesn’t have a choice where to put it - it goes in the hole.

He can either:

1) Widen the existing method: Expand Sarah’s query to accommodate his work, forcing all of its callers to follow the unrelated contract he shoved into it

type UserRepository interface {
    AddOrUpgradeUserSubscriptionTierOrTime(ctx context.Context, userId uint, t *Tier, t *time.Time) error
}
Enter fullscreen mode Exit fullscreen mode

2) Add a near-duplicate method: Break the interface segregation principle so every service working with users now has +1 extra useless method, adding a single-use, near-identical method

type UserRepository interface {
    AddOrUpgradeUserSubscriptionTier(ctx context.Context, userId uint, t Tier) error
    AddOrUpgradeUserSubscriptionTimer(ctx context.Context, userId uint, t *time.Time) error
}
Enter fullscreen mode Exit fullscreen mode

Neither are good. The interface grows either way and becomes a Pandora’s box of hundreds of tangentially related queries. The implementation of this interface is even worse: easily breaking 10k+ lines of unoptimized SQL in a single file as the unavoidable blast radius of each subsequent change climbs.

“The bigger the interface, the weaker the abstraction."

Eventually, we have a god object that is injected into all parts of your code that have to touch user data. Testing your single method becomes a game of ensuring the other 50+ are mocked.

So, how can solely changing the location of these methods transform this approach into the gold standard for maintainable database logic? By spreading it back out.

The good way

Martin Fowler didn’t say the, “collection-like interface[s]” need to be Go files that are thousands of lines long. So let’s make them smaller and more focused. Instead of defining a 1000-point Swiss army knife for your application, we let each service define exactly what it needs: just a screwdriver; a hammer and 3 nails; a butter knife and some tweezers.

We switch from producer-defined interfaces to consumer-defined interfaces, making specialized, mini-repositories per feature.

In Go, the Service defines the interface, and the Repository satisfies it.

// src/features/notifications/service.go
package notifications

// service-owned interface (declaration of the db methods it needs, modular)
type NotificationStore interface {
    GetLastNotified(ctx context.Context, userId uint) (time.Time, error)
    MarkAsNotified(ctx context.Context, userId uint) error
}

type Service struct {
    store NotificationStore
    service NotificationService
}

func (s *Service) NotifyUser(ctx context.Context, userId uint) error {
    last, err := s.store.GetLastNotified(ctx, userId)
    if err != nil { return err }

    if time.Since(last) < 24*time.Hour {
        return nil
    }
    if err := s.service.NotifyUserById(userId); err != nil {
        return err
    }

    return s.store.MarkAsNotified(ctx, userId)
}
Enter fullscreen mode Exit fullscreen mode

The implementation lives in the same feature folder, satisfying the interface of the service.

// src/features/notifications/notification_database.go
package notifications

type PostgresStore struct {
   uow *persistence.UnitofWork
}

func (ps *PostgresStore) GetLastNotified(ctx context.Context, userId uint) (time.Time, error) {
    ...
}

func (ps *PostgresStore) MarkAsNotified(ctx context.Context, userId uint) error {
        ...
}
Enter fullscreen mode Exit fullscreen mode

This gives us clean, modular interfaces; readable and maintainable; testability; and a great separation of concerns between features - at the cost of an interface.

The drawback of this approach is repetition. If 5 services need a GetUser() method, are we going to implement it 5 times?

Let’s look at the nuances of this approach, how those 5 services probably aren’t using same GetUser() method you think they are, and how, “a little copying is better than a little dependency” - Go Proverbs.

Nuance (with pushback)

A different GetUser() method per feature seems crazy - I’m with you; what happened to Don’t Repeat Yourself (DRY)?

Upfront: application-wide repositories are okay and can absolutely be the right move. But they are often not needed.

Let’s see who’s calling this GetUser() method:

  • Authentication service
  • Notification service
  • Payment service

Each of these callers needs different parts of a user’s data for radically different purposes; a user in the context of billing is fundamentally a different entity than a user in the context of authentication.

A) The authentication service wants the user’s username & password; B) the payment service just needs the user’s payment info; C) the notification service only needs an email and the last time they were notified.

Making a global GetUser() method that returns a god User object that has these + 50 more attributes - just to satisfy all callers - sounds eerily similar to the interface explosion problem we just solved.

type User struct {
    ID                int64
    Username          string
    Email             string
    PasswordHash      string 

    PlanID            string
    SubscriptionStatus string
    StripeCustomerID  string
    TrialEndsAt       *time.Time

    LastLoginAt       *time.Time
    ...
    // 50 + more
}
Enter fullscreen mode Exit fullscreen mode

Now every test that touches user data has to construct this entire struct, even if the feature only cares about two fields.

I challenge you to always start local:

// src/features/notification_service/repository.go
type MikesNotificationService interface {
    GetUserLastUTCTimeNotified(ctx context.Context, userId uint) (time.Time, error)
    GetAllPremiumUsersWithPendingNotifications(ctx context.Context, userId uint) ([]NotificationUser, error)
}

// A user specialized for the service
type NotificationUser struct {
    ID                int64
  ContactMethod     string
  ContactAddress    string
    LastNotifiedAt    *time.Time
    HasPendingNotif   bool
    ...
}
Enter fullscreen mode Exit fullscreen mode

Allow queries to be “repeated”. Forcing generic CRUD methods to work with 5 disparate features increases bad coupling, maintenance cost, and lines of code - as the User object blows up to accommodate working with everything. Specific, business-case queries are the way to go.

Lean into high-affinity coupling: components that change together belong together in the same package, not sparsely connected by a generic method. Individual implementations should be able to naturally evolve with their service - at little risk to the rest of the application. Requirements will change; the question is whether your architecture will fight or accommodate that.

Conclusion

Bad code is rarely a skill issue; it’s a pattern issue. The wrong abstraction taught confidently in a tutorial does more damage than no abstraction at all. Knowing the benefits of the right solution matters just as much as knowing the pitfalls of the wrong one; you now have a solid grasp on both.

It wasn’t Mike that caused a pileup with his addition of a method, it was starting with the wrong pattern. Bad decisions compound. So set the standard: make a consumer-defined interface today so you aren’t fighting someone else’s 3000-line producer-defined interface six months from now.

Next time you’re adding a database method to a shared repository, setting up a new feature, or want to refactor that 500-line Jenga block, pause for a second. Ask yourself: “does every function in my codebase need LaunchUserIntoSpaceQuery(), or just the one I’m working with?”

I’m Ivan, follow me for more content like this! :)

Top comments (1)

Collapse
 
abinadi_cordova_db5fa1792 profile image
Abinadi Cordova

This is great advice overall—idiomatic Go, small interfaces, better testing, less coupling—and it directly addresses a real issue with bloated repository layers. The core idea (consumer-defined interfaces) is solid and aligns with how Go is meant to be written. The only thing I’d caution is that this can drift into over-engineering if applied blindly. It really shines in larger or messier codebases where interfaces have already grown out of control; in smaller projects, you likely won’t feel enough pain to justify the added abstraction, and a simple shared repository can be perfectly fine.

Don’t adopt it just because it sounds like a best practice—it’s situational. It can also work against you if you’re trying to maintain strong domain boundaries (like in DDD), or if it leads to duplicated queries and fragmented data access. A well-designed shared repository is not inherently bad; the problem is poorly scoped ones. This approach is less necessary when you have a small codebase or solo project, straightforward CRUD with stable models, strong domain boundaries (DDD-style aggregates), a need for a single source of truth for queries, or low testing complexity where mocks aren’t painful yet.