Repository

👨‍🏫 Before we start…

  • The repository pattern is a design pattern in software development used to isolate/ abstract the data layer.
  • Go standard library provides the database/sql package to interaction with SQL databases. But there are Go libraries such as sqlx, sqlc, GORM, Ent which can be a good fit for your requirements. We choose GORM because it’s good for rapid development and to handle complex database transactions comfortably.

Adding repository

1. Add GORM

GORM is a comprehensive ORM for Go. Its features include CRUD operations, querying, association handling, auto migrations, preloading, hooks, and much more. It is also compatible with various SQL databases including MySQL, PostgreSQL, SQLite, and SQL Server. Please refer to the GORM documentation for more information.

We use a Postgres database and support database logs. So, we need to install following packages.

go get gorm.io/gorm
go get gorm.io/driver/postgres
go get gorm.io/gorm/logger

2. Declare Models

We need to define a Go struct that corresponds to the books table of our database. Since the id column utilizes the UUID data type, we have to use a Go implementation of uuid like gofrs/uuid, google/uuid and here we use google/uuid.

i. Add google/uuid

go get github.com/google/uuid

ii. Update api/resource/book/model.go

import (
    "time"

    "github.com/google/uuid"
    "gorm.io/gorm"
)

type Book struct {
	ID            uuid.UUID `gorm:"primarykey"`
	Title         string
	Author        string
	PublishedDate time.Time
	ImageURL      string
	Description   string
	CreatedAt     time.Time
	UpdatedAt     time.Time
	DeletedAt     gorm.DeletedAt
}

type Books []*Book

3. Implement repository functions

Let’s add repository functions under api/resource/book/repository.go.

package book

import (
	"github.com/google/uuid"
	"gorm.io/gorm"
)

type Repository struct {
	db *gorm.DB
}

func NewRepository(db *gorm.DB) *Repository {
	return &Repository{
		db: db,
	}
}

func (r *Repository) List() (Books, error) {
	books := make([]*Book, 0)
	if err := r.db.Find(&books).Error; err != nil {
		return nil, err
	}

	return books, nil
}

func (r *Repository) Create(book *Book) (*Book, error) {
	if err := r.db.Create(book).Error; err != nil {
		return nil, err
	}

	return book, nil
}

func (r *Repository) Read(id uuid.UUID) (*Book, error) {
	book := &Book{}
	if err := r.db.Where("id = ?", id).First(&book).Error; err != nil {
		return nil, err
	}

	return book, nil
}

func (r *Repository) Update(book *Book) (int64, error) {
	result := r.db.Model(&Book{}).
		Select("Title", "Author", "PublishedDate", "ImageURL", "Description", "UpdatedAt").
		Where("id = ?", book.ID).
		Updates(book)

	return result.RowsAffected, result.Error
}

func (r *Repository) Delete(id uuid.UUID) (int64, error) {
	result := r.db.Where("id = ?", id).Delete(&Book{})

	return result.RowsAffected, result.Error

}

💡 Refer GORM documentation for more information.

Testing repository

First, we will create a helper package for tests, to test code concise and focused by reducing the amount of repeated assert-like code. We use DATA-DOG/go-sqlmock to create unit tests without relying on an actual database connection. As each repository test function requires a mock database connection, we will add a mock DB helper under mock/db package. Then, we can write our tests more concisely.

1. Add test helper

  • Add util/test/test.go
package test

import (
	"testing"
)

func NoError(t *testing.T, err error) {
	if err != nil {
		t.Fatalf("err: %e", err)
	}
}

func Equal[T comparable](t *testing.T, x, y T) {
	if x != y {
		t.Fatalf("not equal: %v, %v", x, y)
	}
}

2. Add DATA-DOG/go-sqlmock

go get github.com/DATA-DOG/go-sqlmock 

3. Add Mock DB

Let’s add the mock database factory function under mock/db/db.go

package db

