Golang Tutorial: Generics with Gorm

- 9 minutes read - 1733 words

Can we expect to have a comprehensive ORM and DBAL framework, similar to Doctrine, for Go?

After months and years of talking, trying things out, and testing, we’ve finally reached a big moment in our favorite programming language. The new Golang version, 1.18, is here. We knew it would bring significant changes to Go’s codebase, even before Generics was officially released. For a long time, when we wanted to make our code more general and abstract, we used code generators in Go. Learning what the “Go way” of doing things was challenging for many of us, but it also led to many breakthroughs. It was worth the effort. Now, there are new possibilities on the horizon.

Many new packages have emerged, giving us ideas on how we can improve the Go ecosystem with reusable code that makes life easier for all of us. This inspiration led me to create a small proof of concept using the Gorm library. Now, let’s take a look at it.

Source code

When I wrote this article, it relied on a GitHub Repository. The code served as a Go library proof of concept, with my intention to continue working on it. However, it was not yet suitable for production use, and I had no plans to offer production support at that time.

You can find the current features by following the link, and below, there is a smaller sample snippet.

Example Usage

package main

import (
  	"github.com/ompluscator/gorm-generics"
	// some imports
)

// Product is a domain entity
type Product struct {
	// some fields
}

// ProductGorm is DTO used to map Product entity to database
type ProductGorm struct {
	// some fields
}

// ToEntity respects the gorm_generics.GormModel interface
func (g ProductGorm) ToEntity() Product {
	return Product{
		// some fields
	}
}

// FromEntity respects the gorm_generics.GormModel interface
func (g ProductGorm) FromEntity(product Product) interface{} {
	return ProductGorm{
		// some fields
	}
}

func main() {
	db, err := gorm.Open(/* DB connection string */)
	// handle error

	err = db.AutoMigrate(ProductGorm{})
	// handle error

	// initialize a new Repository with by providing
	// GORM model and Entity as type
	repository := gorm_generics.NewRepository[ProductGorm, Product](db)

	ctx := context.Background()

	// create new Entity
	product := Product{
		// some fields
	}
	
	// send new Entity to Repository for storing
	err = repository.Insert(ctx, &product)
	// handle error

	fmt.Println(product)
	// Out:
	// {1 product1 100 true}

	single, err := repository.FindByID(ctx, product.ID)
	// handle error
	
	fmt.Println(single)
	// Out:
	// {1 product1 100 true}
}

Why have I picked ORM for PoC?

Coming from a background in software development with traditional object-oriented programming languages like Java, C#, and PHP, one of the first things I did was search Google for a suitable ORM for Golang. Please forgive my inexperience at the time, but that’s what I was expecting. It’s not that I can’t work without an ORM. It’s just that I don’t particularly like how raw MySQL queries appear in the code. All that string concatenation looks messy to me. On the other hand, I always prefer to dive right into writing business logic, with minimal time spent thinking about the underlying data storage. Sometimes, during the implementation, I change my mind and switch to different types of storage. That’s where ORMs come in handy.

In summary, ORM provides me with:

  1. Cleaner code.
  2. More flexibility in choosing the type of underlying data storage.
  3. The ability to focus entirely on business logic rather than technical details.

