บทที่ 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 parametersrequired values น้อยและต่างชนิดชัดpositional values type เดียวกันสลับได้
config structcaller มักตั้งหลายค่าและต้อง review ทั้งชุดzero fields ต้องมี semantics ชัด
functional optionspublic 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