import (
	"database/sql/driver"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

func NewMockDB() (*gorm.DB, sqlmock.Sqlmock, error) {
	db, mock, err := sqlmock.New()
	if err != nil {
		return nil, nil, err
	}

	gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
	if err != nil {
		return nil, nil, err
	}

	return gdb, mock, nil
}

type AnyTime struct{}

func (a AnyTime) Match(v driver.Value) bool {
	_, ok := v.(time.Time)
	return ok
}

💡AnyTime will be used to check if a parameter is of the time.Time type. Refer, “Matching arguments like time.Time

4. Write repository tests

Let’s write repository tests under api/resource/book/repository_test.go

package book_test

import (
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/google/uuid"

	"myapp/api/resource/book"
	mockDB "myapp/mock/db"
	testUtil "myapp/util/test"
)

func TestRepository_List(t *testing.T) {
	t.Parallel()

	db, mock, err := mockDB.NewMockDB()
	testUtil.NoError(t, err)

	repo := book.NewRepository(db)

	mockRows := sqlmock.NewRows([]string{"id", "title", "author"}).
		AddRow(uuid.New(), "Book1", "Author1").
		AddRow(uuid.New(), "Book2", "Author2")

	mock.ExpectQuery("^SELECT (.+) FROM \"books\"").WillReturnRows(mockRows)

	books, err := repo.List()
	testUtil.NoError(t, err)
	testUtil.Equal(t, len(books), 2)
}

func TestRepository_Create(t *testing.T) {
	t.Parallel()

	db, mock, err := mockDB.NewMockDB()
	testUtil.NoError(t, err)

	repo := book.NewRepository(db)

	id := uuid.New()
	mock.ExpectBegin()
	mock.ExpectExec("^INSERT INTO \"books\" ").
		WithArgs(id, "Title", "Author", mockDB.AnyTime{}, "", "", mockDB.AnyTime{}, mockDB.AnyTime{}, nil).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	book := &book.Book{ID: id, Title: "Title", Author: "Author", PublishedDate: time.Now()}
	_, err = repo.Create(book)
	testUtil.NoError(t, err)
}

func TestRepository_Read(t *testing.T) {
	t.Parallel()

	db, mock, err := mockDB.NewMockDB()
	testUtil.NoError(t, err)

	repo := book.NewRepository(db)

	id := uuid.New()
	mockRows := sqlmock.NewRows([]string{"id", "title", "author"}).
		AddRow(id, "Book1", "Author1")

	mock.ExpectQuery("^SELECT (.+) FROM \"books\" WHERE (.+)").
		WithArgs(id).
		WillReturnRows(mockRows)

	book, err := repo.Read(id)
	testUtil.NoError(t, err)
	testUtil.Equal(t, "Book1", book.Title)
}

func TestRepository_Update(t *testing.T) {
	t.Parallel()

	db, mock, err := mockDB.NewMockDB()
	testUtil.NoError(t, err)

	repo := book.NewRepository(db)

	id := uuid.New()
	_ = sqlmock.NewRows([]string{"id", "title", "author"}).
		AddRow(id, "Book1", "Author1")

	mock.ExpectBegin()
	mock.ExpectExec("^UPDATE \"books\" SET").
		WithArgs("Title", "Author", mockDB.AnyTime{}, "", "", mockDB.AnyTime{}, id).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	book := &book.Book{ID: id, Title: "Title", Author: "Author"}
	rows, err := repo.Update(book)
	testUtil.NoError(t, err)
	testUtil.Equal(t, 1, rows)
}

func TestRepository_Delete(t *testing.T) {
	t.Parallel()

	db, mock, err := mockDB.NewMockDB()
	testUtil.NoError(t, err)

	repo := book.NewRepository(db)

	id := uuid.New()
	_ = sqlmock.NewRows([]string{"id", "title", "author"}).
		AddRow(id, "Book1", "Author1")

	mock.ExpectBegin()
	mock.ExpectExec("^UPDATE \"books\" SET \"deleted_at\"").
		WithArgs(mockDB.AnyTime{}, id).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	rows, err := repo.Delete(id)
	testUtil.NoError(t, err)
	testUtil.Equal(t, 1, rows)
}

Adding the repository to the API

1. Add repository as an API dependency

Let’s add the repository as a dependency for API struct in api/resource/book/handler.go. In addition to that we create the factory func New(db *gorm.DB) *API to create the API from router, and then we need to update cmd/api/main.go to open a database connection from the main func and pass it to router.

import "gorm.io/gorm"

type API struct {
	repository *Repository
}

func New(db *gorm.DB) *API {
	return &API{
		repository: NewRepository(db),
	}
}

2. Update router

Let’s update api/router/router.go to call the API factory function with a db *gorm.DB

package router

import (
	"github.com/go-chi/chi/v5"
	"gorm.io/gorm"

	"myapp/api/resource/book"
	"myapp/api/resource/health"
)

func New(db *gorm.DB) *chi.Mux {
	r := chi.NewRouter()

	r.Get("/livez", health.Read)

	r.Route("/v1", func(r chi.Router) {
		bookAPI := book.New(db)
		r.Get("/books", bookAPI.List)
		r.Post("/books", bookAPI.Create)
		r.Get("/books/{id}", bookAPI.Read)
		r.Put("/books/{id}", bookAPI.Update)
		r.Delete("/books/{id}", bookAPI.Delete)
	})

	return r
}

3. Update cmd/api/main.go

package main

import (
	"fmt"
	"log"
	"net/http"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	gormlogger "gorm.io/gorm/logger"

	"myapp/api/router"
	"myapp/config"
)

const fmtDBString = "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable"

func main() {
	c := config.New()

	var logLevel gormlogger.LogLevel
	if c.DB.Debug {
		logLevel = gormlogger.Info
	} else {
		logLevel = gormlogger.Error
	}

	dbString := fmt.Sprintf(fmtDBString, c.DB.Host, c.DB.Username, c.DB.Password, c.DB.DBName, c.DB.Port)
	db, err := gorm.Open(postgres.Open(dbString), &gorm.Config{Logger: gormlogger.Default.LogMode(logLevel)})
	if err != nil {
		log.Fatal("DB connection start failure")
		return
	}

	r := router.New(db)
	s := &http.Server{
		Addr:         fmt.Sprintf(":%d", c.Server.Port),
		Handler:      r,
		ReadTimeout:  c.Server.TimeoutRead,
		WriteTimeout: c.Server.TimeoutWrite,
		IdleTimeout:  c.Server.TimeoutIdle,
	}

	log.Println("Starting server " + s.Addr)
	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		log.Fatal("Server startup failed")
	}
}

