Implementing a MongoDB CRUD API Using Go Generics
I had to write a simple CRUD REST API for a project I'm working on that contains quite a bit of database models in MongoDB. I started writing a controller and a service for each model, but I quickly realized that I was writing the same code over and over again.
Surely there must be a better way to do this.
Go Generics to the Rescue
Go 1.18 introduced generics, which allows you to write functions and data structures that can work with any type.
This is perfect for my use case. I can write a generic controller and service that can work with any model, as long as the input and output are the same as the model struct. This of course won't work for every use case, but for simple CRUD resources, it's perfect.
The Models
Let's start by defining a simple model named Post under models/models.go:
package models
import "go.mongodb.org/mongo-driver/bson/primitive"
type Post struct
Note that each field has a bson tag for MongoDB and a json tag for the API.
A Naive Approach
Here's what a naive controller and service would look like for the Post model:
package controllers
type PostsController struct
func NewPostsController(service *services.PostsService) *PostsController
And the service:
type PostsService struct
func NewPostsService(db *mongo.Database) *PostsService
func (ctx context.Context) ([]models.Post, error)
func (ctx context.Context, post *models.Post) (*models.Post, error)
func (ctx context.Context, id string) (*models.Post, error)
func (ctx context.Context, id string, post *models.Post) (*models.Post, error)
func (ctx context.Context, id string) error
We can clearly see how repetitive this code is. We can do better.
A Generic Approach
Service
We'll start out by defining a generic service that can work with any model:
package services
import (
"context"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
type CrudService[T any] struct
func NewCrudService[T any](db *mongo.Database, collection string) *CrudService[T]
func (ctx context.Context) ([]T, error)
func (ctx context.Context, input T) (primitive.ObjectID, error)
func (ctx context.Context, id string) (*T, error)
func (ctx context.Context, id string, input T) (primitive.ObjectID, error)
func (ctx context.Context, id string) error
This service accepts the collection name as a parameter so it knows which collection to work with.
type CrudService[T any] struct
This is the generic service struct. It accepts a type T which can be any type (in our case, a Post model).
func (ctx context.Context) ([]T, error)
The functions are defined with the same type T that the service struct accepts, so in this context everywhere you see T, think of it as the Post model.
Controller
As for the controller, we will define a generic controller that will contain the service:
package controllers
type CrudController[T any] struct
func NewCrudController[T any](group *echo.Group, db *mongo.Database, collection string) *CrudController[T]
func (ctx echo.Context) error
func (ctx echo.Context) error
func (ctx echo.Context) error
func (ctx echo.Context) error
func (ctx echo.Context) error
This controller registers the routes for the CRUD operations and calls the service methods. Note that I'm using Echo as my web framework of choice, but this will work with any other web framework or router.
Tying It All Together
In the main file, where we start our HTTP server and connect to the database, we can now use the generic controller to create a controller for the Post model:
func main()
Here you can see how scalable this approach is. You can create as many controllers as you want for different models, and you don't have to write any more service or controller code, unless you need to add custom logic.
Generics are a powerful feature that can help you write more scalable and maintainable code. In this example, we used generics to create a generic CRUD service and controller that can work with any model. This approach is perfect for simple CRUD resources, but it might not work for more complex use cases.
This is a simple example, and I can easily see use cases where the controller and service can be extended to handle more complex logic like pagination, filtering, and more.