Golang Clean Architecture with gin and gorm

นาย เค
TakoDigital
Published in
4 min readFeb 17, 2023

หลังจากที่ผมได้ลองศึกษาภาษา Go มาสักพักนึง ดู gin framework มาเล็กน้อย และได้ลองใช้ gorm มาบ้าง คราวนี้ก็ลองมาดู concept Clean Arcitecture ซึ่งเสนอโดย Robert C. Martin (Uncle Bob) มาใช้ implement กับ project นี้ โดยผมได้ศึกษาตัวอย่างมาจาก https://amitshekhar.me/blog/go-backend-clean-architecture แต่ส่วนที่เป็น database ผมจะเปลี่ยนจาก mongodb มาใช้ gorm และ mysql แทนครับ

หลักการของ Clean Architecture

source : Clean Coder Blog

จากรูปนี้ วงกลมที่อยู่ข้างในสุดจะเป็นที่รวบรวม business rule นั่นคือ Entities และวงกลมที่อยู่ข้างนอกจะเป็นการทำงานที่เชื่อมต่อกับระบบ เช่น web, database หรือ API อื่นๆ โดยหลักการของ dependency คือ วงกลมที่อยู่ข้างนอกจะขึ้นอยู่กับวงกลมที่อยู่ข้างในเรียงเป็นชั้นๆ ลงไป ดังนั้น สิ่งใดก็ตามที่ทำที่วงกลมข้างนอก จะต้องไม่กระทบกับโค้ดที่เขียนอยู่ในวงกลมข้างในเลย

Clean Architecture มีหลักการดังนี้

  1. Independent of Frameworks Architecture ต้องเป็นอิสระจาก frame และ library ซึ่งหลักการข้อนี้จะทำให้ระบบของเราใช้ frameworkในฐานะเครื่องมือ ไม่ใช่เป็นข้อบังคับให้เราต้องทำ เมื่อเราพบว่า framework ที่เราใช้อยู่นั้นไม่เหมาะสม และมีตัวอื่นที่เหมาะสมกับงานของเรามากกว่า เราก็สามารถเปลี่ยนไปใช้ตัวอื่นได้โดยไม่กระทบกับ business logic
  2. Testable เราต้องสามารถ test business rule โดยต้องไม่ขึ้นอยู่กับ UI, database, web หรือ library ภายนอกอื่นๆ
  3. Independent of UI หลักการนี้ UI ต้องเปลี่ยนแปลงได้ง่ายโดยไม่กระทบกับ business rule เช่น การเปลี่ยน return type จาก JSON เป็น csv ก็ไม่ควรต้องแก้ business logic
  4. Independent of Database เราสามารถเปลี่ยน database เป็น MySql, Oracle หรือ MongoDB ได้โดยไม่กระทบกับ business rule
  5. Independent of any external agency ตัว business rule ไม่รู้อะไรจากภายนอกเลย นั่นคือตัว business จะสนใจเฉพาะงานของมันเท่านั้น

Golang Clean Architecture

Source : Go Backend Clean Architecture

จะแบ่ง 5 layer ด้วยกัน

  • Router ใช้สำหรับรับ request ทั้งหมด
  • Controller ใช้ validate request และ return model
  • Usecase จัดการเรื่อง business logic
  • Repository ใช้ในการจัดการกับ database
  • Domain/Model/Entity เป็น layer ที่ดูเรื่อง model, entity และเป็น interface สำหรับ Usecase และ Repository

ลูกศรที่ชี้แสดงถึง dependency นั่นคือ Controller จะรู้จัก Router, Usecase, Domain และ Model หรือ Entity
Usecase ก็จะรู้จัก Repository และ Model
Repository ก็จะรู้จัก database ที่เป็น external และ Entity

โดยผมจะอธิบายรายละเอียดทีละ layer จากตัวอย่าง project ที่ทดลองทำขึ้นมาครับ

project นี้ใช้สำหรับทำ centralize configuration ซึ่งใช้ concept จาก spring cloud โดยให้มี micro service นึงใช้สำหรับเก็บค่า configuration ของ micro service ต่างๆ ซึ่งทำให้เกิดความสะดวกเวลานำไป deploy บน environment ต่างๆ ซึ่งตัว feature ของ project มีเพียงแค่อ่านค่า property สำหรับ config เท่านั้น

Router

Router มีหน้าที่รับ request และเรียก Controller

package route

import (
"time"

"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/gin-gonic/gin"
)

func Setup(env *bootstrap.Env, timeout time.Duration, db bootstrap.Database, route *gin.RouterGroup) {
publicRouter := route.Group("")
PropertiesRouter(env, timeout, db, publicRouter)
}

Controller ขึ้นอยู่กับ Usecase และ Usecase ขึ้นอยู่กับ Repository

package route

