Day 11: Package-Oriented Design
Quiz: 0/16 | Score: 0 pts

Day 11: Package-Oriented Design & Project Structure

Eradicating Spring Layer-Driven Habits, Eliminating Cyclic Dependencies, and Mastering Standard Go Project Layout

Section 0: The "Why" & Core Vocabulary

ในโลกของ JVM / Kotlin / Spring Boot เราคุ้นชินกับการจัดหมวดหมู่โค้ดตาม "หน้าที่ทางเทคนิค" (Technical Layering): คลาส Controller ทั้งหมดไปกองรวมกันที่ controller/, Service ทั้งหมดไปอยู่ที่ service/, และ Entity/DTO ทั้งหมดไปอยู่ที่ model/

The JVM Trap: Circular Dependencies & The God Model Package

ใน 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

Section 1: Go Syntax & Command Blueprint

การทำงานกับ Package ใน Go อิงตามโครงสร้าง Directory บนระบบไฟล์โดยตรง โดยมีหลักไวยากรณ์และการตั้งชื่อที่ต้องปฏิบัติตามอย่างเคร่งครัด:

1.1 การสร้าง Module และโครงสร้าง Standard Project Layout

# 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

1.2 ไวยากรณ์ Package Declaration & Visibility Rules

ใน 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
}

1.3 การ Import และการตั้งชื่อ Package ไม่ให้เกิด Package Name Pollution

The Anti-Pattern: Package utils, common, base, helpers

ห้ามตั้งชื่อแพ็กเกจว่า 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 
}

Section 2: Architecture & The `internal/` Compiler Rule

ทำไมสถาปัตยกรรมระดับ Production ของ Go จึงต้องมีโฟลเดอร์ internal/? นี่ไม่ใช่แค่ Convention ที่ตกลงกันเอง แต่เป็น Compiler Enforcement Rule ที่ถูก Hardcode ไว้ในตัว Go Toolchain ตั้งแต่ Go 1.4 เป็นต้นมา

2.1 กฎเหล็กของโฟลเดอร์ `internal/`

โค้ดใดก็ตามที่วางอยู่ใต้โฟลเดอร์ชื่อ internal/ จะสามารถถูก import ได้โดยแพ็กเกจที่อยู่ภายใต้ Root Parent เดียวกันเท่านั้น โมดูลภายนอก (Third-party หรือ Repositories อื่น) จะไม่สามารถ import ไปใช้งานได้โดยเด็ดขาด:

repo-root/ (github.com/mybank/core-ledger) ├── cmd/ │ └── ledger/ (OK: สามารถ import internal/account ได้ เพราะอยู่ใต้ repo-root เดียวกัน) ├── internal/ │ └── account/ (Private implementation details) └── pkg/ └── exportable/ (OK: สามารถ import internal/account ได้) -------------------------------------------------------------------------------------- [External Project]: github.com/other-org/hacker-tool └── main.go └── import "github.com/mybank/core-ledger/internal/account" ^^^ COMPILER ERROR: "use of internal package github.com/mybank/core-ledger/internal/account not allowed"

2.2 การตัดวงจร Cyclic Dependency ด้วย Consumer-Driven Interfaces

เมื่อ Package A ต้องการส่งข้อมูลให้ Package B แต่ Package B ก็ต้องการเรียกใช้งาน Package A ทางออกใน Go ไม่ใช่การใช้ Magic Annotation แต่เป็นการนำหลักการ "Consumer-Driven Interface" มาใช้:

[BAD: Cyclic Import Problem] +----------------------+ Import +----------------------+ | package account | -------------------------------> | package settlement | | (ต้องการ Settlement)| <------------------------------- | (ต้องการ Account) | +----------------------+ Import +----------------------+ ^^^^ COMPILER ERROR: import cycle not allowed ^^^^ ====================================================================================== [GOOD: Decoupled with Interface at Consumer Side] +----------------------+ +----------------------+ | package account | | package settlement | | | | | | - Struct Account | | - Interface: | | - Implement Method: | <........... Satisfies ......... | type BalanceStore | | Balance() int64 | (Implicit Interface) | interface { | +----------------------+ | Balance() int64 | | } | | - Settlement code | +----------------------+ | v Import เฉพาะเมื่อจำเป็นที่ cmd/main

Section 3: Visual Workflow & Dependency Graph

ใน Go โครงสร้าง Dependency Graph ของคุณต้องเป็น Directed Acyclic Graph (DAG) เสมอ ข้อมูลและคำสั่งต้องไหลจากบนลงล่าง หรือไหลจากขอบนอกเข้าสู่แกนกลางเท่านั้น

3.1 Architectural Flow in Banking Microservices

+-----------------------------+ | cmd/ledger-cli/main.go | <-- Composition Root (สร้าง Object & Inject) +-----------------------------+ | \ Imports | \ Imports v v +-----------------------+ +--------------------------+ | internal/account | | internal/settlement | | (Core Domain) | | (Business Logic) | +-----------------------+ +--------------------------+ \ / v v +-----------------------------+ | pkg/currency | <-- Low-level Domain Types / Value Objects +-----------------------------+

ข้อสังเกต: จุดที่ทำการสร้าง Instance (เช่น account.NewAccount()) และส่งผ่านเข้าไปยัง Service อื่น ควรเกิดขึ้นที่ cmd/main.go (Composition Root) เสมอ เพื่อหลีกเลี่ยงไม่ให้ Package ระดับเดียวกันต้องสร้าง Dependency ข้ามกันโดยตรง

Section 4: Edge Cases, Hazards & Idiomatic Patterns

Hazard #1: The Init Function Abuse (Magic Initialization)

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...`) เสมอ

Hazard #2: Exporting Struct Fields when Encapsulation is Needed

ในระบบ 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
}

Idiomatic Naming Conventions ใน Go:

Section 5: Knowledge Verification Quiz (16 Challenges)

ทดสอบความเข้าใจเชิงลึกระดับ Compiler & Package Architecture ทุกข้อมีคะแนนสะสมและอธิบายเหตุผลอย่างละเอียด

Section 6: Hands-on Engineering Labs

ลงมือออกแบบ Package และ Struct ให้ถูกต้องตามกฎเกณฑ์ของ Idiomatic Go และตรวจสอบผลผ่าน Verification Engine

Lab 1: Encapsulated Core Account & Constructor Pattern

โจทย์: สร้าง Package account โดยมี Struct Account ที่มีฟิลด์ id string (Unexported) และ balance int64 (Unexported) พร้อม Constructor New(id string) *Account และเมธอด ID() string กับ Balance() int64

Lab 2: Decoupled Settlement via Consumer-Driven Interface

โจทย์: อยู่ใน Package settlement เพื่อหลีกเลี่ยง Cyclic Dependency กับ Package account ให้ประกาศ Interface BalanceReader มีเมธอด Balance() int64 และสร้างฟังก์ชัน IsSolvent(b BalanceReader) bool คืนค่า true หากยอดเงิน > 0

Lab 3: Race Timing Gate Ingestor with Anti-Stuttering Naming

โจทย์: อยู่ใน Package timing ออกแบบ Struct ชื่อ Gate (หลีกเลี่ยง Stuttering เช่น TimingGate) มีฟิลด์ GateID int (Exported) และเมธอด ProcessBib(bib int) string ที่คืนค่า fmt.Sprintf("bib %d at gate %d", bib, g.GateID)