Avatar

Layers of abstraction and Gorm: a Golang kata

← Back to list
Posted on 24.03.2023
Image by AI on Midjourney
Refill!

It's Friday, and it means the time has come for another Golang kata!

This time I am gonna explore layers of abstraction of a microservice written in Go.

The layers

Just like in any front-end React application, where you have the UI part represented by a set of components, services for talking to an API and sometimes a state, the backend application also peels like an onion into a somewhat similar structure:

  • Converters (sometimes referred also as "Controllers"), interface
  • Services, business
  • Database, storage

The Converters is the logic layer that directly binds with the way how the application is exposed (be that HTTP, gRPC, etc.). The converters directly talk to the Services. A service implements the business logic. That business logic, in turn, is strongly decoupled from the database (storage) layer.

You can change either the way how the app is exposed to the outer world, or you can migrate to another database. But the business logic layer always remains intact.

Data validation and safety

A few words on which layer is responsible for what when it comes to input validation. So,

  • Converters are not responsible for any validation, for they are merely the way how the services are exposed
  • Services, on the contrary, should do the validation. On this level we see white listing, service-layer DTOs, etc.
  • Repositories (database) only do escaping to prevent SQL injections.

Having the code organized like this, I can switch transports (the layer of converters) and still have your business logic protected. Also, as a fail-safe precaution, the repository escapes everything, so even if due to some reason the business-level validation haven't worked, you are not caught off guard. Also, some other databases, such as MongoDB, may require a different sort of protection.

The ORM

For the database layer I'm gonna use Gorm, which stands for "Go ORM". I kinda like this name, it evokes hilarious associations with the black/white sci-fi movies from 80-s: "Gorm hungry! Gorm crush!"

Anyway...

The structures

In order to avoid complexity, the data structure will contain only a single entity Book. And for that entity, I'll create all the levels.

Business

The business level is the cleanest one: it knows not about the database level, neither - about the interface. All data structures in a Go-app are typically co-located inside of the domain folder.

👉 📃  internal/domain/business/book/book.go
package book
type Book struct {
ID string
Title string
Author string
IssueYear int32
}
type GetBooksResult struct {
Books []*Book
Total int64
PageNumber int32
}
The code is licensed under the MIT license

Interface

The interface describes the format of the POST request body. It knows how to convert a JSON-formatted body of the request into a business structure, as well as how to do the opposite: convert the response back to JSON.

This time we have declared the GetBooks request and response format.

👉 📃  internal/domain/rest/book/book.go
package book
import (
"levelsgorm/internal/domain/business/book"
)
type Book struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
}
func FromBusiness(book *book.Book) (result *Book, err error) {
return &Book{
ID: book.ID,
Title: book.Title,
Author: book.Author,
}, nil
}
type GetBooksRequest struct {
Filter string `json:"filter"`
Page int32 `json:"page"`
}
type GetBooksResponse struct {
Books []*Book `json:"books"`
Total int64 `json:"total"`
PageNumber int32 `json:"page_number"`
}
func FromBusinessGetBooksResponse(response *book.GetBooksResult) (result *GetBooksResponse, err error) {
resultBooks := []*Book{}
for _, businessBook := range response.Books {
requestBook, err := FromBusiness(businessBook)
if err != nil {
return nil, err
}
resultBooks = append(resultBooks, requestBook)
}
return &GetBooksResponse{
Books: resultBooks,
Total: response.Total,
PageNumber: response.PageNumber,
}, nil
}
The code is licensed under the MIT license

Storage

The storage level is also quite straight forward. Gorm is a code-first ORM, so we obviously declare the entity and how to convert it from/to business. As we just have one getter here, the FromBusiness() function is not needed.

