NJVERSE // BOOT
>NJVERSE OS v3.14 — BOOT SEQUENCE INITIATED
>loading kernel modules...
>mounting /sys/identity... OK
>applying user preferences...
>spawning interface threads...
>connecting BKK :: 13.7563°N, 100.5018°E
>SYSTEM READY
~/ / posts / 0x08
POST 0x08//go2026.05.26 // 7 min read

เขียน Go ยังไงไม่ให้เพื่อนร่วมทีมเกลียด

ประสบการณ์จริงจากการเขียน Go ในทีม backend ที่ Bangkok — error handling ที่ไม่ทำให้คนอ่านอยากลาออก, struct patterns ที่ยังอ่านออกหลัง 6 เดือน, และทำไม context ถึงไม่ใช่ของเล่น.

NJ
Nattapong Jaisabai
Software Engineer · published 2026.05.26

Go เป็นภาษาที่เรียนรู้ง่าย แต่เขียนให้ดีนั้นยากกว่าที่คิด ผมทำงานกับ Go มาหลายปีในทีม backend และสิ่งที่เจอบ่อยที่สุดไม่ใช่ bug — มันคือ code ที่อ่านยาก, error ที่ไม่มีความหมาย, และ goroutine ที่ไม่มีใครรู้ว่ามันทำอะไรอยู่

นี่คือสิ่งที่ผมอยากบอกตัวเองตอนเริ่มต้น

error handling ที่มีความหมาย

สิ่งแรกที่คนมักทำผิดคือ wrap error แบบไม่มีบริบท

// แย่ — ไม่รู้เกิดอะไรขึ้นที่ไหน
if err != nil {
    return err
}

// ดีกว่า — มีบริบทให้ trace ได้
if err != nil {
    return fmt.Errorf("fetchUser %s: %w", userID, err)
}

ใช้ %w เสมอถ้าคุณต้องการให้ caller ใช้ errors.Is() หรือ errors.As() ได้ ถ้าใช้ %v แล้ว error chain จะขาด

struct patterns ที่อ่านออกหลัง 6 เดือน

อย่าใส่ทุกอย่างลง struct เดียวแล้วหวังว่าจะจำได้

// แย่ — สารพัด field ไม่รู้ว่าใครใช้อะไร
type Service struct {
    db      *sql.DB
    cache   *redis.Client
    cfg     Config
    logger  *zap.Logger
    timeout time.Duration
    retries int
    secret  string
}

// ดีกว่า — แยก concerns ออกจากกัน
type Service struct {
    db     *sql.DB
    cache  *redis.Client
    logger *zap.Logger
    cfg    ServiceConfig
}

type ServiceConfig struct {
    Timeout time.Duration
    Retries int
    Secret  string
}

struct ที่ดีคือ struct ที่คุณอ่านแล้วรู้ทันทีว่า dependency มาจากไหน และ config คืออะไร

context ไม่ใช่ของเล่น

หลายคนส่ง context.Background() ทุกที่เพราะมันง่าย แต่นั่นหมายความว่า request จะไม่มีวัน timeout เว้นแต่ server จะตาย

// อย่าทำแบบนี้ใน production handler
func (s *Service) GetUser(id string) (*User, error) {
    return s.db.QueryRow(context.Background(), ...) // ☠️
}

// ทำแบบนี้แทน — รับ context มาจาก caller เสมอ
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
    return s.db.QueryRow(ctx, ...)
}

rule ง่ายๆ: ถ้า function ทำ I/O ต้องรับ ctx context.Context เป็น argument แรกเสมอ ไม่มีข้อยกเว้น

goroutine leak ที่เจอบ่อย

// leak — channel ไม่มีใครอ่าน goroutine จะ block ตลอดไป
func process(items []string) {
    ch := make(chan result)
    for _, item := range items {
        go func(i string) {
            ch <- doWork(i) // block ตลอดถ้าไม่มีคนอ่าน
        }(item)
    }
    // ลืม drain channel
}

// ดีกว่า — ใช้ errgroup หรือ WaitGroup
func process(ctx context.Context, items []string) error {
    g, ctx := errgroup.WithContext(ctx)
    for _, item := range items {
        item := item
        g.Go(func() error {
            return doWork(ctx, item)
        })
    }
    return g.Wait()
}

สรุป

Go ไม่ใช่ภาษาที่ซับซ้อน แต่มันให้อิสระมากพอที่จะเขียน code ห่วยได้ง่ายมาก สิ่งที่ทำให้ code ดีคือ discipline ไม่ใช่ feature ของภาษา

  • wrap error ทุกครั้งด้วย %w และบริบทที่มีความหมาย
  • แยก config ออกจาก dependency ใน struct
  • ส่ง context ทุกที่ที่มี I/O
  • ใช้ errgroup แทนการจัดการ goroutine เอง

เขียนให้คนอ่านได้ก่อน แล้วค่อย optimize ทีหลัง

EOF · 0x08 · last edit 2026.05.26// thanks for reading.
← PREVIOUS
rewriting my dotfiles. for the 4th time. cope.
2026.05.14 · //meta · 6 min
← back to all posts