DEV Community

Mayra
Mayra

Posted on

Repository Pattern in Golang: A Practical Guide

The Repository Pattern, documented in Martin Fowler's "Patterns of Enterprise Application Architecture," separates data access logic from business logic. This article demonstrates its implementation in Golang through a real-world e-commerce example.

The Problem
Without proper separation, your code mixes business logic with database operations:

func ProcessOrder(orderID string) error {
    db.Query("SELECT * FROM orders WHERE id = ?", orderID)
    // Business rules buried in database code
}
Enter fullscreen mode Exit fullscreen mode

Problems:

  • Tight coupling to database
  • Hard to test
  • Code duplication
  • Can't switch databases easily

The Solution: Repository Pattern

The pattern provides an abstraction layer between business logic and data access, treating data like an in-memory collection.

Benefits:

  • Testability (easy to mock)
  • Flexibility (switch databases)
  • Clean separation of concerns
  • Centralized data access

Implementation

Domain Model

// domain/product.go
package domain

import "time"

type Product struct {
    ID          string
    Name        string
    Price       float64
    Stock       int
    Category    string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

func (p *Product) Validate() error {
    if p.Name == "" {
        return errors.New("name required")
    }
    if p.Price < 0 {
        return errors.New("invalid price")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Repository Interface

// repository/interface.go
package repository

import (
    "context"
    "github.com/yourusername/ecommerce-repository/domain"
)

type ProductRepository interface {
    Create(ctx context.Context, product *domain.Product) error
    FindByID(ctx context.Context, id string) (*domain.Product, error)
    FindAll(ctx context.Context) ([]*domain.Product, error)
    FindByCategory(ctx context.Context, category string) ([]*domain.Product, error)
    Update(ctx context.Context, product *domain.Product) error
    Delete(ctx context.Context, id string) error
}
Enter fullscreen mode Exit fullscreen mode

PostgreSQL Implementation

// repository/postgres.go
package repository

import (
    "context"
    "database/sql"
    "github.com/yourusername/ecommerce-repository/domain"
)

type PostgresProductRepository struct {
    db *sql.DB
}

func NewPostgresProductRepository(db *sql.DB) *PostgresProductRepository {
    return &PostgresProductRepository{db: db}
}

func (r *PostgresProductRepository) Create(ctx context.Context, product *domain.Product) error {
    query := `
        INSERT INTO products (id, name, price, stock, category, created_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7)
    `
    _, err := r.db.ExecContext(ctx, query,
        product.ID, product.Name, product.Price, 
        product.Stock, product.Category, time.Now(), time.Now())
    return err
}

func (r *PostgresProductRepository) FindByID(ctx context.Context, id string) (*domain.Product, error) {
    query := `SELECT id, name, price, stock, category, created_at, updated_at 
              FROM products WHERE id = $1`

    product := &domain.Product{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &product.ID, &product.Name, &product.Price,
        &product.Stock, &product.Category, &product.CreatedAt, &product.UpdatedAt)

    if err == sql.ErrNoRows {
        return nil, fmt.Errorf("product not found")
    }
    return product, err
}

func (r *PostgresProductRepository) FindByCategory(ctx context.Context, category string) ([]*domain.Product, error) {
    query := `SELECT id, name, price, stock, category, created_at, updated_at 
              FROM products WHERE category = $1`

    rows, err := r.db.QueryContext(ctx, query, category)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var products []*domain.Product
    for rows.Next() {
        p := &domain.Product{}
        err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock, 
                        &p.Category, &p.CreatedAt, &p.UpdatedAt)
        if err != nil {
            return nil, err
        }
        products = append(products, p)
    }
    return products, nil
}

func (r *PostgresProductRepository) Update(ctx context.Context, product *domain.Product) error {
    query := `UPDATE products SET name=$1, price=$2, stock=$3, 
              category=$4, updated_at=$5 WHERE id=$6`
    _, err := r.db.ExecContext(ctx, query, product.Name, product.Price,
        product.Stock, product.Category, time.Now(), product.ID)
    return err
}

func (r *PostgresProductRepository) Delete(ctx context.Context, id string) error {
    query := `DELETE FROM products WHERE id = $1`
    _, err := r.db.ExecContext(ctx, query, id)
    return err
}
Enter fullscreen mode Exit fullscreen mode

In-Memory Implementation (Testing)

// repository/memory.go
package repository

import (
    "context"
    "sync"
    "github.com/yourusername/ecommerce-repository/domain"
)

type InMemoryProductRepository struct {
    mu       sync.RWMutex
    products map[string]*domain.Product
}

func NewInMemoryProductRepository() *InMemoryProductRepository {
    return &InMemoryProductRepository{
        products: make(map[string]*domain.Product),
    }
}

func (r *InMemoryProductRepository) Create(ctx context.Context, product *domain.Product) error {
    r.mu.Lock()
    defer r.mu.Unlock()
    r.products[product.ID] = product
    return nil
}

func (r *InMemoryProductRepository) FindByID(ctx context.Context, id string) (*domain.Product, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    product, exists := r.products[id]
    if !exists {
        return nil, fmt.Errorf("not found")
    }
    return product, nil
}

func (r *InMemoryProductRepository) FindByCategory(ctx context.Context, category string) ([]*domain.Product, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    var products []*domain.Product
    for _, p := range r.products {
        if p.Category == category {
            products = append(products, p)
        }
    }
    return products, nil
}
Enter fullscreen mode Exit fullscreen mode

Business Service

// service/product_service.go
package service

import (
    "context"
    "github.com/yourusername/ecommerce-repository/domain"
    "github.com/yourusername/ecommerce-repository/repository"
)

type ProductService struct {
    repo repository.ProductRepository
}

func NewProductService(repo repository.ProductRepository) *ProductService {
    return &ProductService{repo: repo}
}

func (s *ProductService) CreateProduct(ctx context.Context, product *domain.Product) error {
    if err := product.Validate(); err != nil {
        return err
    }
    return s.repo.Create(ctx, product)
}

func (s *ProductService) GetProduct(ctx context.Context, id string) (*domain.Product, error) {
    return s.repo.FindByID(ctx, id)
}

func (s *ProductService) GetProductsByCategory(ctx context.Context, category string) ([]*domain.Product, error) {
    return s.repo.FindByCategory(ctx, category)
}
Enter fullscreen mode Exit fullscreen mode

Main Application

// main.go
package main

import (
    "database/sql"
    "log"
    "net/http"
    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
)

func main() {
    db, _ := sql.Open("postgres", "postgres://user:pass@localhost/ecommerce?sslmode=disable")
    defer db.Close()

    // Switch implementations easily
    repo := repository.NewPostgresProductRepository(db)
    // Or use: repo := repository.NewInMemoryProductRepository()

    service := service.NewProductService(repo)

    r := mux.NewRouter()
    r.HandleFunc("/products", createProduct(service)).Methods("POST")
    r.HandleFunc("/products/{id}", getProduct(service)).Methods("GET")
    r.HandleFunc("/products", listProducts(service)).Methods("GET")

    log.Fatal(http.ListenAndServe(":8080", r))
}
Enter fullscreen mode Exit fullscreen mode

Testing Example

func TestProductService(t *testing.T) {
    repo := repository.NewInMemoryProductRepository()
    service := service.NewProductService(repo)

    product := &domain.Product{
        ID: "1", Name: "Mouse", Price: 29.99, Stock: 50, Category: "electronics"}

    err := service.CreateProduct(context.Background(), product)
    if err != nil {
        t.Fatal(err)
    }

    found, _ := service.GetProduct(context.Background(), "1")
    if found.Name != "Mouse" {
        t.Errorf("expected Mouse, got %s", found.Name)
    }
}
Enter fullscreen mode Exit fullscreen mode

Database Schema

CREATE TABLE products (
    id VARCHAR(255) PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    stock INTEGER NOT NULL,
    category VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_category ON products(category);
Enter fullscreen mode Exit fullscreen mode

Running the Project

# Setup
go mod init github.com/yourusername/ecommerce-repository
go get github.com/gorilla/mux github.com/lib/pq

# Run with PostgreSQL
DATABASE_URL="postgres://user:pass@localhost/ecommerce" go run main.go

# Run with in-memory (testing)
USE_MEMORY=true go run main.go

# Test API
curl -X POST http://localhost:8080/products \
  -d '{"id":"1","name":"Mouse","price":29.99,"stock":50,"category":"electronics"}'

curl http://localhost:8080/products/1
curl http://localhost:8080/products?category=electronics
Enter fullscreen mode Exit fullscreen mode

Key Advantages

  1. Easy Testing: Swap PostgreSQL for in-memory implementation
  2. Database Independence: Change databases without touching business logic
  3. Clean Code: Each layer has one responsibility
  4. Maintainability: All queries in one place

When to Use
Use Repository Pattern when:

  • Complex business logic
  • Need to support multiple databases
  • High test coverage required
  • Large team with separate concerns

Avoid when:

  • Simple CRUD apps
  • Prototypes
  • Very small projects

Conclusion
The Repository Pattern provides a clean separation between business logic and data access. This example shows how to implement it in Golang with real code that you can use in production.

References

Top comments (1)

Collapse
 
officer47p profile image
Parsa Hosseini

What about cases where two actions must be done in a single database transaction?
I usually pass the database connection to my repository functions in case the connection is a db transaction, but I'm not sure if that's the right way.