fbpx

The Benefits Of Using Clean Architecture in Golang

What is Clean Architecture?

Clean Architecture is a design pattern that uses the concept of "separation of layers," a technique used for many years by software engineers. Using abstraction will make it easier for us to do unit testing, and by applying Domain-Driven Design (DDD), one applies a domain layer that holds just the business logic. The benefits become apparent when we refactor code to cater to changing requirements and eliminate technical debt. With Clean Architecture, we change one part of the code with minimal impact on any dependent code.

What Do We Need?

Clean Architecture best applies to Golang by ensuring you have a working knowledge of both Domain-Driven Design (DDD) and Unit Testing. Also, you need to know about Gin and Gorm. Gin is a router framework for Go, and Gorm is ORM for Go.

Layers

The essential layers that need to exist in Clean Architecture are:

  • Repository,
  • UseCase,
  • Entities and
  • Delivery (or UI).

A repository is a layer connecting the app and the unknown part outside the app. For the backend, the storage should be the part where the app talks with the database, stores the data, etc.
After that, we have a UseCase layer. It is a layer where we connect between the UI or Delivery layer and the Repository layer. This layer is used to control the data we use, and some people also call this layer a Controller layer.

Next is the Entities layer, which stores the interface and abstraction from request, response, and data.
Finally, we have the Delivery layer, sometimes called the handler layer. It is the layer that sends data to the outside world.

I'll give you an example of applying the Clean Architecture design pattern using this calculator App.

Calculator App Example

  1. Repository
package repository

import (
   "dumpro/calculate/domain"
   "encoding/json"
   "fmt"
   "github.com/gin-gonic/gin"
   "github.com/go-redis/redis/v8"
   "gorm.io/gorm"
   "time"
)

var CalculateHistoryKey = "C-History"

type calculateRepository struct {
   postgresDb *gorm.DB
   redisDb    *redis.Client
}

func NewCalculateRepository(postgresDb *gorm.DB, redisDb *redis.Client) domain.CalculateRepository {
   return calculateRepository{
      postgresDb: postgresDb,
      redisDb:    redisDb,
   }
}

func (r calculateRepository) GetCalculationHistoryRepository(ctx *gin.Context) ([]domain.CalculationHistory, error) {
   var res []domain.CalculationHistory

   result, err := r.redisDb.Get(ctx, CalculateHistoryKey).Result()
   if err == redis.Nil {
      fmt.Printf("%v\n", result)

      dbRes := r.postgresDb.Find(&res)
      if dbRes.Error != nil {
         return nil, dbRes.Error
      }
      marshal, err := json.Marshal(res)
      if err != nil {
         return nil, err
      }
      err = r.redisDb.Set(ctx, CalculateHistoryKey, marshal, 1000*time.Second).Err()
      return res, nil
   } else if err != nil {
      return nil, err
   } else {
      err := json.Unmarshal([]byte(result), &res)
      if err != nil {
         fmt.Printf("%v\n", err)
         return nil, err
      }
      return res, nil
   }
}

func (r calculateRepository) GetCalculationRepository(ctx *gin.Context, a int, b int) (int, int, int, float64, error) {
   sum := r.Sum(a, b)
   sub := r.Subtract(a, b)
   times := r.Times(a, b)
   div := r.Divide(a, b)

   calculationHistory := domain.CalculationHistory{
      FirstInteger:  a,
      SecondInteger: b,
      Sum:           sum,
      Subtract:      sub,
      Times:         times,
      Divide:        div,
   }

   resDb := r.postgresDb.Create(&calculationHistory)
   if resDb.Error != nil {
      return 0, 0, 0, 0, resDb.Error
   }

   return sum, sub, times, div, nil
}

func (r calculateRepository) Sum(a int, b int) int {
   return a + b
}

func (r calculateRepository) Subtract(a int, b int) int {
   return a - b
}

func (r calculateRepository) Divide(a int, b int) float64 {
   if a == 0 || b == 0 {
      return float64(0)
   }
   return float64(a) / float64(b)
}

func (r calculateRepository) Times(a int, b int) int {
   return a * b
}

