Skip to main content

มาลอง Code style Hexagonal กัน

มาลองเขียน go style Hexagonal กัน

เริ่มต้น setup project ก่อน ในหัวข้อนี้เราจะใช้ Fiber และ Gorm ที่เคยร่ำเรียนมาจากก่อนหน้านี้

  • Fiber เป็นตัวแทนของฝั่งการรับ HTTP Request ที่ส่งมาจาก Client ของ User
  • Gorm เป็นตัวแทนของฝั่งการส่งข้อมูลเข้า Database ที่ทำต่อหลังจากได้รับข้อมูลจาก Business Logic

** เพื่อให้เกิดความเข้าใจที่ถ่องแท้มากขึ้น ขอให้รู้จัก 2 ตัวนี้มาก่อนที่จะเริ่ม code เรื่องนี้

เราจะลองทำ Diagram หน้าเมื่อกี้ (ส่วนของการ Create Order) ออกมาเป็น code กันบ้าง

โดยเราจะทำการวาง Structure กันตามนี้

.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── main.go

1. Port

Structure ที่เกี่ยวข้องจะมี 3 file นี้

.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go

สิ่งแรกที่เรามีการประกาศออกมาก่อนคือ struct สำหรับการเก็บข้อมูล Order ไว้ภายใน core โดย โครงสร้างนี้จะมีการเรียกใช้งานในทุกส่วนของ application เพื่อให้ทุกคนสามารถเข้าถึงโครงสร้างข้อมูลหน้าที่เหมือนกันออกมาได้

ที่ order.go ก็จะมีหน้าประมาณนี้

package core

type Order struct {
ID uint˝
Total float64
}

โดย Port จะมีทั้งหมด 2 ตัวที่ต้อง implement คือ

  1. Primary Port (ส่วนที่ต้องรับจากภายนอก) = เก็บไว้ที่ order_service.go โดย
  • implement OrderService เป็น Port ระบุการเชื่อมต่อเอาไว้ว่า ถ้าจะส่งข้อมูลเข้ามาต้องส่งข้อมูล order หน้าตาแบบไหนมา
  • โดยเราจะสร้าง order.go สำหรับการเก็บ schema ของ data เอาไว้ เพื่อใช้สำหรับกำหนด spec ทั้ง Port, Adapter, Business Logic ว่า data ที่ใช้สื่อสารกันมีหน้าตาเป็นแบบไหน

ที่ไฟล์ order_service.go สำหรับ Primary Port ก็จะมีหน้าตาประมาณนี้

package core

// Primary port
type OrderService interface {
CreateOrder(order Order) error
}
  1. Secondary Port (ส่วนที่จะต้องส่งต่อไปยังส่วนของ database) = เก็บไว้ที่ order_repository.go โดย
  • implement OrderRepository สำหรับ spec ข้อมูลสำหรับการรับข้อมูลเพื่อไป save ลง database (ซึ่งก็คือข้อมูล order)

ที่ไฟล์ order_repository.go สำหรับ Secondary Port ก็จะมีหน้าตาประมาณนี้

package core

// Secondary port
type OrderRepository interface {
Save(order Order) error
}

2. Business Core logic

Structure ที่เกี่ยวข้องจะมี 1 file นี้

.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go

โดย เราจะทำการสร้าง function สำหรับจัดการ Order ขึ้นมา โดยใช้ function จาก Repository มาจัดการผูก logic (เป็น business function ขึ้นมา) สำหรับจัดการตัว Order

หน้าตา order_service.go ก็จะเป็นประมาณนี้เพิ่มขึ้นมา

type orderServiceImpl struct {
repo OrderRepository
}

func NewOrderService(repo OrderRepository) OrderService {
return &orderServiceImpl{repo: repo}
}

func (s *orderServiceImpl) CreateOrder(order Order) error {
if order.Total <= 0 {
return errors.New("total must be positive")
}
// Business logic...
if err := s.repo.Save(order); err != nil {
return err
}
return nil
}

