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 fromGORM
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
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.
-
Adapter Layer: Responsible for routing and adaptation of frameworks, protocols, and front-end displays (web, mobile, and etc).
-
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.
-
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.
-
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!