บทที่ 10 · Part 4 — APIs and Boundaries

Packages as APIs

ออกแบบ package จาก client code, รักษา dependency direction และสร้าง interface ฝั่ง consumer

เมื่อ service โตขึ้น ทีมมักแก้ความรกด้วยการเพิ่มโฟลเดอร์ models, services, repositories, utils และ interfaces ผลคือการแก้ feature หนึ่งต้องเดินข้ามทุกโฟลเดอร์ และ import cycle เริ่ม บอกว่า ownership ไม่ชัด Package ที่ดีไม่ใช่กล่องเก็บชนิดเดียวกัน แต่เป็นหน่วยของ capability และ API

จบบทนี้คุณจะ

  • ออกแบบ package จาก client code และสิ่งที่มันรับผิดชอบ
  • ใช้ internal และ dependency direction เพื่อป้องกัน coupling
  • สร้าง interface ฝั่ง consumer เมื่อมี use case จริง

เริ่มจาก Client ที่อยากเห็น

ก่อนสร้าง package ให้เขียน call site ของ use case หลัก:

store := sqlite.New(db)
service := todo.NewService(store, clock)

task, err := service.Add(ctx, "ซื้อของเข้าบ้าน")
if err != nil {
    return err
}
fmt.Println(task.Title)

ตัวอย่างนี้เป็น application จัดการรายการสิ่งที่ต้องทำ ผู้ใช้เพิ่ม task แล้ว service บันทึกลง SQLite จาก call site สั้น ๆ เราเห็นว่า todo เป็นเจ้าของ use case และคำว่า Task ส่วน sqlite เป็น adapter ที่ประกอบจาก connection ซึ่ง application root เปิดไว้แล้ว ไม่ควรมี common.NewService() หรือให้ business package เปิดไฟล์ database เอง เพราะ dependency และ failure timing จะถูกซ่อน

ลูกศรจริงควรชี้จาก adapter เข้าหา contract ของ application ไม่ให้ core use case รู้ว่า request เข้ามาทาง HTTP หรือเก็บด้วย SQLite การใช้ internal/ บังคับจาก compiler ว่า package ภายนอก parent tree import ไม่ได้ เหมาะกับ implementation ที่ยังไม่ต้องเป็น public commitment

Package ต้อง Cohesive ก่อนจะเล็ก

ไม่ต้องมี package ต่อ 1 struct และไม่ต้องมี pkg/ เป็นพิธีกรรม เริ่มจากโครงเรียบง่ายแล้วแตก เมื่อ code มีเหตุผลเปลี่ยนแปลงต่างกัน เช่น HTTP transport กับกฎของ task ไม่ควรอยู่ไฟล์เดียวกัน เพราะ test และ dependency ต่างกัน แต่ Task, Priority และกฎการปิดงานที่เปลี่ยนพร้อมกันอาจอยู่ package เดียวได้แม้มีหลายไฟล์

สัญญาณว่า package กว้างเกินไปคือชื่ออธิบายไม่ได้ในประโยคเดียว, import จำนวนมากจากหลาย domain, exported surface โตโดยไม่มี client ชัด หรือทุก feature ต้องแก้ package นั้น ส่วน package ที่เล็กเกินไป จะมี type forwarding, constructor ผ่านหลายชั้น และ import เยอะกว่าพฤติกรรมจริง

Import cycle ไม่ใช่ศัตรูที่ต้องแก้ด้วย package interfaces กลางทันที มันเป็น feedback ว่า ownership หรือ dependency direction ผิด ให้ถามว่า behavior ใดเป็นของ consumer, data type ใดควรย้าย และ orchestration ควรอยู่ชั้นไหนก่อนสร้าง abstraction ใหม่

Interfaces Belong to Consumers

ให้ producer ส่ง concrete type และ consumer ประกาศเฉพาะ behavior ที่ต้องใช้:

// package todo
type TaskStore interface {
    Create(ctx context.Context, task Task) error
    ByID(ctx context.Context, id string) (Task, error)
}

type Service struct {
    store TaskStore
}

package sqlite ไม่ต้องประกาศ Repository ขนาด 10 method เผื่อ consumer ในอนาคต มันเพียง export concrete type *Store ที่บังเอิญ satisfy interface ของ todo วิธีนี้ทำให้ interface เป็นเอกสารของ use case, mock เล็ก และเปลี่ยน implementation โดยไม่ให้ adapter เป็นผู้กำหนดความต้องการของ core

คำว่า “accept interfaces, return structs” เป็น default ไม่ใช่กฎห้าม return interface ทั้งหมด standard library บาง package ตั้งใจซ่อน family ของ implementation หลัง abstraction ที่ stable จริง แต่ constructor ทั่วไปควร return concrete type เพื่อให้ caller เห็น capability ครบและไม่ต้องสร้าง interface ฝั่ง producerก่อนมีเหตุผล

Public Surface คือ Compatibility Budget

ทุก exported name, method, error และ behavior เป็นสิ่งที่ consumer อาจพึ่งพา การ export เผื่อไว้ แก้กลับยากกว่าการเปิดภายหลัง ให้ unexport อย่างตั้งใจ, ใช้ internal, เขียน doc comment ที่บอก concurrency/ownership และมี example test สำหรับ API สำคัญ

Production Toolbox

Default คือ standard Go module layout, capability-oriented packages และ manual dependency direction ใช้ go list -deps, go mod graph และ architecture tests เมื่อ codebase โต อย่าเพิ่ม framework เพื่อแก้ import cycle ก่อนเข้าใจ ownership; หาก domain architecture ซับซ้อนต่อ ให้เรียน DDD & Code Architecture

Checklist ของ Package Boundary

  • อธิบาย responsibility ของ package ได้ใน 1 ประโยค
  • call site อ่านง่ายและไม่มีชื่อซ้ำกับ package
  • core ไม่ import transport, database driver หรือ framework
  • interface อยู่กับ consumer และมีเฉพาะ method ที่ใช้
  • constructor return concrete type เว้นแต่ตั้งใจซ่อน implementation family
  • exported surface มี owner, documentation และ compatibility reason
  • import cycle ถูกแก้ที่ ownership ไม่ใช่ย้ายทุก interface ไป package กลาง

อ่านเพิ่ม: Organizing a Go module, Developing modules และ Go Code Review Comments — interfaces