👨🏫 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 assqlx
,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.