Better Programming

Advice for programmers.

Follow publication

Go Generic Repo

Practical application of Go Generics

Denis J.
Better Programming
Published in
7 min readApr 11, 2023
Image by author | Uses Go’s mascot

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:

  1. Define how we want to consume the behavior of our repository through tests
  2. Define a generic repository interface
  3. Create a generic repository interface implementation
  4. 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.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Denis J.
Denis J.

Written by Denis J.

Just a guy exploring topics in software engineering.

No responses yet

Write a response