import (
"time"

"github.com/Piyawat-T/go-centralize-configuration/api/controller"
"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
"github.com/Piyawat-T/go-centralize-configuration/repository"
"github.com/Piyawat-T/go-centralize-configuration/usecase"
"github.com/gin-gonic/gin"
)

func PropertiesRouter(env *bootstrap.Env, timeout time.Duration, db bootstrap.Database, group *gin.RouterGroup) {
repo := repository.NewPropertiesRepository(db, domain.CollectionProperties)
cont := controller.PropertiesController{
PropertiesUsecase: usecase.NewPropertiesUsecase(repo, timeout),
Env: env,
}
group.GET("/:application/:profile", cont.GetConfiguration)
}

Controller

Controller จะ validate data และเรียกใช้งาน Usecase

package controller

import (
"net/http"

"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
"github.com/gin-gonic/gin"
)

type PropertiesController struct {
PropertiesUsecase domain.PropertiesUseCase
Env *bootstrap.Env
}

func (controller *PropertiesController) GetProperties(c *gin.Context) {
properties, err := controller.PropertiesUsecase.GetProperties(c)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, properties)
}

func (controller *PropertiesController) GetConfiguration(c *gin.Context) {
application := c.Param("application")
profile := c.Param("profile")

properties, err := controller.PropertiesUsecase.GetByApplicationAndProfile(c, application, profile)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, properties)
}

Usecase

Usecase จะเป็นที่สำหรับเขียน business logic และเรียกใช้งาน repository

package usecase

import (
"context"
"time"

"github.com/Piyawat-T/go-centralize-configuration/domain"
)

type propertiesUsecase struct {
propertiesRepository domain.PropertiesRepository
contextTimeout time.Duration
}

func NewPropertiesUsecase(propertiesRepository domain.PropertiesRepository, timeout time.Duration) domain.PropertiesUseCase {
return &propertiesUsecase{
propertiesRepository: propertiesRepository,
contextTimeout: timeout,
}
}

func (usecase *propertiesUsecase) GetProperties(c context.Context) ([]domain.Properties, error) {
ctx, cancel := context.WithTimeout(c, usecase.contextTimeout)
defer cancel()

properties, err := usecase.propertiesRepository.Fetch(ctx)
if err != nil {
return nil, err
}
return properties, nil
}

func (usecase *propertiesUsecase) GetByApplicationAndProfile(c context.Context, application string, profile string) ([]domain.Properties, error) {
ctx, cancel := context.WithTimeout(c, usecase.contextTimeout)
defer cancel()
properties, err := usecase.propertiesRepository.FetchByApplicationAndProfile(ctx, application, profile)
if err != nil {
return nil, err
}
return properties, nil
}

Repository

Repository จะ independent กับ database รวมถึง ORM library ต่างๆ ซึ่งมีหน้าที่ในการ call database ผ่าน interface

package repository

import (
"context"

"github.com/Piyawat-T/go-centralize-configuration/bootstrap"
"github.com/Piyawat-T/go-centralize-configuration/domain"
)

type propertiesRepository struct {
database bootstrap.Database
collection string
}

func NewPropertiesRepository(db bootstrap.Database, collection string) domain.PropertiesRepository {
return &propertiesRepository{
database: db,
collection: collection,
}
}

func (ur *propertiesRepository) Fetch(c context.Context) ([]domain.Properties, error) {
var properties []domain.Properties
err := ur.database.Find(&properties)
return properties, err
}

func (repository *propertiesRepository) FetchByApplicationAndProfile(c context.Context, application string, profile string) ([]domain.Properties, error) {
var properties []domain.Properties
err := repository.database.Find(&properties, "application = ? AND profile = ?", "deposit", "default")
return properties, err
}

Domain

Domain จะเป็น layer ที่ประกอบไปด้วยสิ่งต่างๆ เหล่านี้

  • Model สำหรับ request และ response
  • Entity สำหรับ database
  • interface สำหรับ usecase และ repository
package domain

import "context"

const (
CollectionProperties = "properties"
)

type Properties struct {
Id uint `json:"id"`
Application string `json:"application"`
Profile string `json:"profile"`
Key string `json:"key"`
Value string `json:"value"`
}

type PropertiesRepository interface {
Fetch(c context.Context) ([]Properties, error)
FetchByApplicationAndProfile(c context.Context, application string, profile string) ([]Properties, error)
}

type PropertiesUseCase interface {
GetProperties(c context.Context) ([]Properties, error)
GetByApplicationAndProfile(c context.Context, application string, profile string) ([]Properties, error)
}

ซึ่งในบทความนี้ผมจะยังไม่รวมถึงเรื่องการ test และการ mock การตั้งค่า database โดยจะกล่าวถึงต่อไปในอนาคต ท่านผู้อ่านสามารถไปดู link project ได้ที่นี่ครับ go centralize configuration

--

--