Go Generic Repo
Practical application of Go Generics

Introduction
Go generics are a long-awaited feature that became available in Go 1.18. This article will show how to use them to create a generic repository that can be used to store various types of data. Here is an overview of what we will do:
- Define how we want to consume the behavior of our repository through tests
- Define a generic repository interface
- Create a generic repository interface implementation
- Create an extended repository interface for one of the models
For this example, we will have an in-memory repository implementation that will store data in a map. We will have two models that we will store in the repository: Driver
and Vehicle
. We want to be able to use the same implementation and have access to the same core CRUD methods for both models. Here are the tests we will use to define the behavior of our repository:
func TestInMemRepo_Create(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, err := dr.Create(newDriver("John"))
if err != nil {
t.Errorf("error: %v", err)
}
d, err := dr.FindByID(id)
if err != nil {
t.Errorf("error: %v", err)
}
if d.Name != "John" {
t.Errorf("name is not John")
}
}
func TestInMemRepo_FindAll(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
_, _ = dr.Create(newDriver("John"))
_, _ = dr.Create(newDriver("Jane"))
d, err := dr.FindAll()
if err != nil {
t.Errorf("error: %v", err)
}
if len(d) != 2 {
t.Errorf("length is not 2")
}
}
func TestInMemRepo_Update(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, _ := dr.Create(newDriver("John"))
driver := newDriver("Jane")
driver.SetID(id)
err := dr.Update(driver)
if err != nil {
t.Errorf("error: %v", err)
}
d, _ := dr.FindByID(id)
if d.Name != "Jane" {
t.Errorf("name is not Jane")
}
}
func TestInMemRepo_DeleteByID(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
id, _ := dr.Create(newDriver("John"))
err := dr.DeleteByID(id)
if err != nil {
t.Errorf("error: %v", err)
}
_, err = dr.FindByID(id)
if err.Error() != "not found" {
t.Errorf("error is nil but should be not found")
}
}
func TestInMemRepo_DeleteByID_NotFound(t *testing.T) {
dr := newInMemRepo[Driver](make(map[int]Driver))
err := dr.DeleteByID(1)
if err.Error() != "not found" {
t.Errorf("error is nil but should be not found")
}
}
These tests are not exhaustive, but they will give us a good starting point. If you try to run this code, it fails since we have not defined any of the methods we are calling nor the models.
Repository Interface with Generics
Let’s define the Repository (Repo) interface and some constraints for our models:
type Repo[T Model] interface {
Create(entity T) (int, error)
Update(entity T) error
FindByID(id int) (*T, error)
FindAll() ([]*T, error)
DeleteByID(id int) error
}
type Model interface {
GetID() int
SetID(id int)
SetCreatedAt(createdAt string)
SetUpdatedAt(updatedAt string)
}
type Base struct {
ID int `json:"id"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (b *Base) GetID() int {
return b.ID
}
func (b *Base) SetID(id int) {
b.ID = id
}
func (b *Base) SetCreatedAt(createdAt string) {
b.CreatedAt = createdAt
}
func (b *Base) SetUpdatedAt(updatedAt string) {
b.UpdatedAt = updatedAt
}
The Repo
interface defines the behavior we want to expose on the repository. Our generic notation Repo[T Model]
means that we can use any type that implements the Model
interface. We also define a Base
type that will be embedded in our models. By embedding the Base
type, we can use the methods defined on it. This is a common pattern in Go to avoid code duplication.
As you can see, our repository does not know the models we will be using. We could be stricter with our Model
interface and specifically constrain it to the Vehicle
and Driver
models with type Model interface { Vehilce | Driver }
. However, this would make our repository less flexible, and we would have to change it if we wanted to add a new model. I am not saying you should always use the most generic approach, but I think it is appropriate in this case.
In Memory Repository Implementation
Well, this is all well and good, but our tests are still failing. Let’s implement the InMemRepo
type that will satisfy the Repo
interface:
type inMemRepo[T Model] struct {
data map[int]T
mu *sync.Mutex
}
func newInMemRepo[T Model](data map[int]T) *inMemRepo[T] {
return &inMemRepo[T]{data, &sync.Mutex{}}
}
func (i *inMemRepo[T]) Create(t T) (int, error) {
i.mu.Lock()
defer i.mu.Unlock()
id := randomID()
t.SetID(id)
t.SetCreatedAt(time.Now().Format(time.RFC3339))
t.SetUpdatedAt(time.Now().Format(time.RFC3339))
i.data[id] = t
return id, nil
}
func (i *inMemRepo[T]) FindByID(id int) (*T, error) {
if t, ok := i.data[id]; ok {
return &t, nil
}
return nil, fmt.Errorf("not found")
}
func (i *inMemRepo[T]) Update(t T) error {
i.mu.Lock()
defer i.mu.Unlock()
t.SetUpdatedAt(time.Now().Format(time.RFC3339))
i.data[t.GetID()] = t
return nil
}
func (i *inMemRepo[T]) DeleteByID(id int) error {
i.mu.Lock()
defer i.mu.Unlock()
if _, ok := i.data[id]; !ok {
return fmt.Errorf("not found")
}
delete(i.data, id)
return nil
}
func (i *inMemRepo[T]) FindAll() ([]*T, error) {
var result []*T
for _, v := range i.data {
result = append(result, &v)
}
return result, nil
}
func randomID() int {
seed := time.Now().UnixNano()
rand.Seed(seed)
min := 1_000_0000
max := 9_999_9999
return rand.Intn(max-min) + min
}
This is a pretty straightforward implementation. We use a map to store the data, and we implement the methods defined on the Repo
interface. We also define a newInMemRepo
constructor function that will return a new repository instance. If you have a look at the Create
and Update
methods, you will notice that we are using the Base
type methods to set the ID
and date fields. This allows the models to be unaware of such repository-specific logic.
Let’s also define a couple of models that we will use in our tests:
type Driver struct {
*Base
Name string `json:"name"`
}
func newDriver(name string) Driver {
return Driver{Name: name, Base: &Base{}}
}
type Vehicle struct {
*Base
Make string `json:"make"`
}
func newVehicle(make string) Vehicle {
return Vehicle{Make: make, Base: &Base{}}
}
These are very simple models, but they will be enough to test the behavior of our repository.
If you run the tests now, everything should pass, and we should have full statement coverage:
go test -cover ./... ok deni1688/generic-repo 0.189s coverage: 100.0% of statements
Extended Repository Interface for Vehicle
Well, now we have a generic repository that can be used to store any model. However, we should add some repository-specific methods for a specific model that are not part of the core CRUD operations. For example, we can find a vehicle by its make. This is very common in other ORMs like Hibernate or Entity Framework. Let’s see how we can do this with Go Generics, but first, we write the test:
func TestInMemVehicleRepo_FindByMake(t *testing.T) {
vr := newInMemVehicleRepo(make(map[int]Vehicle))
_, _ = vr.Create(newVehicle("Ford"))
id, _ := vr.Create(newVehicle("Volvo"))
vehicles, err := vr.FindByMake("Volvo")
if err != nil {
t.Errorf("error: %v", err)
}
if len(vehicles) != 1 {
t.Errorf("length is not 1")
}
if vehicles[0].GetID() != id {
t.Errorf("id is not %d", id)
}
}
As you can see, our constructor has slightly changed. We now have a newInMemVehicleRepo
constructor that will return a VehicleRepo
interface implementation. This interface will be satisfied by the InMemRepo
type, but it will also expose the FindByMake
:
type VehicleRepo interface {
Repo[Vehicle]
FindByMake(model string) ([]*Vehicle, error)
}
type inMemVehicleRepo struct {
*inMemRepo[Vehicle]
}
func newInMemVehicleRepo(data map[int]Vehicle) *inMemVehicleRepo {
return &inMemVehicleRepo{inMemRepo: newInMemRepo[Vehicle](data)}
}
func (i *inMemVehicleRepo) FindByMake(make string) ([]*Vehicle, error) {
var result []*Vehicle
for _, v := range i.data {
if v.Make == make {
result = append(result, &v)
}
}
return result, nil
}
The VehicleRepo
interface is a simple extension of the Repo
interface. We define a new method FindByMake
that will return a slice of Vehicle
models. The newInMemVehicleRepo
constructor will return an instance of the inMemVehicleRepo
type which embeds the inMemRepo[Vehicle]
type. We then implement the new FindByMake
method with the inMemVehicleRepo
as the pointer receiver. That is how the Repo
interface is satisfied. I kept this all in the same file for simplicity, but you could easily split it into multiple files depending on your requirements.
As before, if you run the tests now, everything should pass, and we should have full statement coverage again.
Conclusion
Personally, I have not had many use cases for generics, but this was one of the first ones that came to mind since I had an implementation of a repository that was using the interface{}
type to get similar behavior. That was a bit more verbose and required more boilerplate code, but it worked.
I think the new approach is much cleaner and easier to use and maintain. Not to say there were not any challenges. For instance, adding pointer receiver methods on the models was not an option since you cannot pass a pointer to a generic type. The base was the simplest solution to this, but it has a small caveat of the model having to create an empty instance of the base type in their constructor.
However, that was specifically a problem because I was using this in-memory data store for testing. In a real-world scenario, the database would handle the ID and date fields, and your models would not need to know them.
I hope you enjoyed this article and found it useful.
References
Want to Connect?
If you have any questions or comments, please feel free to leave them below
or connect with me on Twitter @BitsByDenis.
Originally published at https://blog.bitsbydenis.de on April 11, 2023.