The Repository layer is where we Create, Get, Update or Delete data within a database. If you get data from the database, the data passing to the next layer should be raw or unprocessed data that comes directly from the database.

  1. Use Case/Controller
package usecase

import (
   "dumpro/calculate/domain"
   "github.com/gin-gonic/gin"
   "strconv"
)

type calculateUseCase struct {
   calculateRepo domain.CalculateRepository
}

func NewCalculateUseCase(repo domain.CalculateRepository) domain.CalculateUseCase {
   return calculateUseCase{
      calculateRepo: repo,
   }
}

func (c calculateUseCase) GetCalculationHistoryUc(ctx *gin.Context) ([]domain.CalculationHistory, error) {
   return c.calculateRepo.GetCalculationHistoryRepository(ctx)
}

func (c calculateUseCase) GetCalculationUc(ctx *gin.Context, a string, b string) (int, int, int, float64, error) {
   first, err := strconv.ParseInt(a, 10, 32)
   second, err := strconv.ParseInt(b, 10, 32)
   if err != nil {
      return 0, 0, 0, 0, err
   }
   sum, sub, times, div, err := c.calculateRepo.GetCalculationRepository(ctx, int(first), int(second))
   if err != nil {
      return 0, 0, 0, 0, err
   }
   return sum, sub, times, div, nil
}

The Use Case Layer sits between the handler and repository layers and manages the business logic.

  1. Handler
package http

import (
   "dumpro/calculate/delivery"
   "dumpro/calculate/domain"
   "dumpro/utils"
   "github.com/gin-gonic/gin"
   "net/http"
)

type CalculateHandler struct {
   calculateUseCase domain.CalculateUseCase
}

func NewCalculateHandler(r *gin.Engine, calculateUc domain.CalculateUseCase) {
   handler := &CalculateHandler{
      calculateUseCase: calculateUc,
   }
   r.GET("/calculate", handler.GetCalculation)
   r.GET("/calculate/history", handler.GetCalculationHistory)
}

func (h CalculateHandler) GetCalculation(c *gin.Context) {
   var request domain.CalculateGetRequest
   err := c.ShouldBindQuery(&request)
   if err != nil {
      c.JSON(http.StatusForbidden, utils.Response{Error: err})
      return
   }
   sum, sub, times, div, err := h.calculateUseCase.GetCalculationUc(c, request.First, request.Second)
   if err != nil {
      c.JSON(http.StatusForbidden, utils.Response{Error: err})
      return
   }
   c.JSON(http.StatusOK, utils.Response{
      Data:  delivery.MapCalculateResponse(sum, sub, times, div),
      Error: nil,
   })
}

func (h CalculateHandler) GetCalculationHistory(c *gin.Context) {
   res, err := h.calculateUseCase.GetCalculationHistoryUc(c)
   if err != nil {
      c.JSON(http.StatusForbidden, utils.Response{Error: err})
      return
   }
   c.JSON(http.StatusOK, utils.Response{
      Data:  delivery.MapCalculateHistoryResponse(res),
      Error: nil,
   })
}

The Handler layer is the last before the data goes to your Frontend or app. You can handle errors or map the response to accommodate your needs.

  1. Interface
package domain

import (
   "github.com/gin-gonic/gin"
   "gorm.io/gorm"
)

type CalculateGetRequest struct {
   First  string `form:"first" binding:"required"`
   Second string `form:"second" binding:"required"`
}

type CalculationHistory struct {
   gorm.Model
   FirstInteger  int
   SecondInteger int
   Sum           int
   Subtract      int
   Times         int
   Divide        float64
}

type CalculationHistoryResponse struct {
   ID            uint    `json:"ID"`
   FirstInteger  int     `json:"firstInteger"`
   SecondInteger int     `json:"secondInteger"`
   Sum           int     `json:"sum"`
   Subtract      int     `json:"subtract"`
   Times         int     `json:"times"`
   Divide        float64 `json:"divide"`
}

type CalculateResponse struct {
   Sum    int     `json:"sum"`
   Sub    int     `json:"sub"`
   Times  int     `json:"times"`
   Divide float64 `json:"divide"`
}