โดย

  • ตัว struct orderServiceImpl จะทำการเก็บ logic ของการจัดการ Order เอาไว้ ชื่อ CreateOrder(order Order) โดยจะทำการคุยกับ OrderRepository (ที่จะส่งเข้ามาจาก Adapter อีกทีตาม spec ที่ Port กำหนด) เพื่อทำการไปคุยให้สร้าง Order จาก Database ออกมา
  • ส่วน function NewOrderService ไว้สำหรับการ instance ตัวแปรตอนที่มีการสร้างตัวแปรตาม struct ของ orderServiceImpl (เป็นตัวที่ใช้รับ Repository ที่ส่งจาก adapter เข้ามา)

3. Adapter

Structure ที่เกี่ยวข้องจะมี 2 file นี้

.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── go.mod
├── go.sum
├── main.go

โดย Adapter จะมีทั้งหมด 2 ตัวที่ต้อง implement คือ

  1. Primary Adapter (adapter สำหรับการ handle input ที่เข้ามา) = เก็บไว้ที่ http_adapter.go
  • ฝั่ง Adapter ทำการสร้าง struct HttpOrderHandler ขึ้นมา โดยทำการ implement ตาม Primary port (ซึ่งก็คือ OrderService ที่สร้างไว้)
  • โดย Adapter นี้มีหน้าที่ในการ แปลง HTTP Request ที่เข้ามา เพื่อทำการเตรียมส่งเข้า OrderService (เพื่อให้ OrderService ทำการส่งต่อไปยัง Business logic ต่อ) ซึ่งโจทย์ของ Adapter นั้นมีเพียงแค่ปั้น data ให้ตรงตาม spec ของ OrderService เท่านั้น (เหมือนได้ข้อมูลตรงกัน ที่เหลือก็เป็นหน้าที่ของ Port ที่ต้องไปจัดการต่อเอง)

หน้าตา http_adapter.go ก็จะเป็นประมาณนี้ (โดยจะมีการเรียกใช้ service ของ Fiber เพิ่มขึ้นมา)

package adapters

import (
"fmt"
"mike/core"
"github.com/gofiber/fiber/v2"
)

// Primary adapter
type HttpOrderHandler struct {
service core.OrderService
}

func NewHttpOrderHandler(service core.OrderService) *HttpOrderHandler {
return &HttpOrderHandler{service: service}
}

