The latest release of Go (1.18) added generics to the language, besides fuzzing, workspaces and performance improvements. [1] So let's use them to implement the repository pattern, which is a way to abstract data handling from your business logic. When working on REST-like APIs you want to split handling of data from and into some kind of data store (e.g. a database, a file, ...) from business logic. This is where the repository pattern comes in handy. Especially with Go 1.18 and the introduction of generics, this is a good example for its usage to don't have to write (more or less) the same code again and again.
There is a more formal and longer definition, that you can check out. [2]
Overview
In this example we will create a generics repository and store some objects in these. We will use simple user
and pet
structs, without any complex internals. A user looks like this:
type User struct {
id uuid.UUID
Name string
Hobbies []string
}
The user must also implement the user interface, as we want to use the ID()
method later in our generic repository.
type User interface {
ID() uuid.UUID
}
Introduction to generics
Generics look more or less like in any other language. We will cover generic structs (and methods), generic functions, their instantiation and usage. I also don't care about error handling in all these examples, I will simply panic if anything went wrong.
Generic structs
This is a generic struct of a repository:
type GenericRepository[T storableConstraint] struct {
Store map[uuid.UUID]T
}
The repository will store added objects in a map with a uuid.UUID
key and a value T
, which will be the actual type of the struct the repository will be created for.
A notable thing is the storableConstraint
. A constraint can define methods that a struct or interface used to construct this generic repository must provide. In this case the storableConstraint
looks like this:
type storableConstraint interface {
ID() uuid.UUID
}
This means everything I want to store in a generic repository must implement a ID()
method that returns a uuid.UUID
attribute.
Let's have a look on a method on this repository, e.g. the Add
method.
func (t *GenericRepository[T]) Add(item T) error {
_, ok := t.Store[item.ID()]
if ok {
return nil
}
t.Store[item.ID()] = item
return nil
}
As our storableConstraint
ensures that every object has a ID()
method, we can us it to get the ID of this object. The ID will then be used as the unique identifier in our internal store.
Generic functions
Besides generic structs and methods on these structs, we can also create generic functions. So lets say we want to transform one object into another one, we provide object T
as input, but want to get object U
as return value.
Another new thing with Go 1.18 is the any
keyword. any
replaces interface{}
if you don't have a type for something. In this case we use any
as the constraint, which means that every interface or struct is allowed to be transformed. Which is naive to use in real world application, but will suffice for this example.
We can create a simple function that will look like this:
func transform[T,U any](incoming T) (U, error) {
...
}
For a single generic value, the type can mostly be inferred and doesn't need to be provided while calling the function. In this case we have to provide it, so the function call looks like this:
pet, err := transform[User, Pet](user)
if err != nil {
panic(err)
}
Instantiation
So let's instantiate our generic repository, create a user and add it to the repository. In this case we will instantiate the repository with the user interface instead of the user struct. Please mind that the NewUser()
function does return a interfaces.User
instead of a User
, so we ensure that a User
actually implements the interface.
userRepository := NewGenericRepository[interfaces.User]()
user1 := NewUser("Peter", []string{"1", "2"})
err := userRepository.Add(user1)
if err != nil {
panic(err)
}
userFromRepo, err := userRepository.Get(user1.ID())
if err != nil {
panic(err)
}
fmt.Println(userFromRepo)
We then create a new user and add it to the created repository. Then the repository can be queried with the user id, and we get our earlier added user back.
As the repository is a generic we can simply add another type, that fulfills our earlier defined constraint.
petRepository := NewGenericRepository[interfaces.Pet]()
petOfPeter := NewPet("Peter's Cat", user1.ID())
err = petRepository.Add(petOfPeter)
if err != nil {
panic(err)
}
petFromRepo, err := petRepository.Get(petOfPeter.ID())
if err != nil {
panic(err)
}
fmt.Println(petFromRepo)
So as you can see, this repository can be used to store any object that implements a ID()
method. This is also very handy if you want to mock a repository used in tests.
The whole example can be found in my code repository [3].
Conclusion
To be honest I was a bit skeptic about the addition of generics to go, as I thought with existing and good integrated tooling for code generation go didn't need them. But I was clearly wrong! The generics implementation and usage feels very simple and therefore go-like, and I really enjoy using them. Especially when writing simple APIs or mocks for some tests, generics come in very handy and can save quite some time.
If you are more interested in the internals, I can recommend this excellent post from Vicent Marti. It will show how go implements generics, but also how generics can make your code run slower. [4]