👉 📃  internal/domain/database/book/book.go
package book
import (
"github.com/google/uuid"
"gorm.io/gorm"
"levelsgorm/internal/domain/business/book"
)
type Book struct {
gorm.Model
ID uuid.UUID `gorm:"type:uuid;default:uuid_generate_v4()"`
Title string `db:"title"`
Author string `db:"author"`
IssueYear int32 `db:"issue_year"`
}
func (b *Book) ToBusiness() *book.Book {
return &book.Book{
ID: b.ID.String(),
Title: b.Title,
Author: b.Author,
IssueYear: b.IssueYear,
}
}
The code is licensed under the MIT license

The logic

Business

To cover the business logic part, as mentined before, a service must be declared. This service has a repository as a dependency, as we follow the dependency injection pattern here.

👉 📃  internal/service/book/book.go
package book
import (
"levelsgorm/internal/domain/business/book"
databaseBook "levelsgorm/internal/domain/database/book"
)
type bookRepository interface {
GetBooks(filter string, page int32) (books []*databaseBook.Book, err error)
GetBookCount(filter string) (count int64, err error)
}
type Service struct {
BookRepository bookRepository
}
func (s *Service) GetBooks(filter string, page int32) (result *book.GetBooksResult, err error) {
result = &book.GetBooksResult{
PageNumber: page,
Books: []*book.Book{},
}
bookCount, err := s.BookRepository.GetBookCount(filter)
if err != nil {
return nil, err
}
if bookCount == 0 {
return result, nil
}
result.Total = bookCount
books, err := s.BookRepository.GetBooks(filter, page)
if err != nil {
return nil, err
}
if len(books) > 0 {
for _, dbBook := range books {
result.Books = append(result.Books, dbBook.ToBusiness())
}
}
return result, nil
}
The code is licensed under the MIT license

Interface

We will be exposing the business logic through a REST endpoint. In order to do that, we need to define the controller accordingly:

package book
import (
"encoding/json"
"io/ioutil"
"net/http"
bookBusiness "levelsgorm/internal/domain/business/book"
"levelsgorm/internal/domain/rest/book"
)
type bookService interface {
GetBooks(filter string, page int32) (result *bookBusiness.GetBooksResult, err error)
}
type Controller struct {
BookService bookService
}
func (c *Controller) GetBooks(responseWriter http.ResponseWriter, request *http.Request) {
body, err := ioutil.ReadAll(request.Body)
if err != nil {
panic(err)
}
jsonBody := book.GetBooksRequest{}
err = json.Unmarshal(body, &jsonBody)
if err != nil {
responseWriter.WriteHeader(http.StatusBadRequest)
return
}
result, err := c.BookService.GetBooks(jsonBody.Filter, jsonBody.Page)
if err != nil {
responseWriter.WriteHeader(http.StatusInternalServerError)
return
}
bookResponse, err := book.FromBusinessGetBooksResponse(result)
if err != nil {
responseWriter.WriteHeader(http.StatusInternalServerError)
return
}
responseBody, err := json.Marshal(bookResponse)
if err != nil {
responseWriter.WriteHeader(http.StatusInternalServerError)
return
}
responseWriter.Header().Set("Content-Type", "application/json")
_, err = responseWriter.Write(responseBody)
if err != nil {
responseWriter.WriteHeader(http.StatusInternalServerError)
return
}
}
The code is licensed under the MIT license

The controller is dependent on the service, and each function typically consists of five parts:

  • Reading the request
  • Converting the request from interface to business
  • Calling the business feature with the business request, get the business response
  • Converting the business response back to interface
  • Sending the response

When dealing with gRPC or PubSub there is typically no need to read/write anything, for this task is usually upheld by the underlying logic. The result is normally just returned in such case.

Storage

The scope of the repository is to know how to communicate with a specific database. In this case it is Postgres, but the beauty of Gorm is that it can be used with other database: MySQL, SQLite or SQLServer.