func (h *HttpOrderHandler) CreateOrder(c *fiber.Ctx) error {
var order core.Order
if err := c.BodyParser(&order); err != nil {
fmt.Println(err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}

if err := h.service.CreateOrder(order); err != nil {
// Return an appropriate error message and status code
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}

return c.Status(fiber.StatusCreated).JSON(order)
}

จาก code

  • มี HttpOrderHandler สำหรับเป็น struct กำหนดรูปแบบของ instance ของ Primary Adapter ที่จะต้องมีการส่งเข้ามา
  • NewHttpOrderHandler ทำการ instance โดยรับ service ที่จะทำการส่งต่อเข้ามา (ในทีนี้คือ OrderService)
  • ส่วน CreateOrder ก็จะเป็น method ของ HttpOrderHandler เพื่อให้เรียกใช้สำหรับ endpoint ของการ CreateOrder (สร้างขึ้นมาเพื่อให้ client ที่มีการเรียกใช้เป็น Primary Adapter สามารถเรียกใช้งานได้)
  1. Secondary Adapter (adapter สำหรับการส่งต่อข้อมูลเข้า database) = เก็บไว้ที่ gorm_adapter.go
  • ฝั่ง Adapter ทำการสร้าง struct GormOrderRepository เป็น Secondary adapter โดยทำการ implement ตาม OrderRepository (ที่เป็น interface ของ Secondary port) และทำการเก็บ logic ที่ใช้สำหรับการพูดคุยกับ database เอาไว้
  • ซึ่งในที่นี้คนที่ทำหน้าที่ช่วยคุยใน Secondary adapter ก็คือ library GORM จะเป็นคนไปคุยกับ database ให้โดยการแปลงข้อมูลที่ส่งมา (โดยเรียกใช้งานจาก logic ของ Business logic) ทำการส่งเข้า database โดยตรงไป

หน้าตา gorm_adapter.go ก็จะประมาณนี้

package adapters

import (
"gorm.io/gorm"
"mike/core"
)

// Secondary adapter
type GormOrderRepository struct {
db *gorm.DB
}

func NewGormOrderRepository(db *gorm.DB) core.OrderRepository {
return &GormOrderRepository{db: db}
}

func (r *GormOrderRepository) Save(order core.Order) error {
if result := r.db.Create(&order); result.Error != nil {
// Handle database errors
return result.Error
}
return nil
}

จาก code

  • มี struct GormOrderRepository ที่จะทำการเก็บ function ของการทำงาน database ไว้ (ซึ่งก็คือ method Save())
  • NewGormOrderRepository function สำหรับการ instance ตัว GormOrderRepository โดยทำการรับ db (ซึ่งก็คือ database ที่จะทำการเชื่อมต่อ) เข้ามาผ่าน struct ที่มีการกำหนด spec เอาไว้

รวมทุกอย่างเข้าด้วยกัน

จากทั้งหมดที่เรา implement มา

.
├── adapters --> สำหรับเก็บ adapter
│ ├── gorm_adapter.go
│ └── http_adapter.go
├── core --> สำหรับเก็บ business logic, port
│ ├── order.go
│ ├── order_repository.go
│ └── order_service.go
├── main.go

กลับมาที่ main.go เรามาทำการเรียกใช้ผ่าน main.go กัน โดยที่ main.go สิ่งที่เราจะทำคือ

  1. ทำการกำหนด config database (ในทีนี้ขอใช้ sqlite เพื่อความรวดเร็วในการ implement) และ fiber (port สำหรับการ run http server)
  2. นำตัวแปร config ของ database ไล่ใส่ไปตั้งแต่ Secondary Adapter (ผ่าน Port) > Business logic (Service) > Primary Adapter (ผ่าน Port) โดยถ้าเทียบกับโจทย์ที่เรา implement ไปก็จะเป็น GormOrderRepository > OrderService > HttpOrderHandler

code ก็จะมีหน้าตาประมาณนี้

package main

import (
"github.com/gofiber/fiber/v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"mike/adapters"
"mike/core"
)

func main() {
app := fiber.New()

// Initialize the database connection
db, err := gorm.Open(sqlite.Open("orders.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}

// Migrate the schema
db.AutoMigrate(&core.Order{})

// Set up the core service and adapters
orderRepo := adapters.NewGormOrderRepository(db)
orderService := core.NewOrderService(orderRepo)
orderHandler := adapters.NewHttpOrderHandler(orderService)

// Define routes
app.Post("/order", orderHandler.CreateOrder)

// Start the server
app.Listen(":8000")
}

เมื่อลอง run project ด้วย go run main.go ดู ก็จะสามารถ run ผลลัพธ์ fiber ออกมาได้

hex-result

และนี่ก็คือตัวอย่างของการ implement hexagonal architecture อย่างง่าย ที่เหลือ

  • ถ้ามีส่วน business logic เพิ่มขึ้น = ต้องเพิ่ม service, data structure
  • ถ้ามีส่วนต้องเพิ่ม spec ในการรับข้อมูล = ต้องเพิ่ม Port
  • ถ้ามีส่วนต้องเปลี่ยน adapter ไปใช้อีกตัวหนึ่ง = ต้องเปลี่ยน Adapter (หรือสามารถเพิ่ม Adapter เพื่อใช้ตาม spec เดียวกันออกมาได้)