บทที่ 6 · Part 2 — Opinionated Go Style Guide
APIs, Interfaces and Program Structure
ออกแบบ constructor, interface, embedding, global state และ package structure ให้ contract แคบและชัด
ทีมที่ย้ายมาจากภาษาอื่นมักสร้าง IUserRepository, AbstractService, builder และ dependency container ก่อนมี
use case จริง อีกด้านหนึ่ง ทีมที่ยึด “Go ต้องง่าย” มากเกินไปอาจวาง database client เป็น global และให้ทุก package
เรียกตรง ๆ ทั้ง 2 แบบทำให้ change ยากเหมือนกัน แบบแรกมี abstraction ที่ยังไม่รู้ว่าจะปกป้องอะไร แบบหลังไม่มี
boundary ให้เปลี่ยนหรือทดสอบ
บทนี้กลั่นคำแนะนำของ Google และ Uber เรื่อง interface, constructor, options, embedding, global state,
init, package organization และ process boundary เป็น house style ของคอร์ส จุดยืนคือเริ่มจาก concrete และ
synchronous API แล้วเพิ่ม abstraction เมื่อมันทำให้ contract, ownership หรือ evolution ชัดขึ้น
จบบทนี้คุณจะ
- ออกแบบ package และ public surface จาก client code
- สร้าง interface ฝั่ง consumer และรู้ข้อยกเว้นที่ producer ควรประกาศ protocol
- เลือก zero value, constructor, config struct หรือ functional options ตาม contract
- คุม embedding, global state,
init, goroutine และ process exit ไม่ให้ซ่อน lifecycle
Package เป็นหน่วยของ Capability
package ที่ดีรวม type และ function ซึ่งเปลี่ยนด้วยเหตุผลใกล้กัน ชื่อ directory layer เช่น models, services,
helpers อาจดูเป็นระเบียบแต่บังคับให้ 1 feature กระจายหลาย package และสร้าง import cycle ได้ง่าย Course
default คือจัดรอบ capability หรือ domain ก่อน แล้วค่อยแยก adapter เมื่อ dependency direction ต้องการ
ledger/
entry.go
service.go
mysqlstore/
httpapi/
นี่ไม่ใช่ universal layout โปรแกรมเล็กอาจมี package เดียวที่ชัดกว่า ประเด็นคือแต่ละ package ต้องอธิบายได้ว่า ผู้ใช้ import มาเพื่อ capability อะไร ไม่ใช่เพราะไฟล์ชนิดเดียวกันถูกเก็บไว้ด้วยกัน
เริ่มออกแบบจาก call site:
store := mysqlstore.New(db)
service := ledger.NewService(store, clock)
handler := httpapi.NewHandler(service, logger)
ถ้า call site ต้องรู้ internal type หลายตัว, เรียก Init, ตั้ง field ตามลำดับ หรือ import package ข้าม layer
มากเกิน นั่นเป็น evidence ว่า public surface ยังไม่ลึกพอ อ่านต่อใน
Packages as APIs
เริ่ม Concrete แล้ว Discover Interface
อย่าสร้าง interface ฝั่ง implementation เพียงเพราะอาจมี mock ในอนาคต ให้ package ผู้ใช้ประกาศ method set เล็กที่สุดที่ต้องการ:
package ledger
type entryStore interface {
Load(context.Context, string) (Entry, error)
Save(context.Context, Entry) error
}
type Service struct {
store entryStore
}
package mysqlstore คืน concrete *Store โดยไม่ต้องรู้ว่า ledger มี interface นี้ วิธีนี้ลด coupling และให้
consumer เป็นเจ้าของ contract Test ใช้ stub ที่มีเพียง 2 method ได้โดยไม่บังคับ implement admin methods
ของ database package
คำว่า “interface ต้องมี 1–3 method” เป็น heuristic ไม่ใช่ law method set ควรเล็กและ cohesive แต่
protocol จริงอย่าง http.RoundTripper, transactional store หรือ generated client อาจมีเหตุผลให้ producer
ประกาศ interface ข้อยกเว้นสำคัญคือ package ที่มีหลาย implementation โดย design หรือ API ต้องให้ wrapper
implement ตั้งแต่ต้น ถึงอย่างนั้นควร document semantics ไม่ใช่แค่ method signatures
Course default คือ accept interface เฉพาะที่ function ต้องการและ return concrete type เพื่อไม่ปิด capability ของ result เร็วเกินไป แต่ไม่ใช้ slogan นี้กับ factory ที่เลือก implementation แบบ runtime หรือ plugin boundary ซึ่งการ return interface เป็น contract ที่ตั้งใจได้
อย่าส่ง *interface ปกติ interface value เก็บ dynamic type/value และส่งเป็น value อยู่แล้ว pointer-to-interface
เพิ่ม nil state 2 ชั้น ใช้เฉพาะ API พิเศษที่ต้องแทนที่ตัว interface value เองและต้องอธิบายชัด
Interface Compliance และ Optional Capability
Go ใช้ implicit satisfaction จึงไม่ต้องประกาศ implements แต่ compile-time assertion มีประโยชน์เมื่อการเป็น
protocol นั้นเป็นเจตนาหลักหรือ method set ซับซ้อน:
var _ http.Handler = (*Handler)(nil)
อย่าใส่ assertion ให้ทุก type แบบ mechanical ถ้า compiler ตรวจอยู่แล้วเมื่อ inject เข้าจุดใช้งาน assertion ซ้ำอาจเป็น noise ใช้ใกล้ type เมื่อมันช่วยประกาศ compatibility ที่ต้องรักษา
optional capability ใช้ type assertion แบบ comma-ok ได้ เช่น writer ที่อาจ Flush ได้ แต่ไม่ควรสร้าง optional
interface หลายชั้นจน behavior เปลี่ยนตาม implementation โดยผู้เรียกมองไม่เห็น ถ้าการ flush จำเป็นต่อ
correctness ให้มันอยู่ใน required contract
Embedding โปรโมต API ไม่ใช่แค่ลดการพิมพ์
struct embedding ทำให้ field และ method ของ inner type ถูก promote ถ้า embed ใน exported struct คุณกำลังเปิด API ของ type นั้นและอาจรับ method ใหม่ใน dependency version ถัดไปโดยไม่ตั้งใจ Course default คือใช้ named field สำหรับ dependency และ delegate เฉพาะ behavior ที่ต้องการ support
type Server struct {
logger *slog.Logger
client *http.Client
}
embedding เหมาะเมื่อ outer type ตั้งใจเป็นการขยาย abstraction เดิมและพร้อมรักษา full promoted API เช่น
test helper ภายใน package หรือ adapter ที่ contract ชัด Interface embedding เพื่อประกอบ protocol เช่น
io.ReadWriteCloser ก็เป็น use case ที่ดี เพราะ method set ที่รวมกันมองเห็นตรง declaration
หลีกเลี่ยง embedding mutex เพื่อให้ Lock ถูก promote ออกมา ใช้ named mu sync.Mutex เพื่อเก็บ lock เป็น
implementation detail และป้องกัน caller ล็อกผิด invariant
Constructor คือจุดสร้าง Invariant
ไม่ใช่ทุก type ต้องมี NewT ถ้า zero value ใช้ได้และไม่มี dependency constructor เพิ่มพิธีโดยไม่เพิ่ม safety
แต่เมื่อ type ต้อง validate config, clone input, ตั้ง default หรือถือ resource constructor คือจุดที่เหมาะ
เลือก signature ตาม shape ของ configuration:
| รูปแบบ | ใช้เมื่อ | ระวัง |
|---|---|---|
| ordinary parameters | required values น้อยและต่างชนิดชัด | positional values type เดียวกันสลับได้ |
| config struct | caller มักตั้งหลายค่าและต้อง review ทั้งชุด | zero fields ต้องมี semantics ชัด |
| functional options | public API มี optional settings และต้อง evolve | ซ่อน duplicate/order/validation semantics |
| builder | มีหลายขั้นที่ผลลัพธ์ intermediate มีความหมาย | มัก verbose เกินสำหรับ Go service |
type Config struct {
Timeout time.Duration
Retries int
}
func NewClient(endpoint string, cfg Config) (*Client, error) {
if endpoint == "" {
return nil, errors.New("endpoint is required")
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
return &Client{endpoint: endpoint, config: cfg}, nil
}
functional options ไม่ได้ “ดีกว่าเมื่อเกิน 3 parameters” โดยอัตโนมัติ ถ้าใช้ต้องตอบให้ได้ว่า option ซ้ำกัน last wins หรือ error, order มีผลหรือไม่, validation fail แล้วคืนอย่างไร และ option function ปลอดภัยต่อ concurrent reuse หรือไม่ Required dependency ควรยังเป็น parameter เพื่อให้ call site เห็นสิ่งที่ขาดไม่ได้
รายละเอียด compatibility และ DI อยู่ใน Construction and Compatibility
Global State ทำให้ Dependency และ Test ซ่อนตัว
immutable package constants และ compiled regexp ที่ไม่มี mutable state ใช้ระดับ package ได้ แต่ client, cache, clock, random source หรือ config ที่เปลี่ยนได้ไม่ควรเป็น mutable global เพราะ test parallel กันแล้วรบกวน กันและ shutdown ไม่มี owner
func run(ctx context.Context, cfg Config) error {
db, err := openDB(ctx, cfg.Database)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer db.Close()
store := mysqlstore.New(db)
app := NewApp(store, slog.Default())
return app.Run(ctx)
}
default instance ที่ standard library ใช้ เช่น http.DefaultClient ไม่ใช่ใบอนุญาตให้สร้าง global ทุก package
ถ้าต้องมี convenience function ให้เป็น thin proxy ไปยัง immutable/default instance, document concurrency และยัง
เปิดทางให้ caller inject instance เอง
อย่าแก้ testability ด้วย exported setter อย่าง SetGlobalClientForTest เพราะเพิ่ม temporal coupling ใช้ constructor
หรือ parameter ที่ explicit แทน
init จำกัดไว้ที่ Registration ที่หลีกเลี่ยงไม่ได้
init() รันก่อน main, คืน error ไม่ได้ และซ่อน ordering จึงไม่ควรเปิด network, อ่าน environment, start
goroutine หรือ register business dependency Course default คือ startup ทำใน run/main ซึ่ง log, cleanup และ
คืน error ได้
blank import เพื่อ trigger driver registration หรือ generated registry อาจยอมรับได้ที่ composition root เมื่อ
ecosystem contract บังคับ แต่ package comment หรือ import comment ต้องทำให้ side effect มองเห็น อย่ากระจาย
init หลายไฟล์แล้วอาศัย filename order เป็น workflow
MustCompile ระดับ package เหมาะกับ literal ที่ programmer ควบคุมและ failure คือ build-time-like bug ไม่ใช้
Must* กับ environment หรือ user input ซึ่งต้องคืน error ที่จัดการได้
External Contracts และ Compatibility Surface
struct tag เป็น protocol ไม่ใช่ decoration ชื่อ JSON/DB/YAML ที่ export แล้วต้องเปลี่ยนอย่างมี versioning และ
contract test ระบุ tag ให้ชัดเมื่อ field ข้าม boundary และตัดสิน omitempty, unknown fields, nil/empty จาก schema
จริง อย่าใส่ทุก tag ที่ library รองรับลง domain struct เดียวจน domain ผูกหลาย adapter; แยก transport type เมื่อ
lifecycle ของ schema ต่างจาก model ภายใน
type CreateEntryRequest struct {
AccountID string `json:"account_id"`
Amount int64 `json:"amount_satangs"`
}
type alias ใช้ได้กับ migration หรือ compatibility ที่ตั้งใจและมีแผนจบ ไม่ใช้ alias เพื่อทำให้ package ownership คลุมเครือ เพราะ alias ไม่สร้าง type safety ใหม่และ method set ยังเป็นของ type เดิม ถ้าต้องการ domain invariant ใหม่ให้สร้าง defined type และ conversion ที่ explicit
library ไม่ควร parse global flags หรืออ่าน environment เอง Binary/composition root เป็นเจ้าของ deployment policy แล้วส่ง config ที่ parse/validate แล้วเข้า package วิธีนี้ทำให้ library ใช้ได้ทั้ง HTTP service, test, Lambda และ worker โดยไม่แย่ง flag namespace หรือซ่อน startup failure
randomness ต้องเลือกตาม threat model Token, key, nonce และ security-sensitive identifier ใช้ crypto/rand;
simulation, sampling หรือ randomized test ใช้ math/rand/v2 พร้อม seed/control ตาม test contract อย่าสร้าง
wrapper ชื่อ random แล้วทำให้ callerไม่รู้ว่า source มี security guarantee แบบใด
Synchronous API เป็น Default
function ที่ sync ให้ caller ตัดสินว่าจะเรียกใน goroutine, จำกัด concurrency และ wait อย่างไร API ที่ start
goroutine ภายในต้องเปิด lifecycle ออกมา เช่นรับ context, คืน handle/error channel หรือมี Close/Wait
type Worker struct { /* ... */ }
func (w *Worker) Run(ctx context.Context) error
signature นี้ทำให้ owner เรียกด้วย errgroup ได้และรู้ว่า return หมายถึงหยุดแล้ว ต่างจาก Start() ที่ return
ทันทีแต่ไม่บอกว่า startup สำเร็จไหม, error ไปไหน หรือ shutdown เสร็จเมื่อไร
ใช้ channel direction ใน public API เพื่อบอก ownership เช่น <-chan Event หรือ chan<- Job แต่ไม่คืน channel
เพียงเพื่อดูเป็น concurrent ถ้า iterator, callback หรือ synchronous slice สื่อ contract ง่ายกว่า
Panic, Fatal และ Process Exit อยู่ที่ Edge
library คืน error สำหรับ network failure, invalid input และ config ที่ caller อาจแก้ได้ panic สงวนไว้กับ
programmer invariant ที่ไม่ควรเกิด Recover ถ้าจำเป็นให้อยู่ที่ server/goroutine boundary และแปลงเป็น error/log
พร้อมรักษา process health ไม่วาง recover ทั่วทุก helper
os.Exit, log.Fatal และ signal handling ควรอยู่ใน main เท่านั้น เพราะ exit ข้าม deferred cleanup และทำให้
library/test process ถูกฆ่าโดยไม่เปิดทางตัดสินใจ รูปแบบที่อ่านง่ายคือ main เรียก run แล้ว exit 1 ครั้ง:
func main() {
if err := run(context.Background()); err != nil {
slog.Error("service stopped", "error", err)
os.Exit(1)
}
}
Review Lab: ลด Abstraction แต่เพิ่ม Contract
var DefaultRepository IRepository
type Service struct {
IRepository
}
func init() {
DefaultRepository = NewRepository(os.Getenv("DSN"))
}
func NewService(opts ...Option) IService {
s := &Service{IRepository: DefaultRepository}
go s.refresh()
return s
}
review ควรพบ producer-owned/premature interfaces, mutable global, public embedding, implicit environment,
constructor ที่ report failure ไม่ได้, functional options ที่ยังไม่เห็นประโยชน์, return interface ปิด capability
และ goroutine ไม่มี stop/wait Contract ที่แคบกว่าอาจเป็น NewService(store Store, cfg Config) (*Service, error) และ Run(ctx) error โดย composition root เปิด store แล้ว inject เข้ามา
อย่า refactor เพียงลบ prefix I แล้วถือว่าจบ ชื่อเป็น symptom; งานหลักคือทำ dependency, failure และ lifecycle
ให้มองเห็นที่ call site
Checklist สำหรับ API Structure
- package บอก capability และ public symbol อ่านดีเมื่อมี package นำหน้า
- interface อยู่ใกล้ consumer และมี method เท่าที่ต้องใช้
- embedding ถูกเลือกเพราะตั้งใจโปรโมต API ไม่ใช่เพื่อลด boilerplate
- constructor สร้าง invariant และเปิด failure; required dependencies มองเห็น
- mutable dependency มี instance owner ไม่มี global setter ซ่อน test state
init, goroutine, panic และ process exit ถูกจำกัดไว้ที่ boundary ที่เหมาะ- async API มี stop, wait, error และ overload contract ครบ
บทนี้เรียบเรียงใหม่โดยอิง Google Go Style Decisions, Google Go Best Practices, Uber Go Style Guide และ Go Code Review Comments