type CalculateUseCase interface {
   GetCalculationUc(ctx *gin.Context, a string, b string) (int, int, int, float64, error)
   GetCalculationHistoryUc(ctx *gin.Context) ([]CalculationHistory, error)
}

type CalculateRepository interface {
   GetCalculationRepository(ctx *gin.Context, a int, b int) (int, int, int, float64, error)
   Sum(a int, b int) int
   Subtract(a int, b int) int
   Times(a int, b int) int
   Divide(a int, b int) float64
   GetCalculationHistoryRepository(ctx *gin.Context) ([]CalculationHistory, error)
}

In the Interface layer, you should state all the Exported functions you want to use in the code and make sure your function (name, args, return) maps what you have defined in the implementation.

  1. Main
package main

import (
   calculatehandler "dumpro/calculate/delivery/http"
   calculaterepository "dumpro/calculate/repository"
   calculateusecase "dumpro/calculate/usecase"
   "dumpro/database/postgresql"
   "dumpro/utils"
   "fmt"
   "github.com/gin-gonic/gin"
)

func main() {
   config := utils.GetConfigs()

   postgrestDb, err := postgresql.InitDatabase(config.PostgresConfig) 

   r := gin.Default()
   calculateRepo := calculaterepository.NewCalculateRepository(postgrestDb)
   calculateUseCase := calculateusecase.NewCalculateUseCase(calculateRepo)
   calculatehandler.NewCalculateHandler(r, calculateUseCase)

   err = r.Run(config.Port)
   if err != nil {
      fmt.Printf("%v\n", err)
   }
}

Unit Testing

1. Mock Library

There are two ways to implement mocking. You can write it, which is time-consuming, or use libraries like mockery or gomock. For this article, I will use mockery for the abstraction. First, you need to install mockery on your machine by running :

install github.com/vektra/mockery/v2@latest

It will be installed on your machine, and you can check it using:

mockery -v

After that, you can mock your abstraction using:

mockery --all --inpackage --case snake

  • --all means that all the interfaces will be mocked
  • --inpackage means that all the mocks will stay in that folder and won't make a new folder called mock
  • --case snake means that the mock file name will be in snake case
    The directory should look like this :

2. Testing

Why does Clean Architecture make it so easy to test? It's because you don't have to bother with what dependency is used on each layer and mock them. You imitate the layer you need and use that mock to test it. Here is how to do it:

package usecase_test

import (
   "dumpro/calculate/domain"
   "dumpro/calculate/usecase"
   "errors"
   "github.com/gin-gonic/gin"
   "github.com/stretchr/testify/assert"
   "net/http/httptest"
   "testing"
)

func TestCalculateUseCase_GetCalculationHistoryUc(t *testing.T) {
   gin.SetMode(gin.TestMode)
   c, _ := gin.CreateTestContext(httptest.NewRecorder())
   err := errors.New("error")
   t.Run("Get Empty Data", func(t *testing.T) {
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      mockCalculateRepo.On("GetCalculationHistoryRepository", c).Return([]domain.CalculationHistory{}, nil).Once()
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      uc, err := u.GetCalculationHistoryUc(c)
      assert.NoError(t, err)
      assert.NotNil(t, uc)
   })

   t.Run("Error", func(t *testing.T) {
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      mockCalculateRepo.On("GetCalculationHistoryRepository", c).Return(nil, err).Once()
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      uc, err := u.GetCalculationHistoryUc(c)
      assert.Error(t, err)
      assert.Nil(t, uc)
   })

   t.Run("Get all data", func(t *testing.T) {
      data := []domain.CalculationHistory{
         {
            FirstInteger:  1,
            SecondInteger: 2,
            Sum:           3,
            Subtract:      -1,
            Times:         2,
            Divide:        0.5,
         },
         {
            FirstInteger:  10,
            SecondInteger: 20,
            Sum:           30,
            Subtract:      -10,
            Times:         200,
            Divide:        0.5,
         },
      }
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      mockCalculateRepo.On("GetCalculationHistoryRepository", c).Return(data, nil).Once()
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      uc, err := u.GetCalculationHistoryUc(c)
      assert.NoError(t, err)
      assert.NotNil(t, uc)
      assert.Len(t, uc, 2)
   })
}

