บทที่ 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 logic
  • http.go แปลง HTTP request/response
  • mysql.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 เมื่อพบหลักฐาน:

SignalPossible 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 ที่แข็งแรง

อ่านเพิ่มเติม