Eradicating Spring Layer-Driven Habits, Eliminating Cyclic Dependencies, and Mastering Standard Go Project Layout
ในโลกของ JVM / Kotlin / Spring Boot เราคุ้นชินกับการจัดหมวดหมู่โค้ดตาม "หน้าที่ทางเทคนิค" (Technical Layering): คลาส Controller ทั้งหมดไปกองรวมกันที่ controller/, Service ทั้งหมดไปอยู่ที่ service/, และ Entity/DTO ทั้งหมดไปอยู่ที่ model/
ใน Spring Boot หาก Service A เรียก Service B และ Service B เรียก Service A ทาง Spring มักจะแก้ผ้าเอาหน้ารอดด้วยการแทรก Lazy Proxy (@Lazy) หรืออาศัย Reflection ตอนรันไทม์ ทำให้สถาปัตยกรรมเกิดการผูกมัดเป็นร่างแหที่ซับซ้อน (Tangled Graph of Dependencies)
นอกจากนี้ การสร้างแพ็กเกจ models/ หรือ common/ มักกลายเป็น "ถังขยะรวมโค้ด" (God Package) ที่ทุกคลาสต้องวิ่งเข้ามา Import ส่งผลให้เกิด Tight Coupling และไม่สามารถแยกชิ้นส่วน Microservices ได้ในอนาคต
ใน Go กฎเหล็กของคอมไพเลอร์คือ: ห้ามมี Cyclic Import เด็ดขาด (Strict Directed Acyclic Graph - DAG) หาก Package A import Package B แล้ว Package B พยายาม import Package A ตัวคอมไพเลอร์จะหยุดการทำงานและปฏิเสธการ Build ทันที! Go จึงบีบให้เราต้องออกแบบระบบเป็น Domain-Driven Packages ที่มองแต่ละโฟลเดอร์เป็น "Micro-Library" อิสระ
| คำศัพท์ทางเทคนิค (Go) | นิยามและพฤติกรรมระดับ Compiler / Architecture | แนวคิดเทียบเท่าใน Kotlin / Spring |
|---|---|---|
Go Module (go.mod) |
หน่วยรากฐานของโปรเจกต์ (Root Collection) ที่ระบุ Module Path และ Dependency Versions ทั้งหมด | pom.xml (Maven) หรือ build.gradle.kts (Gradle) |
| Package | โฟลเดอร์ที่มีไฟล์ .go อยู่ภายใน ทุกไฟล์ในโฟลเดอร์เดียวกันจะต้องประกาศ package [ชื่อเดียวกัน] |
Java Package (แต่ใน Go 1 โฟลเดอร์มีได้เพียง 1 Package เท่านั้น) |
Standard Layout (cmd/ & internal/) |
โครงสร้างมาตรฐานอุตสาหกรรม: cmd/ เก็บจุดเริ่มต้น Executable (`main.go`) ส่วน internal/ ป้องกันไม่ให้โปรเจกต์ภายนอกแอบ Import โค้ดไปใช้ |
Multi-module Gradle / Maven Submodules หรือ Package-private visibility |
| Cyclic Import Error | ข้อผิดพลาดระดับคอมไพเลอร์เมื่อพบการ Dependency วนเป็นวงกลม (A → B → A) | Circular Bean Dependency (ซึ่ง Spring พยายามเลี่ยงด้วย @Lazy แต่ Go ห้ามเด็ดขาด) |
| Capitalization Visibility | Go ควบคุมการ Export (Public/Private) ด้วยตัวพิมพ์ใหญ่/พิมพ์เล็กของตัวอักษรแรก (เช่น Account คือ Public, account คือ Private) |
คีย์เวิร์ด public, private, internal ใน Kotlin |
การทำงานกับ Package ใน Go อิงตามโครงสร้าง Directory บนระบบไฟล์โดยตรง โดยมีหลักไวยากรณ์และการตั้งชื่อที่ต้องปฏิบัติตามอย่างเคร่งครัด:
# 1. คำสั่งเริ่มต้นสร้าง Go Module ที่ Root Directory
$ go mod init github.com/mybank/core-ledger
# 2. โครงสร้าง Standard Go Project Layout ระดับ Production:
core-ledger/
├── go.mod # นิยาม Module Path และ dependencies
├── go.sum # Checksum cryptographic hashes ของ dependencies
├── cmd/
│ └── ledger-cli/
│ └── main.go # Entry point ของ binary: package main
├── internal/ # โค้ดลับเฉพาะภายในโปรเจกต์นี้ ห้ามคนนอก import
│ ├── account/ # Domain Package: บัญชีเงินฝาก
│ │ ├── account.go
│ │ └── account_test.go
│ └── settlement/ # Domain Package: การเคลียริ่งยอดเงิน
│ └── clearing.go
└── pkg/ # (Optional) โค้ดที่อนุญาตให้ Public Library ภายนอกดึงไปใช้ได้
└── money/
└── currency.go
ใน Go ไม่มีคีย์เวิร์ด public หรือ private แต่ใช้ ตัวอักษรพิมพ์ใหญ่ (Uppercase) = Exported (Public) และ ตัวอักษรพิมพ์เล็ก (Lowercase) = Unexported (Package-Private):
// internal/account/account.go
package account // ชื่อ package ต้องตรงกับชื่อโฟลเดอร์ (lowercase, คำสั้นๆ, ห้าม snake_case)
import "errors"
// ErrNegativeAmount ขึ้นต้นตัวใหญ่ = Exported (ภายนอก Package เรียกใช้ได้)
var ErrNegativeAmount = errors.New("amount must be greater than zero")
// Account ขึ้นต้นตัวใหญ่ = Exported Struct
type Account struct {
ID string // Exported field: JSON serializable / คนนอกอ่านได้
balance int64 // Unexported field: ซ่อนข้อมูลระดับ Capsule! ภายนอกห้ามแตะตรงๆ
}
// NewAccount เป็น Factory Constructor Function (Idiomatic Go)
func NewAccount(id string, initialBalance int64) (*Account, error) {
if initialBalance < 0 {
return nil, ErrNegativeAmount
}
return &Account{
ID: id,
balance: initialBalance,
}, nil
}
// Balance() เป็น Getter Method (Idiomatic Go ไม่นิยมตั้งชื่อ GetBalance)
func (a *Account) Balance() int64 {
return a.balance
}
ห้ามตั้งชื่อแพ็กเกจว่า utils, common หรือ helper เด็ดขาด! เพราะเมื่อผู้เรียกใช้งานนำไป Import จะกลายเป็น utils.Format() หรือ common.Validate() ซึ่งไร้ความหมายในเชิง Domain ให้ออกแบบชื่อแพ็กเกจตามสิ่งที่มันให้บริการ เช่น httputil, jsonutil หรือ crypto
// cmd/ledger-cli/main.go
package main
import (
"fmt"
"log"
// Full Module Import Path (Module Path + Folder Path)
"github.com/mybank/core-ledger/internal/account"
)
func main() {
acc, err := account.NewAccount("ACC-10029", 50000)
if err != nil {
log.Fatalf("failed to init account: %v", err)
}
// เรียกใช้ Exported Field และ Exported Method
fmt.Printf("Account: %s, Current Balance: %d\n", acc.ID, acc.Balance())
// บรรทัดนี้จะ COMPILE ERROR ทันที เพราะ balance เป็นตัวพิมพ์เล็ก (Unexported)!
// acc.balance = 99999999
}
ทำไมสถาปัตยกรรมระดับ Production ของ Go จึงต้องมีโฟลเดอร์ internal/? นี่ไม่ใช่แค่ Convention ที่ตกลงกันเอง แต่เป็น Compiler Enforcement Rule ที่ถูก Hardcode ไว้ในตัว Go Toolchain ตั้งแต่ Go 1.4 เป็นต้นมา
โค้ดใดก็ตามที่วางอยู่ใต้โฟลเดอร์ชื่อ internal/ จะสามารถถูก import ได้โดยแพ็กเกจที่อยู่ภายใต้ Root Parent เดียวกันเท่านั้น โมดูลภายนอก (Third-party หรือ Repositories อื่น) จะไม่สามารถ import ไปใช้งานได้โดยเด็ดขาด:
เมื่อ Package A ต้องการส่งข้อมูลให้ Package B แต่ Package B ก็ต้องการเรียกใช้งาน Package A ทางออกใน Go ไม่ใช่การใช้ Magic Annotation แต่เป็นการนำหลักการ "Consumer-Driven Interface" มาใช้:
ใน Go โครงสร้าง Dependency Graph ของคุณต้องเป็น Directed Acyclic Graph (DAG) เสมอ ข้อมูลและคำสั่งต้องไหลจากบนลงล่าง หรือไหลจากขอบนอกเข้าสู่แกนกลางเท่านั้น
ข้อสังเกต: จุดที่ทำการสร้าง Instance (เช่น account.NewAccount()) และส่งผ่านเข้าไปยัง Service อื่น ควรเกิดขึ้นที่ cmd/main.go (Composition Root) เสมอ เพื่อหลีกเลี่ยงไม่ให้ Package ระดับเดียวกันต้องสร้าง Dependency ข้ามกันโดยตรง
Go มีฟังก์ชันพิเศษชื่อ func init() ซึ่งจะรันอัตโนมัติเมื่อ Package ถูกโหลด วิศวกรที่ย้ายมาจาก Spring มักชอบยัดเยียด Logic เชื่อมต่อ Database, อ่าน Config, หรือเริ่ม Goroutine ไว้ใน init() เลียนแบบ @PostConstruct
ผลเสีย: init() ทำให้เกิด Hidden Side-Effects, ไม่สามารถจัดการ Error ได้ (เพราะคืนค่า error ไม่ได้ ต้อง panic อย่างเดียว), และทำให้ Unit Test ทำงานได้ยากมาก! Idiomatic Go แนะนำให้ใช้ Explicit Constructor (`New...`) เสมอ
ในระบบ Ledger การเปิดฟิลด์เป็นตัวพิมพ์ใหญ่ เช่น type Account struct { Balance int64 } อนุญาตให้โค้ดภายนอกแก้ไขตัวเลขยอดเงินได้โดยไม่ต้องผ่านกฎเกณฑ์การตรวจสอบ (Bypass Validation Logic)
// อันตราย: ปล่อยให้ภายนอกเขียนค่าทับได้โดยตรง
type UnsafeAccount struct {
Balance int64 // Public field: ใครก็แก้เป็น 1,000,000,000 ได้!
}
// ปลอดภัยตามหลัก Encapsulation:
type SafeAccount struct {
balance int64 // Private: บังคับแก้ไขผ่านฟังก์ชันที่มี Guard เท่านั้น
}
func (s *SafeAccount) Deposit(amount int64) error {
if amount <= 0 {
return errors.New("deposit amount must be positive")
}
s.balance += amount
return nil
}
transport, ledger, rfid ห้ามใช้ camelCase, snake_case, หรือขีดกลาง kebab-caseaccount.AccountService ให้ตั้งชื่อว่า account.Service เพราะเวลาเรียกจะกลายเป็น account.Service ที่อ่านลื่นไหลกว่าทดสอบความเข้าใจเชิงลึกระดับ Compiler & Package Architecture ทุกข้อมีคะแนนสะสมและอธิบายเหตุผลอย่างละเอียด
ลงมือออกแบบ Package และ Struct ให้ถูกต้องตามกฎเกณฑ์ของ Idiomatic Go และตรวจสอบผลผ่าน Verification Engine
โจทย์: สร้าง Package account โดยมี Struct Account ที่มีฟิลด์ id string (Unexported) และ balance int64 (Unexported) พร้อม Constructor New(id string) *Account และเมธอด ID() string กับ Balance() int64
โจทย์: อยู่ใน Package settlement เพื่อหลีกเลี่ยง Cyclic Dependency กับ Package account ให้ประกาศ Interface BalanceReader มีเมธอด Balance() int64 และสร้างฟังก์ชัน IsSolvent(b BalanceReader) bool คืนค่า true หากยอดเงิน > 0
โจทย์: อยู่ใน Package timing ออกแบบ Struct ชื่อ Gate (หลีกเลี่ยง Stuttering เช่น TimingGate) มีฟิลด์ GateID int (Exported) และเมธอด ProcessBib(bib int) string ที่คืนค่า fmt.Sprintf("bib %d at gate %d", bib, g.GateID)