func TestCalculateUseCase_GetCalculationUc(t *testing.T) {
   gin.SetMode(gin.TestMode)
   c, _ := gin.CreateTestContext(httptest.NewRecorder())
   err := errors.New("error")

   t.Run("Get Calculation", func(t *testing.T) {
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      mockCalculateRepo.On("GetCalculationRepository", c, 10, 10).Return(1, 1, 1, float64(1), nil).Once()
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      sum, sub, times, div, err := u.GetCalculationUc(c, "10", "10")
      assert.NoError(t, err)
      assert.NotZero(t, sum)
      assert.NotZero(t, sub)
      assert.NotZero(t, times)
      assert.NotZero(t, div)
   })

   t.Run("error on repository", func(t *testing.T) {
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      mockCalculateRepo.On("GetCalculationRepository", c, 10, 10).Return(0, 0, 0, float64(0), err).Once()
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      sum, sub, times, div, err := u.GetCalculationUc(c, "10", "10")
      assert.Error(t, err)
      assert.Zero(t, sum)
      assert.Zero(t, sub)
      assert.Zero(t, times)
      assert.Zero(t, div)
   })

   t.Run("error on parse", func(t *testing.T) {
      mockCalculateRepo := domain.NewMockCalculateRepository(t)
      u := usecase.NewCalculateUseCase(mockCalculateRepo)
      sum, sub, times, div, err := u.GetCalculationUc(c, "10", "asd")
      assert.Error(t, err)
      assert.Zero(t, sum)
      assert.Zero(t, sub)
      assert.Zero(t, times)
      assert.Zero(t, div)
   })
}

Here is what usecase_test looks like, you use NewMockCalculateRepository, and you set the return of the function you want to be called out. Just look at the calculate repository, and you have to think about how to mock the database to test a function that didn't need a database. Here is how the handler_test looks:

package http_test

import (
   calculatehandler "dumpro/calculate/delivery/http"
   "dumpro/calculate/domain"
   "errors"
   "github.com/gin-gonic/gin"
   "github.com/stretchr/testify/assert"
   "github.com/stretchr/testify/mock"
   "net/http"
   "net/http/httptest"
   "testing"
)

func TestCalculateHandler_GetCalculation(t *testing.T) {
   gin.SetMode(gin.TestMode)

   err := errors.New("error")

   t.Run("Get Data", func(t *testing.T) {
      _, engine := gin.CreateTestContext(httptest.NewRecorder())
      mockCalculateUseCase := domain.NewMockCalculateUseCase(t)
      mockCalculateUseCase.On("GetCalculationUc", mock.Anything, "10", "10").Return(10, 10, 10, float64(10), nil)
      calculatehandler.NewCalculateHandler(engine, mockCalculateUseCase)
      req, err := http.NewRequest(http.MethodGet, "/calculate?first=10&second=10", nil)
      assert.NoError(t, err)
      w := httptest.NewRecorder()
      engine.ServeHTTP(w, req)

      assert.Equal(t, http.StatusOK, w.Code)
   })
   t.Run("error parse", func(t *testing.T) {
      _, engine := gin.CreateTestContext(httptest.NewRecorder())
      mockCalculateUseCase := domain.NewMockCalculateUseCase(t)
      mockCalculateUseCase.On("GetCalculationUc", mock.Anything, "10", "asd").Return(0, 0, 0, float64(0), err)
      calculatehandler.NewCalculateHandler(engine, mockCalculateUseCase)
      req, err := http.NewRequest(http.MethodGet, "/calculate?first=10&second=asd", nil)
      assert.NoError(t, err)
      w := httptest.NewRecorder()
      engine.ServeHTTP(w, req)

      assert.Equal(t, http.StatusForbidden, w.Code)
   })

   t.Run("error Calculate", func(t *testing.T) {
      _, engine := gin.CreateTestContext(httptest.NewRecorder())
      mockCalculateUseCase := domain.NewMockCalculateUseCase(t)
      mockCalculateUseCase.On("GetCalculationUc", mock.Anything, "10", "10").Return(0, 0, 0, float64(0), err)
      calculatehandler.NewCalculateHandler(engine, mockCalculateUseCase)
      req, err := http.NewRequest(http.MethodGet, "/calculate?first=10&second=10", nil)
      assert.NoError(t, err)
      w := httptest.NewRecorder()
      engine.ServeHTTP(w, req)

      assert.Equal(t, http.StatusForbidden, w.Code)
   })
}

