Right, let’s talk about the Repository pattern. You’ve probably heard of it. It’s the one that’s supposed to save you from the database, acting as a persistent in-memory collection. In Go, its implementation is a beautiful study in pragmatism, where we take a fancy academic pattern and bash it against the rocks of reality until it works for us, not against us.

The core idea is simple: you want to decouple your core business logic (the stuff that makes you money) from the nitty-gritty details of how you shove data into a storage system (the stuff that gives you migraines). Your UserService shouldn’t care if its data comes from PostgreSQL, a giant CSV file, or a psychic octopus. The Repository is the mediator. It speaks in terms of your domain objects (User, Order) and translates those into the crude language of SELECT * FROM... or collection.Find().

Why Bother? The Great Decoupling

The “why” is more important than the “how” here. If you hardwire SQL into your service functions, you’ve created a monster. Testing becomes a nightmare—you either mock the database driver (a special kind of hell) or run a test database, which is slow and brittle. Changing databases? Forget it. That’s a full rewrite. The Repository pattern gives you a clean seam. For testing your UserService, you can mock the UserRepository interface with a simple map-based in-memory version. It’s trivial, fast, and lets you focus on the business logic, not the plumbing. It’s not just about testing, though; it’s about keeping your options open and your code sane.

The Go Twist: It’s All About the Interface

Here’s where Go’s simplicity shines. You don’t need a heavyweight framework or complex base class. You just need a well-defined interface. This is the contract your business logic will depend on.

package user

// User is our domain model. Notice: no JSON or DB tags here.
// This is pure business, not persistence.
type User struct {
    ID        int
    Email     string
    IsActive  bool
}

// UserRepository defines the persistent operations we can perform on a User.
// This is the magic. Your service only knows about this interface.
type UserRepository interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
    Save(user *User) error
    // In a real app, you'd have more: Update, Delete, FindAll, etc.
}

// The beauty? Your service doesn't care about the implementation.
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) RegisterUser(email string) error {
    // First, check if the user exists (uses the interface)
    existing, _ := s.repo.FindByEmail(email)
    if existing != nil {
        return fmt.Errorf("user already exists")
    }
    
    // Create the new domain object
    user := &User{
        Email:    email,
        IsActive: true,
    }
    
    // Save it (uses the interface again)
    return s.repo.Save(user)
}

Now, your UserService is blissfully unaware of the outside world. It’s just moving domain objects around. This is the goal.

The Implementation: Getting Your Hands Dirty

The interface is the promise. Now we need to fulfill it. Let’s create a concrete implementation for PostgreSQL. This is where we get dirty with SQL and database drivers.

package pg

// PostgreSQLUserRepository implements the user.UserRepository interface.
// This is the only file that should know about SQL and the database.
type PostgreSQLUserRepository struct {
    db *sql.DB
}

func NewPostgreSQLUserRepository(db *sql.DB) *PostgreSQLUserRepository {
    return &PostgreSQLUserRepository{db: db}
}

func (r *PostgreSQLUserRepository) FindByID(id int) (*user.User, error) {
    // Look at this. It returns a *user.User. It fulfills the contract.
    query := `SELECT id, email, is_active FROM users WHERE id = $1`
    row := r.db.QueryRow(query, id)
    
    var u user.User
    err := row.Scan(&u.ID, &u.Email, &u.IsActive)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            // Translate the database error into a domain-friendly outcome.
            return nil, nil // Not found? Return nil, nil.
        }
        return nil, fmt.Errorf("error finding user by id: %w", err)
    }
    return &u, nil
}

func (r *PostgreSQLUserRepository) Save(u *user.User) error {
    if u.ID == 0 {
        // This is a new user, so INSERT
        query := `INSERT INTO users (email, is_active) VALUES ($1, $2) RETURNING id`
        err := r.db.QueryRow(query, u.Email, u.IsActive).Scan(&u.ID)
        if err != nil {
            return fmt.Errorf("error saving user: %w", err)
        }
        return nil
    }
    // Otherwise, it's an UPDATE. You get the idea.
    return fmt.Errorf("update not implemented for brevity")
}

See what happened there? The pg package imports the user package, not the other way around. Your core domain doesn’t know anything about the database. This dependency direction is crucial.

Common Pitfalls and The Rough Edges

  1. The Over-Abstracted Repository: Don’t fall into the generic repository trap. I’ve seen folks try to create a GenericRepository[User] with methods like GetAll(). This almost always leaks persistence details (like SQL queries for filtering/sorting) back into your service layer. Your repository methods should be purposeful and based on the actual queries your application needs, like FindActiveUsers() or FindOrdersByCustomerID. Be specific.

  2. Transaction Handling: This is the big one. The repository pattern can make transactions awkward. Where do you put Begin(), Commit(), and Rollback()? Putting them on the repository interface is wrong—a transaction often spans multiple repositories (e.g., debiting one account and crediting another). The best practice in Go is to create a separate Transactioner interface (e.g., BeginTx(context.Context) (*sql.Tx, error)) and inject a *sql.Tx (which implements *sql.DB’s methods) into your repositories for the duration of a operation. It’s a bit more manual but gives you perfect control.

  3. Context Propagation: For the love of all that is holy, pass context.Context through your repository methods. Your FindByID signature should be FindByID(ctx context.Context, id int) (*User, error). This allows for timeouts, cancellation, and distributed tracing to flow all the way down to the database call. It’s non-negotiable for any serious application.

The Repository pattern in Go isn’t about dogmatic adherence to theory. It’s a practical tool for writing testable, maintainable, and flexible code. Use it to build a wall between your business logic and your data access code. Your future self, desperately trying to fix a bug at 2 AM, will thank you for it.