👉 📃  internal/repository/book/book.go
package book
import (
"gorm.io/gorm"
"levelsgorm/internal/domain/database/book"
)
const (
TableName = "books"
PageSize = 5
)
type Repository struct {
Session *gorm.DB
}
func (r *Repository) GetBooks(filter string, page int32) (books []*book.Book, err error) {
runner := r.Session.Table(TableName)
if filter != "" {
runner = runner.Where("title like ?", filter)
}
runner.Offset(int(page * PageSize)).Limit(PageSize).Find(&books)
return books, nil
}
func (r *Repository) GetBookCount(filter string) (count int64, err error) {
runner := r.Session.Table(TableName)
if filter != "" {
runner = runner.Where("title like ?", filter)
}
runner.Select("id").Count(&count)
return count, nil
}
The code is licensed under the MIT license

The main file

It is time to finally put everything together.

The boilerplate for the server itself is typical for Go. You can use this amazing article as a source of inspiration, or you can always ask Chat-GPT to generate one for you :)

What is really important here - the code that goes before. There we establish a connection with the database first, then make an instance of the repository. This instance is then fed to an instance of the service, which, in turn, goes as a dependency for a controller. The controller then is attached to the REST multiplexer.

👉 📃  main.go
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"sync"
"levelsgorm/internal/controller/book"
bookRepository "levelsgorm/internal/repository/book"
bookService "levelsgorm/internal/service/book"
"levelsgorm/internal/util/db"
)
const (
keyServerAddress = "serverAddress"
)
func main() {
session, err := db.Connect()
if err != nil {
panic(err)
}
booksRepo := &bookRepository.Repository{
Session: session,
}
bookSvc := &bookService.Service{
BookRepository: booksRepo,
}
bookController := book.Controller{
BookService: bookSvc,
}
mux := http.NewServeMux()
mux.HandleFunc("/books", bookController.GetBooks)
ctx := context.Background()
server := &http.Server{
Addr: ":" + os.Getenv("PORT"),
Handler: mux,
BaseContext: func(l net.Listener) context.Context {
address := l.Addr().String()
fmt.Println("Listening at " + address)
ctx = context.WithValue(ctx, keyServerAddress, address)
return ctx
},
}
err = server.ListenAndServe()
if err != nil {
panic(err)
}
var wg sync.WaitGroup
wg.Add(1)
wg.Wait()
}
The code is licensed under the MIT license

The db.Connect() helper is declared as follows:

👉 📃  internal/util/db/db.go
package db
import (
"fmt"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func Connect() (*gorm.DB, error) {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
os.Getenv("POSTGRES_DB_HOST"),
os.Getenv("POSTGRES_USER"),
os.Getenv("POSTGRES_PASSWORD"),
os.Getenv("POSTGRES_DB"),
os.Getenv("POSTGRES_DB_PORT"),
)
connection, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return connection, nil
}
The code is licensed under the MIT license

Key takeaways

👉 It is of utmost importance to stick to separation of the levels. This particular example is trivial, but in more complex cases the business structure may not exactly match the database/interface, so way more intricate from/to converters may be needed.

👉 There are certain and reasonable rules for what level can call depend on which. For example, a service can have several repositories as dependencies, but one repository can't depend on another.

CallsConverterServiceRepository
Converter
Service
Repository

👉 Sometimes it makes sense to have nested structures, for example a book can have an author, which is also, in turn, a completely separable entity. In this case, such superstructure should reside on the service (business) layer.

👉 Another good example when the layering is useful is when in the database there are nullable fields. Then, on the database structure the fields must be defined as pointers, but on the business level they may still be regular values.

Well, that is basically it. The separation of layers is the right way to properly architect the backend application. It makes refactoring easier, it enables loose coupling by its nature. And, in the end, it just feels cleaner and more safe to practice.

As usual, the code of the kata is here. Enjoy!


Avatar

Sergei Gannochenko

Business-oriented fullstack engineer, in ❤️ with Tech.
Golang, React, TypeScript, Docker, AWS, Jamstack.
15+ years in dev.