Beyond Syntax: เขียน Go ให้ Scale กับทีม ไม่ใช่แค่ CPU

Beyond Syntax: เขียน Go ให้ Scale กับทีม ไม่ใช่แค่ CPU

Dev Team · IT ·

Beyond Syntax: เขียน Go ให้ Scale กับทีม ไม่ใช่แค่ CPU

ทฤษฎีของเราคือ: “Junior engineer ทำให้โค้ดทำงานได้ แต่ Senior engineer ทำให้โค้ดอ่านแล้วเข้าใจทันที”

ฟังดูเท่ใช่ไหม? เราก็คิดว่าเท่เหมือนกัน จนกระทั่งเจอ PR ของตัวเองจาก 3 เดือนก่อนแล้วอ่านไม่ออก 💀

Go ขึ้นชื่อเรื่องเรียนง่าย — ถ้าเทียบกับภาษาที่เราเคยเขียนมาทั้งหมด Go ง่ายสุด syntax ไม่ verbose เหมือน Java หรือ C++ ไม่มี generics ที่ยัดเยียดมา (ตอนนี้มีแล้วนะ แต่อย่าบอกใคร) ไม่มี inheritance hierarchy ซ้อนกัน 10 ชั้น ไม่มี operator overloading ที่ทำให้งง

ภายในสัปดาห์เดียวคุณ productive ได้แล้ว ลองเทียบกับ Rust ที่ต้องสู้กับ borrow checker เป็นเดือน ๆ ดู — เหมือนจีบสาวที่พ่อเป็น compiler

แต่ productive ไม่ได้แปลว่า master — เหมือนทำไข่เจียวได้ไม่ได้แปลว่าเป็นเชฟ

เรา review PR มาหลายร้อยชิ้นจากหลายทีม เห็น pattern เดิมซ้ำ ๆ: โค้ด compile ผ่าน test ผ่าน feature ship ได้ — แต่ผ่านไป 6 เดือนไม่มีใครอยากแตะไฟล์นั้น ไม่ใช่เพราะ bug แต่เพราะ อ่านแล้วเหนื่อย เหมือนอ่านเงื่อนไขกู้บ้าน

ปัญหาไม่ได้อยู่ที่ syntax แต่อยู่ที่สิ่งที่สอนยากกว่า: intent legibility — ความสามารถในการสื่อว่าโค้ดทำอะไร และ ทำไมถึงออกแบบมาแบบนี้ ให้คนอ่านเข้าใจได้ตอนตี 2 ตอนที่ระบบกำลังไฟไหม้ (แล้วคุณก็ไม่ใช่คนเขียน)

บทความนี้รวบรวม pattern ที่แยก Go ระดับ “เรียนมาแล้ว” ออกจาก Go ระดับ “คิดมาแล้ว” — 12 ข้อ เท่ากับจำนวนเดือนที่ต้องทำงานกับโค้ดของตัวเอง


1. Function Signature คือสัญญา ไม่ใช่สิ่งที่คิดทีหลัง

สิ่งแรกที่เพื่อนร่วมทีมอ่านคือ function signature ก่อนจะอ่าน body ก่อนจะอ่าน comment — ชื่อ, parameter, return type มันเล่าเรื่องของมันอยู่แล้ว ถ้ามันเล่าไม่รู้เรื่อง… ก็เหมือนชื่อร้านอาหารที่เขียนว่า “ร้านอาหาร”

แบบ amateur:

func Send(data []byte, opts map[string]interface{}, retry bool) ([]byte, error)

อ่านแล้วเหมือนกำแพง — retry คือ retry ไหม retry กี่ครั้ง? opts ต้องใส่ key อะไรบ้าง? []byte ที่ return กลับมาคืออะไรกันแน่? เป็น response หรือเป็น receipt?

แบบ senior:

func SendNotification(msg Message, opts DeliveryOptions) (DeliveryReceipt, error)

ตอนนี้ opts เป็น struct ที่ document ได้และ evolve ได้แยกจากกัน DeliveryReceipt เป็น named type ที่บอกว่า return value มีความหมาย — ไม่ใช่แค่ bytes แต่คือหลักฐานว่าส่งแล้ว

ชื่อ function encode ทั้ง action และ parameter ที่สำคัญ โดยไม่ต้องอ่าน body — ไม่ต้องเปิด implementation ไม่ต้องถามใครใน Slack

กฎทอง: ถ้าคนต้องอ่าน function body เพื่อจะรู้ว่าเรียกยังไง — signature ของคุณล้มเหลวแล้ว (และถ้าต้อง Slack ถามด้วย — ล้มเหลวสองรอบ)

Named Return Values — ใช้น้อย ๆ แต่ใช้ให้มีความหมาย

Named return ไม่ใช่แค่ความสะดวก — มันคือ documentation ที่ compiler บังคับให้อยู่

// กำกวม — อันไหนคือ header อันไหนคือ body?
func ExtractParts(raw string, sep rune) (string, string, error)

// ชัดเจน — อ่านแค่ signature ก็เข้าใจ
func ExtractParts(raw string, sep rune) (header, body string, err error)

เมื่อ function return หลายค่าที่เป็น type เดียวกัน named returns ตัด overhead “อันไหนคืออันไหน?” ออกไปเลย

แต่ อย่าใช้ naked return — ประหยัดได้ 3 ตัวอักษร แต่เสียเวลาอ่าน 3 นาที เหมือนย่อ URL ให้สั้นจนไม่มีใครกล้ากด


2. Error Handling คือสถาปัตยกรรม ไม่ใช่ Boilerplate

ทุกคนเคยได้ยินคนบ่นเรื่อง if err != nil ใน Go ที่ดู verbose — บ่นกันจน community เกือบจะมี meme ประจำภาษา (จริง ๆ มีแล้วล่ะ) คนที่บ่นมักเป็นคนที่ยังไม่ได้คิดว่า error เหล่านั้น หมายถึงอะไร

Error handling ใน Go เป็น first-class design activity — วิธีที่คุณจัด error บอก caller ว่า ต้องทำอะไรกับมัน

Sentinel Errors vs. Error Types: รู้ความต่าง

ใช้ sentinel errors เมื่อ caller ต้องเช็คตัวตน:

var ErrQuotaExceeded = errors.New("storage quota exceeded")
var ErrDuplicate = errors.New("duplicate entry")

// Caller แยก branch ได้ง่าย:
if errors.Is(err, ErrQuotaExceeded) {
    return http.StatusTooManyRequests
}

ใช้ error types เมื่อ caller ต้องการ context เพื่อตัดสินใจ:

type PermissionError struct {
    UserID   string
    Resource string
    Action   string
}

func (e *PermissionError) Error() string {
    return fmt.Sprintf("user %s cannot %s on %s", e.UserID, e.Action, e.Resource)
}

// Caller ดึงข้อมูลไป audit ได้:
var pe *PermissionError
if errors.As(err, &pe) {
    auditLog.Warn("access denied", "user", pe.UserID, "resource", pe.Resource)
}

ความต่างนี้สำคัญเชิงสถาปัตยกรรม: sentinel errors ผูก package identity ของคุณกับ caller ส่วน error types ให้ข้อมูลโดยไม่ผูกมัด — เหมือนต่างระหว่าง “ผิด” กับ “ผิดเพราะอะไร”

Wrap Error ด้วย Context

แบบแย่ — เพิ่มแค่คำ (เหมือนบอกว่า “มีปัญหา” แล้วก็จบ):

return fmt.Errorf("something went wrong: %w", err)

แบบดี — เพิ่ม location และ intent (เหมือนบอกว่า “มีปัญหาที่ไหน เรื่องอะไร”):

return fmt.Errorf("uploadFile: writing chunk %d to bucket %s: %w", chunkNum, bucketName, err)

เมื่อ error นี้โผล่ใน log ตอนตี 3 คุณจะรู้ call site, identifier ที่เกี่ยวข้อง, และ root cause ทันที — ไม่ต้องเปิด debugger ไม่ต้องนั่งเดาเหมือนหมอดู

Senior engineer ถามตัวเองเสมอว่า: “ถ้า error นี้โผล่ใน production โดยไม่มี context อื่นเลย มีคนหาปัญหาเจอไหม?” ถ้าคำตอบคือไม่ — wrapping ยังไม่พอ


3. Interfaces: นิยามที่จุดใช้งาน

นี่คือ Go convention ที่ถูกเข้าใจผิดมากที่สุด — เหมือนคนที่ใส่ถุงเท้ากับรองเท้าแตะแล้วบอกว่า “fashion” แต่อันนี้มีผลกระทบระดับทีมอย่างมหาศาล

ในภาษาอย่าง Java, interface ถูกนิยามคู่กับ implementation — service นิยาม interface, client depend มัน Go กลับแนวคิดนี้โดยตั้งใจ

ผิด (คิดแบบ Java ใน Go):

// mailer/sender.go — นิยามโดย implementor
package mailer

type Sender interface {
    Send(to string, subject string, body string) error
    SendBulk(recipients []string, msg Template) error
}

ถูก (Go idiom):

// order/service.go — นิยามโดย consumer
package order

type emailSender interface {
    Send(to string, subject string, body string) error
}

type OrderService struct {
    notifier emailSender
}

สังเกตว่า consumer ขอแค่ Send — ไม่สนใจ SendBulk เพราะไม่ได้ใช้ Interface เล็กลง test ง่ายขึ้น coupling น้อยลง ชีวิตดีขึ้น

กฎทอง: accept interfaces, return structs — return interface บังคับให้ caller เข้า abstraction ของคุณ return concrete type ให้ caller ตัดสินใจเอง


4. Concurrency: ออกแบบเรื่อง Ownership ไม่ใช่แค่ Correctness

Bug concurrency ส่วนใหญ่ใน Go ไม่ใช่ race condition แบบเดิม ๆ แต่คือ ความคลุมเครือเรื่อง ownership — goroutine สองตัวคิดว่าตัวเองมีสิทธิ์จัดการ data ชิ้นเดียวกัน เหมือนสองคนจับพวงมาลัยพร้อมกัน ผลลัพธ์ไม่มีทางดี

คำถามแรกของ senior engineer ตอนเขียน concurrent code ไม่ใช่ “ป้องกันยังไง?” แต่คือ “ใครเป็นเจ้าของ?”

Channel Ownership Pattern

Document ownership ให้ชัดเจนในระดับ channel:

// producer เป็นเจ้าของ write; consumer เป็นเจ้าของ read
func streamMetrics(ctx context.Context) <-chan Metric {
    ch := make(chan Metric, 128)

    go func() {
        defer close(ch) // producer close — เสมอ ห้ามให้คนอื่น close
        for {
            select {
            case <-ctx.Done():
                return
            default:
                m := collectMetric()
                ch <- m
            }
        }
    }()

    return ch // caller ได้ read-only channel
}

การ return <-chan Metric แทน chan Metric ไม่ใช่แค่ type constraint — มันคือการสื่อสาร บอก caller ว่า: คุณคือ consumer ไม่ใช่ producer function ที่สร้าง channel รับผิดชอบ close มัน

Pattern นี้ใช้สม่ำเสมอ ตัด panic จาก “closed channel” ออกไปทั้ง class — เหมือนติดป้าย “ทางเข้า” “ทางออก” ที่ประตู

ใช้ errgroup แทน Goroutine เปล่า ๆ

Fan-out goroutine แบบดิบ ๆ แทบจะไม่เคยเป็นคำตอบที่ถูกใน production — เหมือนส่งเด็กฝึกงาน 10 คนไปทำงานพร้อมกันโดยไม่มีหัวหน้า:

// เปราะบาง: ไม่มี error propagation, ไม่มี lifecycle control
for _, url := range urls {
    go func(url string) {
        crawl(url)
    }(url)
}

ใช้ errgroup กับ context แทน:

g, ctx := errgroup.WithContext(ctx)

for _, url := range urls {
    url := url
    g.Go(func() error {
        return crawl(ctx, url)
    })
}

if err := g.Wait(); err != nil {
    return fmt.Errorf("crawl batch: %w", err)
}

ตอนนี้ cancellation propagate ได้ error โผล่ขึ้นมาได้ และ caller มี join point ที่สะอาด นี่คือ pattern ที่ผ่าน code review, รับ load spike, และรอด teammate ลาหายไปได้

done Channel vs. context.Context

ถ้ายังส่ง done chan struct{} เป็น cancellation signal ในโค้ดใหม่ — หยุดเลย เหมือนยังใช้แผนที่กระดาษตอนที่มี GPS:

// แบบเก่า — compose ไม่ได้ เหมือนรีโมทที่กดได้แค่ปิด
func worker(done <-chan struct{}) { ... }

// แบบถูก — compose กับ timeout, deadline, values ได้
func worker(ctx context.Context) error { ... }

context.Context พก deadline, cancellation signal, และ request-scoped values ได้ done channel พกได้แค่ “หยุด” ใช้ context เสมอ


5. Package Design: สถาปัตยกรรมอยู่ใน Import Graph

ใน Go, package structure คือ สถาปัตยกรรม import graph คือ dependency graph

ถ้าออกแบบผิดจะไม่รู้สึกทันที — แต่จะรู้ตอนที่ codebase ต้องโต เหมือนสร้างบ้านบนทรายก็อยู่ได้… จนฝนตก

Anti-pattern: ถุงขยะ utils

internal/
  utils/
    utils.go       // 2,400 บรรทัด — ใครเป็นคนเขียน?
    string.go
    http.go
    db.go
    crypto.go

Package utils คือสัญญาณว่าไม่มีใครมีเวลาคิดว่าของพวกนี้ควรอยู่ที่ไหน มันโตไม่หยุด สะสม coupling ที่มองไม่เห็น และทำให้ไม่มีทางเข้าใจว่าอะไร depend อะไร — เหมือนลิ้นชักที่ใส่ทุกอย่างที่ไม่รู้จะวางไว้ไหน

ถามแทน: สิ่งนี้รับผิดชอบอะไร และต้องการอะไรเพื่อทำหน้าที่?

internal/
  httputil/
    retry.go
    middleware.go
  storage/
    upload.go
    download.go
  auth/
    token.go
    verify.go

แต่ละ package มีหน้าที่ชัดเจน เข้าใจได้ ทดสอบได้ เปลี่ยนได้ แยกจากกัน — เหมือนจัดห้องให้มีที่ทางของทุกอย่าง


6. Unexported Field คือ Policy Decision

ทุก exported field ใน struct คือ คำมั่นสัญญา พอ export ไปแล้ว caller จะ depend มัน และการเปลี่ยนคือ breaking change — เหมือนสัญญาแต่งงาน เลิกทีไม่ง่าย

Senior engineer default ไปที่ unexported และตัดสินใจ export อย่างมีสติ:

type Server struct {
    addr     string        // unexported: ห้ามแก้หลัง start
    port     int           // unexported: เช่นกัน
    timeout  time.Duration // unexported: มี default ที่สมเหตุสมผล
    MaxConn  int           // exported: caller ปรับได้ตามต้องการ
}

สำคัญกว่านั้น constructor function ที่มี validation encode invariant:

func NewServer(addr string, port int) (*Server, error) {
    if addr == "" {
        return nil, errors.New("address is required")
    }
    if port <= 0 || port > 65535 {
        return nil, fmt.Errorf("invalid port: %d", port)
    }
    return &Server{addr: addr, port: port, timeout: 30 * time.Second}, nil
}

Server ที่ invalid ไม่สามารถมีอยู่ได้ ไม่ต้อง validate ที่ทุก call site เพราะ constructor ทำให้สร้างของเสียไม่ได้ตั้งแต่ต้น — นี่คือหลักการ “ทำให้ state ที่ผิดสร้างไม่ได้” ซึ่งใช้ได้ทุกภาษา (แต่ Go ทำได้สวยเป็นพิเศษเพราะ unexported fields + constructor)


7. Table-Driven Tests: เรื่อง Coverage Legibility ไม่ใช่แค่ DRY

นักพัฒนา Go ส่วนใหญ่รู้จัก table-driven tests แต่น้อยคนเข้าใจว่าทำไมมันทำงานดีมากในระดับทีม — คำตอบคือ “อ่านแค่ table ก็รู้ว่า test ครอบคลุมอะไร”

func TestSendNotification(t *testing.T) {
    tests := []struct {
        name      string
        recipient string
        channel   string
        wantErr   bool
        errTarget error
    }{
        {
            name:      "valid email notification",
            recipient: "[email protected]",
            channel:   "email",
        },
        {
            name:      "empty recipient rejected",
            recipient: "",
            channel:   "email",
            wantErr:   true,
            errTarget: ErrInvalidRecipient,
        },
        {
            name:      "unsupported channel rejected",
            recipient: "[email protected]",
            channel:   "pigeon",
            wantErr:   true,
            errTarget: ErrUnsupportedChannel,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := SendNotification(tt.recipient, tt.channel)
            if tt.wantErr {
                if !errors.Is(err, tt.errTarget) {
                    t.Errorf("got error %v, want %v", err, tt.errTarget)
                }
                return
            }
            if err != nil {
                t.Errorf("unexpected error: %v", err)
            }
        })
    }
}

จุดแข็งคือ: เพิ่ม test case ใหม่แค่เพิ่ม struct เข้าไป ไม่ต้องเขียน function ใหม่ ทีมใหม่เข้ามาอ่านก็เข้าใจว่ามี test อะไรบ้างภายในไม่กี่วินาที — นี่คือ coverage legibility (และใช่ channel “pigeon” ก็ unsupported จริง ๆ)


8. Naming: ความแม่นยำมาก่อนความสั้น

Convention ของ Go คือ ชื่อสั้นใน scope เล็ก ชื่อยาวขึ้นเมื่อ scope กว้างขึ้น แต่มิติที่คนส่วนใหญ่พลาดคือ ความแม่นยำ — สั้นแต่ไม่แม่นก็เหมือนบอกทางว่า “ไปทางนั้น” โดยไม่ชี้

// ไม่แม่นยำ — "data" หมายถึงอะไร? ข้อมูลอะไร? ของใคร?
func handle(data []byte) error

// แม่นยำ — รู้เลยว่าอะไรเข้ามา
func handleIncomingOrder(payload []byte) error

ตัวแปรตัวอักษรเดียวใน loop สั้น ๆ ไม่เป็นไร แต่ตัวแปรตัวอักษรเดียวเป็น struct field ไม่ได้ ต้นทุนของการถอดรหัส s.a vs server.address อาจดูน้อยต่อครั้ง แต่รวมทั้ง codebase แล้วมหาศาล — เหมือนค่าเซอร์วิสบวกทุกบิล

Pattern ที่ควรจำ: method name บน type ไม่ต้องซ้ำ type:

// ซ้ำซ้อน — receiver บอกอยู่แล้วว่าคือ Order
func (o *Order) GetOrderStatus() string

// สะอาด
func (o *Order) Status() string

ที่ call site เห็น order.Status() อยู่แล้ว ซ้ำชื่อ type ใน method name คือ noise — เหมือนพิธีกรประกาศว่า “ตอนนี้เป็นเวลาของเวลาที่จะ…“


9. init() แทบไม่เคยเป็นคำตอบที่ถูก

init() รันอัตโนมัติ มองไม่เห็นจาก caller และ return error ไม่ได้ สามคุณสมบัตินี้รวมกันเป็นหายนะ — เหมือนระเบิดเวลาที่ไม่มีจอแสดงเวลา

// ซ่อน, test ไม่ได้, config ไม่ได้
func init() {
    cache, _ = redis.Connect(os.Getenv("REDIS_URL"))
}

ถ้า environment variable ผิด cache จะเป็น nil แล้วคุณจะรู้ตอน request แรก ไม่ใช่ตอน startup ไม่มีทาง inject test connection ไม่มีทาง handle error — เหมือนรู้ว่าลืมกุญแจตอนถึงบ้านแล้ว

ทางเลือกคือ explicit initialization เสมอ:

func NewCache(redisURL string) (*redis.Client, error) {
    client := redis.NewClient(&redis.Options{Addr: redisURL})
    if err := client.Ping(context.Background()).Err(); err != nil {
        return nil, fmt.Errorf("NewCache: ping: %w", err)
    }
    return client, nil
}

ตอนนี้ทุกอย่างโปร่งใส: สร้างไม่ได้ก็รู้ทันที, test ใช้ mock ได้, ไม่มีสถานะแอบซ่อน


10. Comments: Document “ทำไม” ไม่ใช่ “ทำอะไร”

// แย่: บอกซ้ำกับโค้ด — ขอบคุณนะ Captain Obvious
// Increment counter
counter++

// ดี: อธิบายเหตุผลที่ไม่ชัดเจน
// เพิ่มก่อน response เพื่อป้องกัน race ที่ client retry
// ก่อนที่ counter จะ reflect request ที่กำลังทำอยู่
counter++

“ทำอะไร” อ่านได้จากโค้ด “ทำไม” อยู่ในหัวของคนเขียน จนกว่าจะเขียนลงไป — แล้วจะหายไปตอนคนนั้นลาออก

สำหรับ exported symbol, comment คือ API documentation — เขียนให้คนที่จะใช้ function โดยไม่อ่าน implementation:

// Upload ส่งไฟล์ไปยัง object storage ที่กำหนด
// return ErrFileTooLarge ถ้าไฟล์เกิน quota ที่ตั้งไว้
// return ErrBucketNotFound ถ้า bucket ไม่มีอยู่
// หรือ error อื่นสำหรับ transient failure ที่ retry ได้
func (s *Storage) Upload(ctx context.Context, file File) (ObjectRef, error)

11. Dependency Injection: ทำให้โค้ดทดสอบได้ตั้งแต่วันแรก

Go ไม่มี DI framework หรู ๆ แบบ Spring — แต่จริง ๆ แล้วนั่นคือข้อดี เพราะ DI ใน Go ทำได้ด้วย constructor ธรรมดา ๆ ไม่ต้อง annotation ไม่ต้อง reflection ไม่ต้อง magic ที่ทำให้งง

แบบ test ยาก:

type ReportService struct{}

func (s *ReportService) Generate(userID string) (Report, error) {
    // เรียก database ตรง ๆ — mock ไม่ได้
    user, err := db.GetUser(userID)
    if err != nil {
        return Report{}, err
    }
    // เรียก external API ตรง ๆ — test ช้า, flaky
    stats, err := analytics.FetchStats(userID)
    if err != nil {
        return Report{}, err
    }
    return buildReport(user, stats), nil
}

แบบ test ง่าย:

type UserStore interface {
    GetUser(id string) (User, error)
}

type StatsProvider interface {
    FetchStats(id string) (Stats, error)
}

type ReportService struct {
    users UserStore
    stats StatsProvider
}

func NewReportService(u UserStore, s StatsProvider) *ReportService {
    return &ReportService{users: u, stats: s}
}

func (svc *ReportService) Generate(userID string) (Report, error) {
    user, err := svc.users.GetUser(userID)
    if err != nil {
        return Report{}, fmt.Errorf("generate report: get user: %w", err)
    }
    stats, err := svc.stats.FetchStats(userID)
    if err != nil {
        return Report{}, fmt.Errorf("generate report: fetch stats: %w", err)
    }
    return buildReport(user, stats), nil
}

ตอน test แค่ส่ง mock เข้าไป ตอน production ส่ง implementation จริง — ไม่มี magic ไม่มี framework ไม่มีอะไรซ่อน ทุกอย่างอ่านได้จากโค้ด

กฎทอง: ถ้า function ของคุณเรียก dependency ตรง ๆ โดยไม่ผ่าน interface — คุณไม่ได้เขียน testable code คุณเขียน prayer-based testing (“ขอให้ API ไม่ล่มตอน test”)


12. Graceful Shutdown: จบให้สวยเหมือนเริ่ม

โปรแกรมที่ดีไม่ใช่แค่รันได้ แต่ต้อง หยุดได้อย่างสง่างาม — เหมือนออกจากงานเลี้ยง ไม่ใช่หนีออกจากหน้าต่าง

ปัญหาที่เจอบ่อย: deploy ใหม่แล้ว request ค้าง, connection ถูก drop กลางคัน, data เขียนไม่ครบ — ทั้งหมดเพราะ process ถูก kill โดยไม่ได้ cleanup

func main() {
    srv := &http.Server{Addr: ":8080", Handler: router}

    // Start server in goroutine
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()

    // รอ signal — SIGINT (Ctrl+C) หรือ SIGTERM (k8s/docker)
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("shutting down gracefully...")

    // ให้เวลา 30 วินาทีสำหรับ in-flight requests
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("forced shutdown: %v", err)
    }

    log.Println("server stopped cleanly")
}

Pattern นี้ทำให้:

  • Request ที่กำลังทำอยู่ได้ทำจนเสร็จ
  • Connection ใหม่ถูกปฏิเสธอย่างสุภาพ
  • Resources (DB connections, file handles) ถูก cleanup
  • Kubernetes/Docker ไม่ force kill เพราะ process ตอบสนอง SIGTERM ทัน

กฎทอง: ถ้าโปรแกรมของคุณไม่จัดการ SIGTERM — มันไม่ได้ production-ready มันแค่ demo-ready (ซึ่งก็ดีถ้าคุณทำ demo อยู่จริง ๆ)


สรุป

ทุกการตัดสินใจ — ตั้งชื่อ, wrap error, วาง interface, จัด package, inject dependency, shutdown gracefully — คือข้อความถึง คนที่จะมาอ่านต่อ (ซึ่งบ่อยครั้งคือตัวคุณเองใน 3 เดือน)

คุณภาพของ Go ที่คุณเขียนวัดจาก cognitive load ที่คุณทิ้งไว้ให้คนที่มาทีหลัง ยิ่งน้อยยิ่งดี — เหมือนเขียนจดหมายรัก ยิ่งสั้นยิ่งจับใจ… เอ๊ะ ไม่ใช่สิ เหมือนเขียนโค้ดที่ดี ยิ่งอ่านง่ายยิ่งรักกัน (ในทีม)

ลองนำ 12 ข้อนี้ไปใช้ แล้วคุณจะพบว่า: PR review เร็วขึ้น, bug น้อยลง, และที่สำคัญ — ไม่มีใครแอบด่าคุณตอนอ่านโค้ด (อย่างน้อยก็ไม่ใช่เรื่องโค้ด)