Implementing a Generic Repository Pattern in Golang Applications

In software development, managing data access is a crucial part of building maintainable and scalable applications. One of the design patterns widely used to handle this responsibility is the Repository Pattern. In this blog, we’ll explore the concept of the repository pattern, its importance in application architecture, and how Go’s generics can elevate its implementation.
In the real-world project, it’s a common pattern that an application has several layers, like repository, service, and controller. The repository is responsible as the data access layer that communicates to the database. So, this layer will handle all queries to the database. Through the blog, we will only focus on this repository layer.
Design Pattern: Repository Pattern
The repository pattern acts as an abstraction layer between the data source (e.g., databases) and the application’s business logic. Instead of directly interacting with the database, the application communicates through repositories, which handle data access responsibilities.
Why Use the Repository Pattern?
- Separation of Concerns
It decouples data access logic from business logic, making the application more modular and easier to maintain. - Improved Testability
Since repositories abstract the data source, you can mock them during testing, avoiding reliance on actual databases. - Flexibility and Reusability
With a centralized data access layer, switching databases or modifying schemas becomes less intrusive. - Consistency
By using repositories, you enforce uniform data access patterns, reducing duplication and potential bugs.
Generics in Go
Go introduced generics in version 1.18, allowing developers to write type-safe and reusable code. Before generics, Go developers often relied on interfaces or type assertions, which were less efficient and error-prone for certain use cases. Generics solve this problem by enabling functions, methods, and types to operate on types specified as parameters.
Simple Example of Generics
Here’s a basic example to demonstrate how generics simplify repetitive operations: (this example isn’t related to the repository pattern)
package main
import "fmt"
// Generic function to find the maximum of two values
func Max[T comparable](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(10, 20)) // Output: 20
fmt.Println(Max("apple", "zebra")) // Output: zebra
}
Key Points in the Example:
T
Is a placeholder for any type.- The
comparable
constraint ensures that the type supports comparison (e.g.,<
or>
). - The function is reusable for different types like integers and strings without needing separate implementations.
Why Use Generics in Repositories?
When implementing the repository pattern, generics enable you to create a single repository interface or struct that works across multiple entity types. This drastically reduces boilerplate code while maintaining type safety. For instance:
- Before Generics: You might need to implement separate CRUD logic for each entity type.
- With Generics: A single implementation can handle all entities, making your code cleaner and easier to maintain.
Implementation: Using a Generic Repository Pattern in Go
We’ll see how generics in Go can simplify the implementation of the repository pattern for a multi-role system. We’ll implement a generic repository that reduces code duplication and maintains type safety while handling operations. Let’s say, we have an application that has three user roles: student, staff, and admin.
The main idea is to create a reusable, type-safe repository struct using Go’s generics (Repository[T]
). This struct encapsulates common CRUD operations, which can be used by specific repositories for different entities like student
, staff
, and admin
.
Benefits of a Generic Repository
- Less Boilerplate Code
With the generic repository, common operations likeCreate
,GetAll
,GetByID
,Update
, andDelete
are implemented once and reused for all entities. This eliminates the need to redefine these methods for every user role. - Consistency
By centralizing data access logic in a generic repository, you ensure consistent handling of database operations across all entities. - Ease of Maintenance
Any improvement or bug fix to a common method (e.g.,Update
) propagates to all repositories automatically. - Type Safety
Generics enforce strong typing, ensuring compile-time checks for all operations, and reducing runtime errors.
Practical Example
Generic Repository (Repository[T]
):
This struct provides common data access methods such as Create
, GetByID
, Update
, Delete
, and more. It uses generics (T
) to allow reuse across multiple entity types while remaining type-safe. We will use MongoDB, by the way. Of course, you can use any database that you want.
Example user_repository.go
const (
ErrUserNotFound = "user not found"
ErrIdInvalid = "invalid ID"
)
type Repository[T any] struct {
collection *mongo.Collection
}
func (r *Repository[T]) Create(ctx context.Context, doc *T) (*T, error) {
_, err := r.collection.InsertOne(ctx, doc)
if err != nil {
return nil, fmt.Errorf("failed to insert document: %w", err)
}
return doc, nil
}
func (r *Repository[T]) GetAll(ctx context.Context) ([]*T, error) {
cursor, err := r.collection.Find(ctx, bson.M{})
if err != nil {
return nil, fmt.Errorf("failed to get documents: %v", err)
}
defer cursor.Close(ctx)
var results []*T
_ = cursor.All(ctx, &results)
return results, nil
}
func (r *Repository[T]) GetByEmail(ctx context.Context, email string) (*T, error) {
var result T
err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&result)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
return &result, err
}
func (r *Repository[T]) GetByID(ctx context.Context, id string) (*T, error) {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, errors.New(ErrIdInvalid)
}
var result T
err = r.collection.FindOne(ctx, bson.M{"_id": objectID}).Decode(&result)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
return &result, err
}
func (r *Repository[T]) Update(ctx context.Context, id string, updateDoc *T) (*T, error) {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return nil, errors.New(ErrIdInvalid)
}
filter := bson.M{"_id": objectID}
update := bson.M{"$set": updateDoc}
result, err := r.collection.UpdateOne(ctx, filter, update)
if err != nil || result.MatchedCount == 0 {
return nil, errors.New(ErrUserNotFound)
}
return updateDoc, nil
}
func (r *Repository[T]) Delete(ctx context.Context, id string) error {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return errors.New(ErrIdInvalid)
}
result, err := r.collection.DeleteOne(ctx, bson.M{"_id": objectID})
if err != nil || result.DeletedCount == 0 {
return errors.New(ErrUserNotFound)
}
return nil
}
func (r *Repository[T]) CountByEmail(ctx context.Context, email string) (int64, error) {
count, err := r.collection.CountDocuments(ctx, bson.M{"email": email})
if err != nil {
return 0, err
}
return count, nil
}
func (r *Repository[T]) CountByID(ctx context.Context, id string) (int64, error) {
objectID, err := primitive.ObjectIDFromHex(id)
if err != nil {
return 0, errors.New(ErrIdInvalid)
}
count, err := r.collection.CountDocuments(ctx, bson.M{"_id": objectID})
if err != nil {
return 0, err
}
return count, nil
}
Role-Specific Repositories:
Each role (e.g., Student
, Staff
, Admin
) has its own repository that embeds the generic repository. This allows you to add role-specific methods while reusing the generic methods.
Example admin_repository.go
type AdminRepositoryInterface interface {
CreateAdmin(ctx context.Context, admin *models.Admin) (*models.Admin, error)
GetByID(ctx context.Context, id string) (*models.Admin, error)
GetByEmail(ctx context.Context, email string) (*models.Admin, error)
GetAll(ctx context.Context) ([]*models.Admin, error)
Update(ctx context.Context, id string, admin *models.Admin) (*models.Admin, error)
Delete(ctx context.Context, id string) error
CountByEmail(ctx context.Context, email string) (int64, error)
CountByID(ctx context.Context, id string) (int64, error)
}
type AdminRepository struct {
Repository[models.Admin]
log *slog.Logger
}
func NewAdminRepository(db *mongo.Database, log *slog.Logger) AdminRepositoryInterface {
return &AdminRepository{
Repository: Repository[models.Admin]{collection: db.Collection("admins")},
log: log,
}
}
func (r *AdminRepository) CreateAdmin(ctx context.Context, admin *models.Admin) (*models.Admin, error) {
admin.ID = primitive.NewObjectID()
return r.Create(ctx, admin)
}
Example student_repository.go
type StudentRepository interface {
CreateStudent(ctx context.Context, student *models.Student) (*models.Student, error)
GetByID(ctx context.Context, id string) (*models.Student, error)
GetByUUID(ctx context.Context, studentUuid string) (*models.Student, error)
GetByEmail(ctx context.Context, email string) (*models.Student, error)
GetAll(ctx context.Context) ([]*models.Student, error)
Update(ctx context.Context, id string, student *models.Student) (*models.Student, error)
Delete(ctx context.Context, id string) error
CountByEmail(ctx context.Context, email string) (int64, error)
}
type studentRepository struct {
Repository[models.Student]
log *slog.Logger
}
func NewStudentRepository(db *mongo.Database, log *slog.Logger) StudentRepository {
return &studentRepository{
Repository: Repository[models.Student]{collection: db.Collection("students")},
log: log,
}
}
func (r *studentRepository) CreateStudent(ctx context.Context, student *models.Student) (*models.Student, error) {
student.ID = primitive.NewObjectID()
return r.Create(ctx, student)
}
For the staff role, the implementation is very similar to the previous two roles.
Extensibility for Custom Methods:
If a role requires specific logic (e.g., GetByMajor
for students), you can define those methods in the role-specific repository without affecting others.
// don't forget to add method signature to the student inteface
func (r *studentRepository) GetByMajor(ctx context.Context, majorId) ([]*models.Student, error) {
var result []models.Student
err := r.collection.FindMany(ctx, bson.M{"major_id": majorId}).Decode(&result)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, err
}
return &result, err
}
Centralized Initialization
Role-specific repositories are instantiated using a factory function (NewStudentRepository
, NewStaffRepository
, NewAdminRepository
), which wires up the MongoDB collection and logging.
Conclusion
Using a generic repository pattern in Go simplifies data access by centralizing shared operations, reducing redundancy, and ensuring type safety. This approach not only saves development time but also aligns with best practices for maintainable and scalable application architectures.
With this setup, role-specific repositories remain concise, focusing only on unique logic, while leveraging the power of generics for all common database operations. This is a robust solution for applications managing multiple entities, such as systems with diverse user roles.
Happy coding!