There are many ORM [solutions](https://github.com/d-tsuji/awesome-go-orms solutions) available for Golang, and I’ve used most of them. Not surprisingly, I’ve used GORM the most because it covers a wide range of features. Yes, it lacks some well-known patterns like Identity Map, Unit of Work, and Lazy Load, but I can work without them. However, I have often missed the Repository pattern because I’ve encountered duplicated or very similar code blocks from time to time (and I really dislike repeating myself).

For that purpose, I sometimes used the GNORM library, which had templating logic that allowed me to create Repository structures with freedom. While I liked the idea that GNORM presented (very much in line with The Golang Way!), constantly updating templates to add new features to the Repository didn’t look good. I attempted to provide my own implementation that relied on reflection and share it with the Open Source community. Unfortunately, it didn’t go as planned. It worked, but maintaining the library was painful, and its performance was not exceptional. In the end, I deleted the GitHub repository. And just as I was giving up on this ORM upgrade in Go, Generics came into play. Oh, boy. Oh, boy! I was back to the drawing board immediately.

Implementation

Part of my background involves Domain-Driven Design. This means I prefer to separate the domain layer from the infrastructure layer. Some ORMs treat the Entity pattern more like a Row Data Gateway or Active Record. However, because its name references the DDD pattern Entity, we can sometimes get confused and end up mixing business logic and technical details in the same class, creating a kind of monster.

The Entity pattern isn’t related to mapping to a database table schema or the underlying storage in any way. So, I always use Entity in the domain layer and Data Access Objects (DAO in the infrastructure layer. The signature of my Repositories always supports only Entity, but internally, they use DTO to map data to and from a database and fetch and store them into Entity. This approach guarantees a functional Anti-Corruption Layer.

In this case, I work with a trio of interfaces and structures, as you can see in the diagram below:

  1. Entity, which holds business logic in the domain layer.
  2. GormModel, serving as a DAO used to map data from Entity into a database.
  3. GormRepository, functioning as an orchestrator for querying and persisting data.

Gorm Generics

Two main parts, GormModel and GormRepository, require generic types to define the signatures of their methods. Utilizing generics enables us to specify GormRepository as a struct and create a more generalized implementation:

GormRepository methods

func (r *GormRepository[M, E]) Insert(ctx context.Context, entity *E) error {
  	// map the data from Entity to DTO
	var start M
	model := start.FromEntity(*entity).(M)

  	// create new record in the database
	err := r.db.WithContext(ctx).Create(&model).Error
	// handle error

  	// map fresh record's data into Entity
	*entity = model.ToEntity()
	return nil
}

func (r *GormRepository[M, E]) FindByID(ctx context.Context, id uint) (E, error) {
  	// retrieve a record by id from a database
	var model M
	err := r.db.WithContext(ctx).First(&model, id).Error
	// handle error

  	// map data into Entity
	return model.ToEntity(), nil
}

func (r *GormRepository[M, E]) Find(ctx context.Context, specification Specification) ([]E, error) {
  	// retreive reords by some criteria
	var models []M
	err := r.db.WithContext(ctx).Where(specification.GetQuery(), specification.GetValues()...).Find(&models).Error
	// handle error

  	// mapp all records into Entities
	result := make([]E, 0, len(models))
	for _, row := range models {
		result = append(result, row.ToEntity())
	}

	return result, nil
}

I didn’t intend to add more or less complex features like preloading, joins, or even limit and offset for this proof of concept. The idea was to test the simplicity of implementing generics in Go with the GORM library. In the code snippet, you can see that the GormRepository struct supports inserting new records, retrieving records by identity, and querying by Specification.

The Specification pattern is another pattern from Domain-Driven Design that we can use for various purposes, including querying data from storage. The proof of concept provided here defines a Specification interface, which provides a WHERE clause and the values used inside it. This does require some usage of generics for comparable operators and could potentially serve as a precursor for a future Query Object:

Specification example

type Specification interface {
	GetQuery() string
	GetValues() []any
}

// joinSpecification is the real implementation of Specification interface.
// It is used fo AND and OR operators.
type joinSpecification struct {
	specifications []Specification
	separator      string
}

// GetQuery concats all subqueries
func (s joinSpecification) GetQuery() string {
	queries := make([]string, 0, len(s.specifications))

	for _, spec := range s.specifications {
		queries = append(queries, spec.GetQuery())
	}

	return strings.Join(queries, fmt.Sprintf(" %s ", s.separator))
}

// GetQuery concats all subvalues
func (s joinSpecification) GetValues() []any {
	values := make([]any, 0)

	for _, spec := range s.specifications {
		values = append(values, spec.GetValues()...)
	}

	return values
}

// And delivers AND operator as Specification
func And(specifications ...Specification) Specification {
	return joinSpecification{
		specifications: specifications,
		separator:      "AND",
	}
}

// notSpecification negates sub-Specification
type notSpecification struct {
	Specification
}

// GetQuery negates subquery
func (s notSpecification) GetQuery() string {
	return fmt.Sprintf(" NOT (%s)", s.Specification.GetQuery())
}

// Not delivers NOT operator as Specification
func Not(specification Specification) Specification {
	return notSpecification{
		specification,
	}
}

// binaryOperatorSpecification defines binary operator as Specification
// It is used for =, >, <, >=, <= operators.
type binaryOperatorSpecification[T any] struct {
	field    string
	operator string
	value    T
}

// GetQuery builds query for binary operator
func (s binaryOperatorSpecification[T]) GetQuery() string {
	return fmt.Sprintf("%s %s ?", s.field, s.operator)
}

// GetValues returns a value for binary operator
func (s binaryOperatorSpecification[T]) GetValues() []any {
	return []any{s.value}
}

// Not delivers = operator as Specification
func Equal[T any](field string, value T) Specification {
	return binaryOperatorSpecification[T]{
		field:    field,
		operator: "=",
		value:    value,
	}
}

The Specification part of the package offers the ability to provide custom criteria to the Repository and fetch data that meets those criteria. It allows for combining, negating, and extending criteria as needed.

Results

This implementation ultimately achieves the main objective of this proof of concept, which is to create a generalized interface for querying records from the database.

Outcome

err := repository.Insert(ctx, &Product{
    Name:        "product2",
    Weight:      50,
    IsAvailable: true,
})
// error handling

err = repository.Insert(ctx, &Product{
    Name:        "product3",
    Weight:      250,
    IsAvailable: false,
})
// error handling

many, err := repository.Find(ctx, gorm_generics.And(
    gorm_generics.GreaterOrEqual("weight", 90),
    gorm_generics.Equal("is_available", true)),
)
// error handling

fmt.Println(many)
// Out:
// [{1 product1 100 true}]

Concerning my aspirations, the code snippet from above delivers a quick and elegant way to retrieve data in a clean and readable form. And without affecting performance (significantly).

Conclusion

Exploring generics for the first time following the official release of Go 1.18 was quite refreshing. I’ve been facing some challenges lately, and having this opportunity for new ideas was just what I needed. Additionally, resuming my blogging after a long break was something I felt compelled to do. It’s wonderful to share my opinions publicly once more, and I’m eagerly anticipating all the feedback you folks can provide.

Useful Resources

comments powered by Disqus