Go Programming

Unlocking Type Safety: Implementing Generic Repository Patterns in Go for Database Abstraction

For years, Go developers have prided themselves on simplicity and explicit code. However, as applications grow in complexity, repetitive boilerplate for database operations often clutters codebases. Before Go 1.18, implementing a generic repository layer required either cumbersome interface gymnastics or copy-pasting logic across different entities. With the introduction of Generics, Go has evolved into a language capable of supporting robust design patterns without sacrificing its core philosophy of simplicity.

In this post, we will explore how to implement a Generic Repository pattern in modern Go. We will create a reusable abstraction that allows you to perform common CRUD (Create, Read, Update, Delete) operations on any entity, drastically reducing boilerplate while maintaining type safety.

Why Use a Generic Repository?

The Repository pattern acts as a mediator between the domain and data mapping layers. Its primary goal is to separate business logic from data access logic. In a typical Go application, you might find yourself writing nearly identical query structures for a User, Post, and Comment model.

Without generics, achieving this reusability often involves passing interfaces or using reflection, both of which can lead to runtime errors or performance overhead. Generics allow us to define a single interface or struct that works with any type T, ensuring that the database layer knows exactly what fields exist at compile time.

Defining the Generic Interface

Let's start by defining a generic interface that outlines the basic operations. This interface will be parameterized by a type T. For this example, we will assume we are using a hypothetical ORM or SQL driver, but the pattern remains consistent regardless of the underlying data library.

package repository

import (
	"context"
	"errors"
)

// Entity represents a generic database entity with an ID field.
// In a real application, you might use a more complex interface or require
// specific tags for ORM mapping.
type Entity interface {
	GetID() any
}

// ErrNotFound is returned when an entity is not found.
var ErrNotFound = errors.New("entity not found")

// GenericRepository defines standard CRUD operations for any entity type T.
type GenericRepository[T Entity] interface {
	Create(ctx context.Context, entity T) error
	GetByID(ctx context.Context, id any) (T, error)
	Update(ctx context.Context, entity T) error
	Delete(ctx context.Context, id any) error
	List(ctx context.Context) ([]T, error)
}

Notice that T must satisfy the Entity constraint. This ensures that any type passed to our repository has a GetID() method, which is crucial for update and delete operations.

Implementing the Concrete Repository

Now, let's implement a concrete type that satisfies this interface. While we won't write a full ORM here, we can demonstrate the structure of the implementation. We'll use a map as a mock data store to keep the example focused on the pattern rather than database connectivity.

package repository

import (
	"context"
	"fmt"
	"sync"
)

// MockRepository is a generic implementation for demonstration purposes.
type MockRepository[T Entity] struct {
	mu    sync.Mutex
	Store map[any]T
}

// NewMockRepository creates a new instance of the mock repository.
func NewMockRepository[T Entity]() *MockRepository[T] {
	return &MockRepository[T]{
		Store: make(map[any]T),
	}
}

// Create adds a new entity to the store.
func (r *MockRepository[T]) Create(ctx context.Context, entity T) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	
	id := entity.GetID()
	if _, exists := r.Store[id]; exists {
		return fmt.Errorf("entity with ID %v already exists", id)
	}
	r.Store[id] = entity
	return nil
}

// GetByID retrieves an entity by its ID.
func (r *MockRepository[T]) GetByID(ctx context.Context, id any) (T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	
	entity, exists := r.Store[id]
	if !exists {
		var zero T
		return zero, ErrNotFound
	}
	return entity, nil
}

// List returns all entities in the store.
func (r *MockRepository[T]) List(ctx context.Context) ([]T, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	
	entities := make([]T, 0, len(r.Store))
	for _, v := range r.Store {
		entities = append(entities, v)
	}
	return entities, nil
}

By using MockRepository[T Entity], we ensure that the compiler enforces that only types implementing Entity can be used with this repository. If you try to create a repository for a struct that lacks a GetID() method, the code will fail to compile.

Practical Usage Example

Let's see how to use this pattern in a service layer. Define a simple user struct:

type User struct {
	ID   int64
	Name string
}

func (u User) GetID() any {
	return u.ID
}

You can now instantiate a repository specifically for Users:

userRepo := repository.NewMockRepository[User]()
err := userRepo.Create(context.Background(), User{ID: 1, Name: "Alice"})
if err != nil {
    log.Fatal(err)
}

user, err := userRepo.GetByID(context.Background(), 1)
if err == repository.ErrNotFound {
    log.Println("User not found")
} else if err != nil {
    log.Fatal(err)
}
fmt.Printf("Retrieved user: %+v\n", user)

Conclusion

Implementing Generic Repository patterns in Go offers a powerful way to abstract database logic while leveraging the type safety that Go developers love. It eliminates repetitive code, makes testing easier by allowing simple mock implementations, and provides a clear contract for data access.

While this example uses a mock store, the same principles apply when integrating with libraries like GORM, sqlx, or ent. By adopting generics, you can write more expressive, maintainable, and scalable Go applications.

Share: