» Go: Build a REST API with Gin » 2. Development » 2.5 4 Layers Architecture

4 Layers Architecture

At this point, your code is running as expected. But, if you take a closer look, it's not "clean" enough.

Not "clean"

Here are some of the not-clean points of the current code:

// Initialize a database instance
var db *gorm.DB
  • It's a bad idea to explicitly use a global variable to hold the database instance and use it all around.
// Initialize database
func initDB() {
	// Open SQLite database
	var err error
	db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	// Auto Migrate
	db.AutoMigrate(&model.Book{})
}
  • It's not a good practice to initialize a database in the business code. Database stuff is "infrastructure", but not "business". They're different concerns, and you should put them in different places.
// Get single book
func getBook(c *gin.Context) {
	id, _ := strconv.Atoi(c.Param("id"))
	var book model.Book
	if err := db.First(&book, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"})
		return
	}
	c.JSON(http.StatusOK, book)
}
  • It's not recommended to directly rely on third party libraries in your business code. db.First is a method from GORM package, which is a third party library. The cost would be huge if you decide to switch to a new ORM framework in the future.

Separation of Concerns

Clean Arch From https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

The Clean Architecture is a software architectural pattern introduced by Robert C. Martin, in his book "Clean Architecture: A Craftsman's Guide to Software Structure and Design." It emphasizes the separation of concerns within a software system, with the primary goal of making the system more maintainable, scalable, and testable over its lifecycle.

4 Layers Architecture varies somewhat in the details, but it's similar to the Clean Architecture. They all have the same objective, which is the separation of concerns. They all achieve this separation by dividing the software into layers.

4 layers

  1. Adapter Layer: Responsible for routing and adaptation of frameworks, protocols, and front-end displays (web, mobile, and etc).

  2. Application Layer: Primarily responsible for obtaining input, assembling context, parameter validation, invoking domain layer for business processing. The layer is open-ended, it's okay for the application layer to bypass the domain layer and directly access the infrastructure layer.

  3. Domain Layer: Encapsulates core business logic and provides business entities and business logic operations to the Application layer through domain services and domain entities' methods. The domain is the core of the application and does not depend on any other layers.

  4. Infrastructure Layer: Primarily handles technical details such as CRUD operations on databases, search engines, file systems, RPC for distributed services, etc. External dependencies need to be translated through gateways here before they can be used by the Application and Domain layers above.

Refactoring

Let's do the code refactoring with 4 layers architecture.

The new folder structure looks like this:

projects/lr_rest_books_go
├── LICENSE
├── README.md
├── adaptor
│   └── router.go
├── application
│   ├── executor
│   │   └── book_operator.go
│   └── wire_helper.go
├── domain
│   ├── gateway
│   │   └── book_manager.go
│   └── model
│       └── book.go
├── go.mod
├── go.sum
├── infrastructure
│   ├── config
│   │   └── config.go
│   └── database
│       └── sqlite.go
├── main.go
└── test.db

Move model/book.go into domain/model/book.go:

package model

import "time"

// Book represents the structure of a book
type Book struct {
	ID          uint      `json:"id"`
	Title       string    `json:"title"`
	Author      string    `json:"author"`
	PublishedAt string    `json:"published_at"`
	Description string    `json:"description"`
	ISBN        string    `json:"isbn"`
	TotalPages  int       `json:"total_pages"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

domain folder is where all your core business rules reside. Non-business code stay away from this folder.

Create domain/gateway/book_manager.go:

package gateway

import (
	"context"

	"literank.com/rest-books/domain/model"
)

// BookManager manages all books
type BookManager interface {
	CreateBook(ctx context.Context, b *model.Book) (uint, error)
	UpdateBook(ctx context.Context, id uint, b *model.Book) error
	DeleteBook(ctx context.Context, id uint) error
	GetBook(ctx context.Context, id uint) (*model.Book, error)
	GetBooks(ctx context.Context) ([]*model.Book, error)
}

The BookManager interface defines the business capabilities of the domain entity Book. This is just an interface; the implementations are done in the infrastructure layer. Additionally, these methods allow the application layer to utilize them for business logic.

Create infrastructrue/database/sqlite.go:

/*
Package database does all db persistence implementations.
*/
package database

import (
	"context"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"

	"literank.com/rest-books/domain/model"
)

type SQLitePersistence struct {
	db *gorm.DB
}

func NewSQLitePersistence(fileName string) (*SQLitePersistence, error) {
	db, err := gorm.Open(sqlite.Open(fileName), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	return &SQLitePersistence{db}, nil
}

func (s *SQLitePersistence) CreateBook(ctx context.Context, b *model.Book) (uint, error) {
	if err := s.db.WithContext(ctx).Create(b).Error; err != nil {
		return 0, err
	}
	return b.ID, nil
}

func (s *SQLitePersistence) UpdateBook(ctx context.Context, id uint, b *model.Book) error {
	var book model.Book
	if err := s.db.WithContext(ctx).First(&book, id).Error; err != nil {
		return err
	}
	return s.db.WithContext(ctx).Model(book).Updates(b).Error
}

func (s *SQLitePersistence) DeleteBook(ctx context.Context, id uint) error {
	return s.db.WithContext(ctx).Delete(&model.Book{}, id).Error
}

func (s *SQLitePersistence) GetBook(ctx context.Context, id uint) (*model.Book, error) {
	var book model.Book
	if err := s.db.WithContext(ctx).First(&book, id).Error; err != nil {
		return nil, err
	}
	return &book, nil
}

func (s *SQLitePersistence) GetBooks(ctx context.Context) ([]*model.Book, error) {
	books := make([]*model.Book, 0)
	if err := s.db.WithContext(ctx).Find(&books).Error; err != nil {
		return nil, err
	}
	return books, nil
}

As you see here, struct SQLitePersistence implements all the methods declared in the interface BookManager.

Create infrastructrue/config/config.go:

package config

type Config struct {
	App ApplicationConfig `json:"app" yaml:"app"`
	DB  DBConfig          `json:"db" yaml:"db"`
}

type DBConfig struct {
	FileName string `json:"file_name" yaml:"file_name"`
}

type ApplicationConfig struct {
	Port int `json:"port" yaml:"port"`
}

The Config structure pulls out some hard-coded items in the code, which is a good practice in general.

Create application/executor/book_operator.go:

package executor

import (
	"context"

	"literank.com/rest-books/domain/gateway"
	"literank.com/rest-books/domain/model"
)

type BookOperator struct {
	bookManager gateway.BookManager
}

func NewBookOperator(b gateway.BookManager) *BookOperator {
	return &BookOperator{bookManager: b}
}

func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Book, error) {
	id, err := o.bookManager.CreateBook(ctx, b)
	if err != nil {
		return nil, err
	}
	b.ID = id
	return b, nil
}

func (o *BookOperator) GetBook(ctx context.Context, id uint) (*model.Book, error) {
	return o.bookManager.GetBook(ctx, id)
}

func (o *BookOperator) GetBooks(ctx context.Context) ([]*model.Book, error) {
	return o.bookManager.GetBooks(ctx)
}

func (o *BookOperator) UpdateBook(ctx context.Context, id uint, b *model.Book) (*model.Book, error) {
	if err := o.bookManager.UpdateBook(ctx, id, b); err != nil {
		return nil, err
	}
	return b, nil
}

func (o *BookOperator) DeleteBook(ctx context.Context, id uint) error {
	return o.bookManager.DeleteBook(ctx, id)
}

The application layer encapsulates everything and provides interfaces for the topmost adaptor layer.

Create application/wire_helper.go:

package application

import (
	"literank.com/rest-books/domain/gateway"
	"literank.com/rest-books/infrastructure/config"
	"literank.com/rest-books/infrastructure/database"
)

// WireHelper is the helper for dependency injection
type WireHelper struct {
	persistence *database.SQLitePersistence
}

func NewWireHelper(c *config.Config) (*WireHelper, error) {
	db, err := database.NewSQLitePersistence(c.DB.FileName)
	if err != nil {
		return nil, err
	}
	return &WireHelper{persistence: db}, nil
}

func (w *WireHelper) BookManager() gateway.BookManager {
	return w.persistence
}

WireHelper helps with DI (dependency injection). If you want advanced DI support, consider the wire package.

With all these preparation work, finally the router can do its job "cleanly". It doesn't know which database is used under the hood. Concerns are separated now.

Create adaptor/router.go:

package adaptor

import (
	"fmt"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"literank.com/rest-books/application"
	"literank.com/rest-books/application/executor"
	"literank.com/rest-books/domain/model"
)

// RestHandler handles all restful requests
type RestHandler struct {
	bookOperator *executor.BookOperator
}

func MakeRouter(wireHelper *application.WireHelper) (*gin.Engine, error) {
	rest := &RestHandler{
		bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
	}
	// Create a new Gin router
	r := gin.Default()

	// Define a health endpoint handler
	r.GET("/", func(c *gin.Context) {
		// Return a simple response indicating the server is healthy
		c.JSON(http.StatusOK, gin.H{
			"status": "ok",
		})
	})
	r.GET("/books", rest.getBooks)
	r.GET("/books/:id", rest.getBook)
	r.POST("/books", rest.createBook)
	r.PUT("/books/:id", rest.updateBook)
	r.DELETE("/books/:id", rest.deleteBook)
	return r, nil
}

// Get all books
func (r *RestHandler) getBooks(c *gin.Context) {
	books, err := r.bookOperator.GetBooks(c)
	if err != nil {
		fmt.Printf("Failed to get books: %v", err)
		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get books"})
		return
	}
	c.JSON(http.StatusOK, books)
}

// Get single book
func (r *RestHandler) getBook(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
		return
	}
	book, err := r.bookOperator.GetBook(c, uint(id))
	if err != nil {
		fmt.Printf("Failed to get the book with %d: %v", id, err)
		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to get the book"})
		return
	}
	c.JSON(http.StatusOK, book)
}

// Create a new book
func (r *RestHandler) createBook(c *gin.Context) {
	var reqBody model.Book
	if err := c.ShouldBindJSON(&reqBody); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	book, err := r.bookOperator.CreateBook(c, &reqBody)
	if err != nil {
		fmt.Printf("Failed to create: %v", err)
		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to create"})
		return
	}
	c.JSON(http.StatusCreated, book)
}

// Update an existing book
func (r *RestHandler) updateBook(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
		return
	}

	var reqBody model.Book
	if err := c.ShouldBindJSON(&reqBody); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	book, err := r.bookOperator.UpdateBook(c, uint(id), &reqBody)
	if err != nil {
		fmt.Printf("Failed to update: %v", err)
		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to update"})
		return
	}
	c.JSON(http.StatusOK, book)
}

// Delete an existing book
func (r *RestHandler) deleteBook(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Invalid id"})
		return
	}

	if err := r.bookOperator.DeleteBook(c, uint(id)); err != nil {
		fmt.Printf("Failed to delete: %v", err)
		c.JSON(http.StatusNotFound, gin.H{"error": "Failed to delete"})
		return
	}
	c.JSON(http.StatusNoContent, nil)
}

Replace main.go with the following content to make it clean:

package main

import (
	"fmt"

	"literank.com/rest-books/adaptor"
	"literank.com/rest-books/application"
	"literank.com/rest-books/infrastructure/config"
)

func main() {
	c := &config.Config{
		App: config.ApplicationConfig{
			Port: 8080,
		},
		DB: config.DBConfig{
			FileName: "test.db",
		},
	}
	// Prepare dependencies
	wireHelper, err := application.NewWireHelper(c)
	if err != nil {
		panic(err)
	}

	// Build main router
	r, err := adaptor.MakeRouter(wireHelper)
	if err != nil {
		panic(err)
	}
	// Run the server on the specified port
	if err := r.Run(fmt.Sprintf(":%d", c.App.Port)); err != nil {
		panic(err)
	}
}

Refactoring is achieved. 🎉Great!

PrevNext