ออกแบบไม่ให้เป็น Ball of Mud (Deep Modules)
บทก่อนหน้า (MP-3) เราปล่อยให้ agent ลุยเขียนโค้ดตาม ticket ทีละใบด้วยความเร็วสูง — นั่นคือจุดแข็งของ AI แต่ก็เป็นจุดอันตรายเหมือนกัน เพราะ agent ที่เร็วมาก ๆ จะเขียนโค้ด "ที่ทำงานได้" แต่ไม่แคร์ว่าโครงสร้างจะยุ่งเหยิงแค่ไหน ถ้าไม่มีใครคอย "ลงทุนกับ design" ระหว่างทาง ภายในไม่กี่สัปดาห์ codebase จะกลายเป็น ball of mud — ทุกไฟล์ผูกกันมั่ว แก้ตรงนี้พังตรงโน้น และที่แย่กว่าเดิมคือ agent ตัวถัดไปที่มาอ่านโค้ดนี้ก็จะงงพอ ๆ กับคุณ
บทนี้เป็นแกนสำคัญของทั้งแทร็ก mattpocock/skills เพราะพูดถึง "วินัยการออกแบบ" ที่ agent (และคุณ)
ต้องทำทุกวัน ไม่ใช่แค่ตอนรีแฟกเตอร์ใหญ่ครั้งเดียว เราจะเรียนรู้ 4 skill ที่ทำงานร่วมกัน:
codebase-design (คำศัพท์ + หลักคิดเรื่อง deep module), domain-modeling
(รักษาคำศัพท์โดเมนให้คม), improve-codebase-architecture (สแกนหาจุดที่ควรปรับให้ลึกขึ้น)
และ code-review (ตรวจทั้งมาตรฐานโค้ดและตรงสเปกหรือไม่)
1. ทำไม agent ถึงเร่ง entropy ของโค้ดให้พุ่งเร็วกว่าที่คิด
ลองนึกภาพ dev คนหนึ่งเขียนโค้ดเอง วันละไม่กี่ร้อยบรรทัด — ถ้าเขียนมั่วก็ยังพอไล่ตามทัน แต่ agent เขียนโค้ดได้เป็นพัน ๆ บรรทัดต่อวัน ถ้าไม่มีกรอบ (guardrail) เรื่องการออกแบบ ความยุ่งเหยิงก็จะสะสมเร็วเป็นทวีคูณ ปัญหาคลาสสิกที่เจอบ่อยคือ
- Shallow module ทุกที่ — สร้าง class/service ใหม่ทุกครั้งที่มี logic นิดหน่อย สุดท้าย interface ใหญ่พอ ๆ กับ implementation ไม่มีอะไรถูกซ่อนไว้จริง ๆ
- Logic กระจัดกระจาย — แก้ feature เดียวต้องไล่แก้ 6-7 ไฟล์ (shotgun surgery) เพราะไม่มีจุดรวมความรู้ที่ชัดเจน
- คำศัพท์โดเมนเพี้ยน — agent เดาความหมายของคำว่า "order", "account" เอาเอง พอผสมกับที่ dev เข้าใจไปคนละแบบ โค้ดก็เริ่มพูดคนละภาษากับ business
- ไม่มีใครรีวิวสถาปัตยกรรม — โฟกัสแค่ "ผ่าน test ไหม" แต่ไม่มีใครถามว่า "โครงสร้างตรงนี้ยังดีอยู่ไหม" จนกว่าจะสายเกินแก้
ทางแก้ไม่ใช่ "หยุดใช้ agent" แต่คือฝัง design discipline เข้าไปในทุกขั้นของงาน — มีคำศัพท์กลางที่ทุกคน (คนและ agent) ใช้ตรงกัน มีจุดเช็กว่าโมดูลไหน "ตื้น" เกินไป และมีรอบรีวิวที่ตรวจทั้งมาตรฐานกับสเปกอย่างสม่ำเสมอ นั่นคือสิ่งที่ 4 skill ในบทนี้ทำ
ถ้าปล่อยให้ agent commit ติดกันหลายวันโดยไม่มีใครถามคำถามเรื่อง "โครงสร้างนี้ยังดีอยู่ไหม" entropy จะไม่ค่อย ๆ เพิ่ม แต่จะกระโดด เพราะ agent รุ่นถัดไปจะเลียนแบบแพทเทิร์นเดิม (ถูกหรือผิดก็ตาม) ที่เห็นในโค้ดที่มีอยู่ ยิ่งปล่อยนาน ยิ่งกู้คืนยาก
2. codebase-design — ภาษากลางสำหรับออกแบบ "Deep Module"
Skill นี้เป็นคำศัพท์ที่ต้องใช้ตรงกันเป๊ะ ไม่สลับไปมาระหว่าง "component", "service", "API", "boundary" — เพราะภาษาที่สม่ำเสมอคือหัวใจของการสื่อสารเรื่องออกแบบ มาดูศัพท์หลักกัน
| คำศัพท์ | ความหมาย | คำที่ห้ามใช้แทน |
|---|---|---|
| Module | อะไรก็ได้ที่มี interface + implementation — function, class, package, หรือแม้แต่ slice ที่ข้าม tier ก็นับ | unit, component, service |
| Interface | ไม่ใช่แค่ type signature แต่รวม invariant, error mode, ลำดับการเรียก, config ที่ต้องมี และ performance characteristic ด้วย | API, signature |
| Depth | leverage ที่ interface ให้ — behaviour เยอะแค่ไหนต่อความซับซ้อนของ interface 1 หน่วย | — |
| Seam | ตำแหน่งที่ interface ของ module อาศัยอยู่ — เป็นการตัดสินใจแยกจาก "ข้างในมีอะไร" | boundary |
| Adapter | ของจริงที่ทำตาม interface ที่ seam หนึ่ง ๆ (เช่น PostgresRepo, InMemoryFake) | — |
หัวใจของ skill นี้คือประโยคเดียว: ออกแบบ deep module — behaviour เยอะซ่อนอยู่หลัง interface เล็ก ๆ วางไว้ที่ seam ที่สะอาด และทดสอบผ่าน interface นั้นได้จริง
หลักคิดสำคัญ 4 ข้อจาก skill นี้ที่ต้องจำ:
- Depth เป็นคุณสมบัติของ interface ไม่ใช่ implementation — ข้างในจะสับเป็นชิ้นเล็ก ๆ มี mock ได้เท่าไหร่ก็ได้ ตราบใดที่ชิ้นเล็กพวกนั้นไม่ใช่ส่วนหนึ่งของ interface ที่ caller เห็น
- Deletion test — ลองจินตนาการลบ module นี้ทิ้ง ถ้าความซับซ้อนหายไปเลย แสดงว่ามันเป็นแค่ pass-through แต่ถ้าความซับซ้อนกลับไปโผล่ที่ caller หลายที่ แสดงว่ามันคุ้มค่าที่จะมีอยู่
- Interface คือพื้นผิวที่ทดสอบ — caller กับ test ควรข้าม seam เดียวกัน ถ้าอยาก test "ทะลุ" ผ่าน interface ไปได้ แปลว่า module มีรูปทรงผิดตั้งแต่แรก
- Adapter เดียว = seam สมมติ, สอง adapter = seam จริง — อย่าเพิ่ง extract interface ถ้ายังไม่มีอะไรที่ผันแปรจริงข้าม seam นั้น (YAGNI ในภาษา design)
ผูกกับงานจริง: NestJS service
ตัวอย่าง shallow service ที่พบบ่อยมาก — interface ยาวเท่า ๆ กับ implementation:
// ❌ Shallow: caller ต้องรู้ทุกสเต็ป เรียงลำดับเอง
class OrderService {
validateOrder(order) { /* ... */ }
calculatePrice(order) { /* ... */ }
checkInventory(items) { /* ... */ }
chargeCard(payment) { /* ... */ }
sendConfirmationEmail(order) { /* ... */ }
}
// controller ต้องเรียงเมธอดถูกลำดับเอง — logic รั่วออกมานอก module
async createOrder(dto) {
this.orderService.validateOrder(dto);
const price = this.orderService.calculatePrice(dto);
this.orderService.checkInventory(dto.items);
this.orderService.chargeCard({ ...dto, price });
this.orderService.sendConfirmationEmail(dto);
}
// ✅ Deep: interface เดียว, ลำดับ+ความซับซ้อนซ่อนไว้ข้างใน
class OrderService {
constructor(
private pricing: PricingModule,
private inventory: InventoryModule,
private payments: PaymentGateway,
private notifier: NotificationModule,
) {}
async placeOrder(dto: PlaceOrderInput): Promise<Order> {
// validate, price, check stock, charge, notify — ทั้งหมดอยู่ที่นี่
// caller รู้แค่: ส่ง dto เข้าไป ได้ Order กลับมา หรือ throw error
}
}
// controller เหลือแค่บรรทัดเดียว — ทดสอบผ่าน interface นี้พอ
async createOrder(dto) {
return this.orderService.placeOrder(dto);
}
สังเกตว่า placeOrder() ยังใช้ PricingModule, InventoryModule
ฯลฯ ข้างในได้ตามสบาย — นั่นคือ internal seam ที่ test ของ OrderService เองใช้
mock ได้ แต่จากภายนอก caller เห็นแค่ placeOrder(dto) เท่านั้น
ใน Go ก็หลักการเดียวกัน: package order ควร export แค่ func PlaceOrder(ctx, input) (Order, error)
ไม่ใช่ export ทุก helper ย่อยให้ caller ต้องเรียงเรียกเอง
วิธีเช็กง่าย ๆ ว่า module ที่คุณ (หรือ agent) เพิ่งสร้างเป็น deep หรือ shallow: เปิด controller/handler ที่เรียกใช้มัน — ถ้าต้องเรียก 4-5 เมธอดเรียงลำดับเอง แปลว่ายัง shallow อยู่ ให้รวบเป็น 1 entry point ที่ซ่อนลำดับไว้ข้างใน
3. domain-modeling — รักษาคำศัพท์โดเมนให้คมตลอดเวลา
Skill นี้ไม่ใช่แค่ "อ่าน CONTEXT.md" (นั่นเป็นแค่นิสัยพื้นฐานที่ skill ไหนก็ทำได้)
แต่คือการลงมือปรับปรุงโมเดลโดเมนระหว่างคุยงาน — ท้าทายคำศัพท์ที่กำกวม
คิดสถานการณ์ edge-case มาทดสอบความหมาย แล้วบันทึกลงทันทีที่ตกผลึก
โครงไฟล์ที่ skill นี้ดูแล (repo ส่วนใหญ่มีแค่ context เดียว):
/
├── CONTEXT.md
├── docs/
│ └── adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
ถ้ามี CONTEXT-MAP.md ที่ root แปลว่า repo มีหลาย context ย่อย
แต่ละ context จะมี CONTEXT.md และ docs/adr/ ของตัวเอง —
สร้างไฟล์แบบ lazy เท่านั้น คือสร้างตอนมีเทอมแรกที่ตกผลึกจริง ๆ ไม่ใช่สร้างว่าง ๆ ไว้ก่อน
สิ่งที่ agent ควรทำระหว่าง session (จาก skill โดยตรง):
- ท้าทายเมื่อขัดกับ glossary — "ใน
CONTEXT.mddefines 'cancellation' ว่า X แต่ที่คุณพูดดูเหมือนหมายถึง Y — อันไหนถูก?" - ลับคำที่กำกวม — "คุณพูดว่า 'account' หมายถึง Customer หรือ User? สองอย่างนี้ต่างกัน"
- คิด scenario จริงมาทดสอบ — เมื่อคุยเรื่องความสัมพันธ์ในโดเมน ให้คิด edge case มาบีบให้ผู้ใช้ต้องชัดเจนเรื่องขอบเขตของแนวคิด
- ตรวจไขว้กับโค้ดจริง — ถ้า user บอกว่า "ยกเลิกได้บางส่วน" แต่โค้ด cancel ทั้ง Order ยกทีเดียว ต้อง surface ความขัดแย้งนี้ทันที
- อัปเดต
CONTEXT.mdทันทีที่เทอมตกผลึก — ห้ามเก็บสะสมไว้ค่อยเขียนทีหลัง
ข้อสำคัญมาก: CONTEXT.md ต้องไม่มีรายละเอียด implementation เลย
มันคือ glossary ล้วน ๆ ไม่ใช่ spec ไม่ใช่ scratch pad ไม่ใช่ที่เก็บ decision เรื่องโค้ด
ถ้าอยากบันทึก decision (เช่น "ทำไมเลือก event sourcing") ให้ไปเขียน ADR แทน
เกี่ยวกับ ADR (Architecture Decision Record) — เสนอสร้างเมื่อครบ 3 เงื่อนไขเท่านั้น:
- ยากที่จะย้อนกลับ — ต้นทุนของการเปลี่ยนใจภายหลังมีนัยสำคัญ
- น่าแปลกใจถ้าไม่มีบริบท — คนอ่านในอนาคตจะสงสัยว่า "ทำไมเลือกแบบนี้"
- เป็นผลจาก trade-off จริง — มีทางเลือกจริง ๆ และเลือกอันหนึ่งด้วยเหตุผลเฉพาะ
ถ้าขาดข้อใดข้อหนึ่งใน 3 ข้อนี้ ให้ข้ามไป ไม่ต้องสร้าง ADR ทุกครั้งที่ตัดสินใจอะไรเล็ก ๆ
4. improve-codebase-architecture — สแกนหา "deepening opportunity"
Skill นี้คือ command ที่ต้องสั่งเอง (disable-model-invocation: true)
— agent จะไม่สุ่มรันเองระหว่างทำงานทั่วไป เหมาะเป็นกิจวัตร "สแกนสุขภาพสถาปัตยกรรม" ที่ทำเป็นรอบ ๆ
เช่น ทุก 2-3 วัน ระหว่างที่ agent ทยอย implement ticket ไปเรื่อย ๆ
กระบวนการมี 3 ขั้น:
ขั้น 1 — Explore
อ่าน CONTEXT.md และ ADR ในพื้นที่ที่จะแตะก่อน แล้วใช้ Explore sub-agent
เดินสำรวจ codebase แบบไม่ยึดสูตรตายตัว มองหาจุดที่ "รู้สึกฝืด" เช่น
- ต้องเด้งไปมาระหว่างโมดูลเล็ก ๆ หลายอันเพื่อเข้าใจแนวคิดเดียว
- โมดูลไหนที่ interface ซับซ้อนพอ ๆ กับ implementation (shallow)
- pure function ที่ถูก extract ไปเพื่อทดสอบง่าย แต่บั๊กจริงซ่อนอยู่ตรง "วิธีเรียกใช้" (ไม่มี locality)
- โมดูลที่คัปเปิลแน่นจนรั่วข้าม seam
- ส่วนไหนไม่มี test เลย หรือ test ยากผ่าน interface ปัจจุบัน
ใช้ deletion test กับทุกจุดที่สงสัยว่า shallow — ลบแล้วความซับซ้อนหายไปจริงไหม หรือย้ายไปกระจุกที่อื่น
ขั้น 2 — รายงานเป็น HTML report
เขียนไฟล์ HTML แบบ self-contained ไปไว้ที่ temp directory ของ OS (ไม่ปนกับ repo)
ใช้ Tailwind + Mermaid ผ่าน CDN แต่ละ candidate มีการ์ดที่บอก: ไฟล์ที่เกี่ยวข้อง, ปัญหา,
วิธีแก้แบบ plain English, ประโยชน์ (ในแง่ locality/leverage), diagram before/after,
และ badge ระดับความน่าลงมือ (Strong / Worth exploring / Speculative)
จบด้วยส่วน "Top recommendation" ว่าควรเริ่มจากอันไหนก่อน
ขั้น 3 — Grilling loop
พอผู้ใช้เลือก candidate ที่จะลงมือ agent จะรัน /grilling คุยลงรายละเอียด
(constraints, dependency, รูปทรงของ module ที่จะลึกขึ้น, tests ไหนยังอยู่รอด) —
ระหว่างนั้นถ้าตั้งชื่อ concept ใหม่ที่ยังไม่มีใน CONTEXT.md
ก็จะเรียก domain-modeling มาอัปเดตกลอสซารีทันที และถ้าผู้ใช้ปฏิเสธ candidate
ด้วยเหตุผลที่หนักแน่น ก็เสนอสร้าง ADR กันไม่ให้รอบสแกนถัดไปเสนอเรื่องเดิมซ้ำ
5. code-review — สองแกนที่ห้ามปนกัน: Standards กับ Spec
Skill สุดท้ายในบทนี้คือ code-review ที่รีวิว diff ระหว่าง HEAD กับจุดคงที่จุดหนึ่ง
(commit, branch, tag) ด้วยสองแกนที่แยกกันเด็ดขาด:
| แกน | ตรวจอะไร | ตัวอย่างผลลัพธ์ |
|---|---|---|
| Standards | โค้ดตรงกับมาตรฐานที่ repo เขียนไว้ไหม (เช่น CODING_STANDARDS.md)
บวกกับ baseline: Fowler code smells จาก Refactoring บทที่ 3 |
"ฟังก์ชันนี้ดูเหมือน Feature Envy — ควรย้ายไปอยู่กับ data ที่มันใช้" |
| Spec | โค้ดตรงกับสิ่งที่ issue/PRD ต้นทางขอไหม | "สเปกข้อ 3 ขอ validation เบอร์โทร แต่ diff นี้ยังไม่มี" |
ตัวอย่าง smell ใน baseline ที่ต้องรู้จัก (มีทั้งหมด 12 แบบ อ้างจาก Fowler):
- Mysterious Name — ชื่อไม่บอกว่าไว้ทำอะไร → เปลี่ยนชื่อ ถ้าตั้งชื่อดี ๆ ไม่ได้ แปลว่า design ยังไม่ชัด
- Duplicated Code — logic รูปแบบเดียวกันซ้ำหลายจุด → ดึงออกมาเป็นที่เดียว
- Feature Envy — เมธอดที่ล้วงข้อมูลของ object อื่นมากกว่าของตัวเอง → ย้ายไปอยู่กับข้อมูลนั้น
- Primitive Obsession — string/number แทนที่ concept โดเมนที่ควรมี type ของตัวเอง
- Shotgun Surgery — เปลี่ยนเรื่องเดียวแต่ต้องแก้กระจายหลายไฟล์ → รวมสิ่งที่เปลี่ยนด้วยกันไว้ที่เดียว (นี่คือสัญญาณของ shallow module ตรงกับ codebase-design เป๊ะ)
- Speculative Generality — เพิ่ม abstraction/parameter ที่สเปกไม่ได้ขอ → ลบทิ้ง
สองแกนนี้รันเป็น parallel sub-agent แยกกัน (ใช้ general-purpose subagent ทั้งคู่)
เพื่อไม่ให้ context ปนกัน แล้วค่อยเอาผลมาแสดงคู่กันภายใต้หัวข้อ ## Standards และ ## Spec
— ห้ามรวมหรือจัดอันดับข้ามแกน เพราะโค้ดอาจ "ผ่าน Standards แต่ fail Spec"
(ทำถูกมาตรฐานแต่ทำผิดโจทย์) หรือ "ผ่าน Spec แต่ fail Standards" (ทำตามโจทย์แต่โค้ดสกปรก)
— ถ้ารวมกันจะบดบังอีกแกนหนึ่งทันที
ก่อนรัน sub-agent สองตัว ต้องเช็กก่อนว่า fixed point (เช่น main) resolve ได้จริง
และ diff ไม่ว่าง — ถ้า ref ผิดหรือ diff ว่างเปล่า ต้อง fail ตรงนี้ ไม่ใช่ปล่อยให้ไปพังใน sub-agent
ที่รันขนานกันอยู่ (debug ยากกว่ามาก)
ผูกกับงานจริง: Go package
สมมติทีมมีมาตรฐานเขียนไว้ใน CONTRIBUTING.md ว่า "error ต้อง wrap ด้วย fmt.Errorf
พร้อม context เสมอ" — เวลารีวิว diff, Standards sub-agent จะเช็กว่าทุก return err
ที่เพิ่มมาทำตามนี้ไหม ส่วน Spec sub-agent จะเช็กแยกว่า diff นี้ทำตาม ticket
"รองรับ retry เมื่อ DB connection หลุด" ครบไหม — สองคำถามนี้ตอบแยกกันได้ ไม่ต้องรอกัน
สรุปภาพรวมทั้ง 4 skill
| Skill | ใช้เมื่อ | ผลลัพธ์ |
|---|---|---|
| codebase-design | ออกแบบ/รีวิว interface ของ module ใด ๆ | คำศัพท์ + หลักตัดสินใจ deep vs shallow |
| domain-modeling | คุยงานแล้วเจอคำกำกวม หรือมี decision สำคัญ | CONTEXT.md / ADR ที่อัปเดตสด |
| improve-codebase-architecture | สั่งเองเป็นรอบ ๆ (ทุก 2-3 วัน) | HTML report จุดที่ควร deepen |
| code-review | รีวิว diff ก่อน merge | รายงาน Standards + Spec แยกกัน |
-
เปิด repo ของคุณ (NestJS หรือ Go ก็ได้) หา service/package หนึ่งจุดที่ controller/handler
ต้องเรียกหลายเมธอดเรียงลำดับเอง — ลองเขียนใหม่ให้เหลือ entry point เดียว (เช่น
placeOrder()) แล้วลองทำ deletion test: ถ้าลบ module นี้ทิ้ง ความซับซ้อนไปกองที่ไหน? -
เปิดไฟล์
CONTEXT.mdของโปรเจกต์ (หรือสร้างใหม่ถ้ายังไม่มี) เขียนนิยามคำศัพท์โดเมน 1 คำที่คุณเคยใช้แบบกำกวมมาก่อน (เช่น "user" vs "account") ให้ชัดเจนแบบที่ agent ท้าทายได้ -
ลองพิมพ์กับ agent ว่า
"รีวิว diff ตั้งแต่ main จนถึง HEAD ตามสอง axis คือ Standards กับ Spec"แล้วดูว่ารายงานที่ได้แยกสองแกนจริงไหม หรือมันดันเผลอรวมข้อสรุปเป็นอันเดียว