บทที่ 12 · Part 3 — Organizing the Codebase
Small Single-Domain Codebase
เริ่ม codebase เล็กด้วย package เท่าที่จำเป็น และเพิ่ม seam เมื่อมี growth signal ที่สังเกตได้
Small Single-Domain Codebase
service ใหม่ที่มี Customer use cases 3 รายการถูก scaffold ด้วย 12 packages ตั้งแต่วันแรก
engineer ต้องวิ่งผ่าน api/application/domain/ports/adapters/infrastructure เพื่อเปลี่ยน field
เดียว ทั้งที่ยังไม่มี dependency ตัวที่ 2 หรือ rule ซับซ้อน Architecture ที่ดีสำหรับ codebase
เล็กควรทำให้ flow อ่านง่ายและเหลือทางออกเมื่อแรงเปลี่ยน
จบบทนี้คุณจะ
ออกแบบโครงสร้างขั้นต่ำสำหรับ single-domain Go codebase ตัดสินว่าอะไรควรอยู่ใกล้กัน ระบุ seam ที่ยังไม่ earned และเขียน exit criteria ที่ชัดก่อนเพิ่ม package/layer
[!IMPORTANT] วิธีอ่านหลักฐานในบทนี้ คำที่เป็น Definition/Pattern มีแหล่งต้นทางอยู่ใกล้ claim ส่วน decision table, starting structure, checklist และ lab เป็น Heuristic/Trade-off หรือ Course Convention สำหรับฝึกตัดสินใจ ไม่ใช่มาตรฐานสากล เว้นแต่บทจะระบุแหล่งและขอบเขตไว้ชัดเจน
เริ่มด้วย Package เท่าที่จำเป็น
ตัวอย่างเริ่มต้น:
cmd/customer-api/main.go
internal/customer/
service.go
http.go
mysql.go
main.go เป็น composition root อ่าน configuration เปิด MySQL สร้าง dependencies และ
wire handler เท่านั้น Business code อยู่ internal/customer ซึ่ง Go ป้องกันการ import
จาก module ภายนอกตาม semantics ของ internal
3 ไฟล์อยู่ package เดียวได้:
service.goมี commands, outcomes และ use-case logichttp.goแปลง HTTP request/responsemysql.goมี focused queries และ error mapping
การอยู่ package เดียวไม่ได้ทำให้ concern ปนกันโดยอัตโนมัติ ไฟล์และ type visibility ช่วย แยก responsibility ขณะที่ flow ยัง navigate ง่าย
อย่าสร้าง Seam เร็วเกินไป
dependency สมควรมี port/interface เมื่อ:
- application ต้องปกป้องตัวเองจาก external contract/volatility
- มี use cases หลายตัว consume capability เดียวที่มีความหมาย
- test ต้องควบคุม nondeterminism หรือ failure ที่ของจริงสร้างยาก
- implementation มี lifecycle/ownership แยกจริง
อย่าสร้าง CustomerServiceInterface คู่กับ CustomerService เพื่อ mocking หรือ generic
repository ก่อนรู้ query ที่ต้องใช้ Go interface guidance
แนะนำให้ consumer ประกาศ interface เล็กเมื่อจำเป็น
Concrete *sql.DB ใน MySQL store ไม่รั่วหาก application เรียกผ่าน focused function/type
และ SQL ไม่แทรก business decisions การแยก package เพิ่มควรลดความรู้ที่ต้องถือ ไม่ใช่แค่
เพิ่ม directory
รักษาความถูกต้องในโครงสร้างเล็ก
เล็กไม่ได้แปลว่าละเว้น controls:
- request มี limit และ validation ที่ transport boundary
- principal/tenant ถูก establish ก่อน use case
- query ใส่ tenant scope และใช้ parameters
- unique/foreign/check constraints ป้องกัน invalid persistence state ที่ทำได้
- transaction ครอบ atomic local writes
- secrets/config ไม่อยู่ domain
- structured logs ไม่เก็บ credential/PII โดยไม่จำเป็น
type Service struct {
db *sql.DB
clock func() time.Time
}
func (s *Service) RenameCustomer(ctx context.Context, actor Principal, id CustomerID, name string) error {
if actor.TenantID == "" || !actor.Can("customer:write") {
return ErrForbidden
}
name = strings.TrimSpace(name)
if name == "" {
return ErrInvalidName
}
return updateCustomerName(ctx, s.db, actor.TenantID, id, name, s.clock())
}
Clock ถูก inject เพราะเวลาเป็น nondeterministic dependency ที่ tests ต้องควบคุม
แต่ไม่ต้องสร้าง ClockFactoryProvider หลายชั้น
สัญญาณว่าระบบกำลังโต
เพิ่ม structure เมื่อพบหลักฐาน:
| Signal | Possible move |
|---|---|
| business rule ถูกใช้หลาย use cases | แยก domain value/policy ใน package เดิมก่อน |
| HTTP กับ message consumer เรียก use case เดียว | สร้าง application entry ที่ transport-independent |
| MySQL mapping ใหญ่และมี integration tests เฉพาะ | แยก store/repository subpackage |
| external provider type รั่ว | เพิ่ม consumer-owned port + adapter |
| package มี concept หลายชุดและคนละ owner | แยก business modules |
| ต้อง scale/fail/release แยก | ประเมิน deployment extraction หลัง semantic boundary |
จำนวนบรรทัดเป็น warning ได้แต่ไม่ใช่เหตุผลเดี่ยว ไฟล์ 800 บรรทัดอาจต้องแตกไฟล์ ไม่จำเป็นต้องแตก context ส่วน package เล็ก 10 package ที่ import วนกันอาจซับซ้อนกว่า
Exit Criteria เป็นข้อตกลง
บันทึก decision:
Today: one internal/customer package, concrete MySQL store
Why: three simple use cases, one team, one transport
Guardrails: transport mapping in http.go; tenant-scoped SQL; transaction tests
Split when: second driving adapter appears, shared invariant emerges,
or MySQL mapping needs independent lifecycle/tests
Do not split for: file count alone or desire to match a template
ทุก review cycle ถามว่าสัญญาณเกิดหรือยัง การรักษา codebase เล็กต้องเป็น deliberate decision ไม่ใช่การละเลย architecture
แบบฝึกปฏิบัติ: ตั้งใจทำให้เล็ก
ออกแบบ Customer service ที่มี Create, Rename และ Disable use cases ระบุ tree, public types, transaction boundaries, test seams และ SQL constraints จากนั้นเพิ่ม requirement “import customer from message queue” แล้วตัดสินว่าจะ reuse use case อย่างไรโดยไม่ให้ message DTO เข้า service logic
คะแนน:
- ผ่าน เมื่อ flow อ่านได้ใน package เดียวและ controls ยังครบ
- ดี เมื่อมี exit criteria เชื่อมกับ observable change
- ควรทำใหม่ เมื่อมี layer/package ต่อ conceptual noun หรือ business logic อยู่ handler
รายการตรวจสอบ
-
cmdเป็น composition root ไม่ใช่ business layer - Package จำนวนต่ำแต่ responsibility ยังเห็นได้
- Interface มี consumer force ไม่ใช่ทำเพื่อ mock
- Tenant, transaction และ constraints ไม่ถูกลดเพราะ codebase เล็ก
- Growth signals เชื่อมกับ refactor ที่เล็กที่สุด
- Exit criteria ถูกบันทึกพร้อม owner/review date
สรุปบทนี้
Single-domain codebase ควรเริ่มจากโครงสร้างที่เล็กพอให้ flow อยู่ในหัวได้ ใช้ files แยก responsibility และเพิ่ม package/ports เมื่อ dependency หรือ domain earning seam ความเรียบง่ายที่มี guardrails และ exit criteria คือ architecture choice ที่แข็งแรง
อ่านเพิ่มเติม
- Go module layout —
cmd,internalและ server project guidance - Organizing a Go module — official examples โดยไม่บังคับ template เดียว
- Go Code Review Comments: Interfaces — interface ownership
- A Philosophy of Software Design — complexity และ deep modules