4. Run go mod tidy

When we add a new package and use it, we have to run go mod tidy to reorganize the dependencies in the go.mod file.

Using repository from handlers

Let’s start coding handlers in api/resource/book/handler.go.

1. Add helper functions

Currently, in api/resource/book/model.go, we have the Form, Book and DTO structs. But the Form needs to be converted to Book model to save in the database. Also, the Book needs to be converted to DTO to show to the end user.

func (f *Form) ToModel() *Book {
	pubDate, _ := time.Parse("2006-01-02", f.PublishedDate)

	return &Book{
		Title:         f.Title,
		Author:        f.Author,
		PublishedDate: pubDate,
		ImageURL:      f.ImageURL,
		Description:   f.Description,
	}
}

func (b *Book) ToDto() *DTO {
	return &DTO{
		ID:            b.ID.String(),
		Title:         b.Title,
		Author:        b.Author,
		PublishedDate: b.PublishedDate.Format("2006-01-02"),
		ImageURL:      b.ImageURL,
		Description:   b.Description,
	}
}

func (bs Books) ToDto() []*DTO {
	dtos := make([]*DTO, len(bs))
	for i, v := range bs {
		dtos[i] = v.ToDto()
	}

	return dtos
}

2. List

func (a *API) List(w http.ResponseWriter, r *http.Request) {
	books, err := a.repository.List()
	if err != nil {
		// handle later
		return
	}

	if len(books) == 0 {
		fmt.Fprint(w, "[]")
		return
	}

	if err := json.NewEncoder(w).Encode(books.ToDto()); err != nil {
		// handle later
		return
	}
}

3. Create

func (a *API) Create(w http.ResponseWriter, r *http.Request) {
	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		// handle later
		return
	}

	newBook := form.ToModel()
	newBook.ID = uuid.New()

	_, err := a.repository.Create(newBook)
	if err != nil {
		// handle later
		return
	}

	w.WriteHeader(http.StatusCreated)
}

4. Read

func (a *API) Read(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		// handle later
		return
	}

	book, err := a.repository.Read(id)
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			w.WriteHeader(http.StatusNotFound)
			return
		}

		// handle later
		return
	}

	dto := book.ToDto()
	if err := json.NewEncoder(w).Encode(dto); err != nil {
		// handle later
		return
	}
}

5. Update

func (a *API) Update(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		// handle later
		return
	}

	form := &Form{}
	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
		// handle later
		return
	}

	book := form.ToModel()
	book.ID = id

	rows, err := a.repository.Update(book)
	if err != nil {
		// handle later
		return
	}
	if rows == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
}

6. Delete

func (a *API) Delete(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		// handle later
		return
	}

	rows, err := a.repository.Delete(id)
	if err != nil {
		// handle later
		return
	}
	if rows == 0 {
		w.WriteHeader(http.StatusNotFound)
		return
	}
}

💡Now, you can test the APIs via the Open-API specification we generated in the previous section.

📁 Final project structure

myapp
├── cmd
│  ├── api
│  │  └── main.go
│  └── migrate
│     └── main.go
├── api
│  ├── router
│  │  └── router.go
│  │
│  └── resource
│     ├── health
│     │  └── handler.go
│     ├── book
│     │  ├── handler.go
│     │  ├── model.go
│     │  ├── repository.go
│     │  └── repository_test.go
│     └── common
│        └── err
│           └── err.go
├── migrations
│  └── 00001_create_books_table.sql
├── config
│  └── config.go
├── .env
├── go.mod
├── go.sum
├── mock
│  └── db
│     └── db.go
├── util
│  └── test
│     └── test.go
├── docker-compose.yml
└── Dockerfile

👨‍🏫 What’s next…

In the next article, we’ll add the error handing and the validator to our application.