func TestCalculateHandler_GetCalculationHistory(t *testing.T) {
   gin.SetMode(gin.TestMode)

   err := errors.New("error")

   data := []domain.CalculationHistory{
      {
         FirstInteger:  10,
         SecondInteger: 10,
         Sum:           10,
         Subtract:      10,
         Times:         10,
         Divide:        10,
      },
      {
         FirstInteger:  20,
         SecondInteger: 20,
         Sum:           20,
         Subtract:      20,
         Times:         20,
         Divide:        20,
      },
   }

   t.Run("Get Data", func(t *testing.T) {
      _, engine := gin.CreateTestContext(httptest.NewRecorder())
      mockCalculateUseCase := domain.NewMockCalculateUseCase(t)
      mockCalculateUseCase.On("GetCalculationHistoryUc", mock.Anything).Return(data, nil)
      calculatehandler.NewCalculateHandler(engine, mockCalculateUseCase)
      req, err := http.NewRequest(http.MethodGet, "/calculate/history", nil)
      assert.NoError(t, err)
      w := httptest.NewRecorder()
      engine.ServeHTTP(w, req)

      assert.Equal(t, http.StatusOK, w.Code)
   })
   t.Run("error parse", func(t *testing.T) {
      _, engine := gin.CreateTestContext(httptest.NewRecorder())
      mockCalculateUseCase := domain.NewMockCalculateUseCase(t)
      mockCalculateUseCase.On("GetCalculationHistoryUc", mock.Anything).Return(nil, err)
      calculatehandler.NewCalculateHandler(engine, mockCalculateUseCase)
      req, err := http.NewRequest(http.MethodGet, "/calculate/history", nil)
      assert.NoError(t, err)
      w := httptest.NewRecorder()
      engine.ServeHTTP(w, req)

      assert.Equal(t, http.StatusForbidden, w.Code)
   })
}

In this handler_test, you can see that we mock the use-case and the function in the use-case.

Changing Dependency

It's easy to change the dependency with this approach. Let's say we currently use Postgres in the app, but we want to change it to use MySQL. It is easy to do with this database layer:

package database

import (
   "dumpro/calculate/domain"
   "dumpro/utils"
   "fmt"
   "gorm.io/driver/postgres"
   "gorm.io/gorm"
)

func InitDatabase(config utils.PostgresConfig) (*gorm.DB, error) {
   dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", config.Host, config.User, config.Password, config.Db, config.Port)
   db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
   db.AutoMigrate(domain.CalculationHistory{})

   return db, err
}

If we want to use MySQL, we change the gorm driver as follows :

package database

import (
   "dumpro/calculate/domain"
   "dumpro/utils"
   "fmt"
   "gorm.io/driver/mysql"
   "gorm.io/gorm"
)

func InitDatabase(config utils.PostgresConfig) (*gorm.DB, error) {
   dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Shanghai", config.Host, config.User, config.Password, config.Db, config.Port)
   db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
   db.AutoMigrate(domain.CalculationHistory{})

   return db, err
}

It's straightforward, and we don't need to change anything else in the app.

Clean Architecture in Other Languages

You can implement Clean Architecture differently depending on where it would be implemented. If Clean Architecture is implemented in Flutter, it needs a mapper layer for mapping all the data from the response API to a Flutter Object. If it's implemented in Vue, we won't need to make a mapper layer because Javascript can read JSON.

Conclusion

No matter where you implement it, Clean Architecture always provides two advantages:

You can always mock the abstraction and change its dependencies without changing other parts of the code when it becomes obsolete or needs refactoring.

References

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
https://github.com/bxcodec/go-clean-arch

Author: Andhika Satria Bagaskoro, Programmer

Share

Get the latest news from us to your inbox

(Weekly newsletter)

Leave a comment



from Indonesia:
from Australia:
from New Zealand:
from Singapore:
Our social media
        
© Copyright 1991 - 2022 Mitrais