Layers of abstraction and Gorm: a Golang kata
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.
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.
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.
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...
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.
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.
package booktype Book struct {ID stringTitle stringAuthor stringIssueYear int32}type GetBooksResult struct {Books []*BookTotal int64PageNumber int32}
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.
package bookimport ("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 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.
package bookimport ("github.com/google/uuid""gorm.io/gorm""levelsgorm/internal/domain/business/book")type Book struct {gorm.ModelID 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,}}
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.
package bookimport ("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 = bookCountbooks, 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}
We will be exposing the business logic through a REST endpoint. In order to do that, we need to define the controller accordingly:
package bookimport ("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 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.
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.
package bookimport ("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}
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.
package mainimport ("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.WaitGroupwg.Add(1)wg.Wait()}
The db.Connect() helper is declared as follows:
package dbimport ("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}
👉 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.
Calls | Converter | Service | Repository |
---|---|---|---|
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!
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.