teach-me-ai / sources / MP-4
⟵ กลับหน้าแทร็กเสริม
MP-4 · mattpocock/skills

ออกแบบไม่ให้เป็น Ball of Mud (Deep Modules)

⏱️ ~24 นาที 🎯 เข้าใจ deep modules, domain model และการรีวิว/กู้สถาปัตยกรรม 📦 ต้นทาง: skills/codebase-design · domain-modeling · improve-codebase-architecture · code-review

บทก่อนหน้า (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 นั้นได้จริง

Deep Module (ดี) interface เล็ก: processOrder() validate • pricing inventory • payment tax • notification (ซ่อนไว้ทั้งหมด) implementation ลึก → leverage สูง, ทดสอบง่าย Shallow Module (เลี่ยง) validateOrder() calcPrice() checkStock() chargeCard() sendEmail() applyTax() ... แค่ pass-through บาง ๆ → caller ต้องรู้ทุกเมธอด, ทดสอบยาก
Deep module: interface สั้น แต่มี behaviour ซ่อนอยู่เยอะ / Shallow module: 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.md defines '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 เงื่อนไขเท่านั้น:

  1. ยากที่จะย้อนกลับ — ต้นทุนของการเปลี่ยนใจภายหลังมีนัยสำคัญ
  2. น่าแปลกใจถ้าไม่มีบริบท — คนอ่านในอนาคตจะสงสัยว่า "ทำไมเลือกแบบนี้"
  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 กันไม่ให้รอบสแกนถัดไปเสนอเรื่องเดิมซ้ำ

1. Explore สแกนหา friction 2. HTML Report candidates + badge 3. Grilling เลือก + ลงรายละเอียด CONTEXT.md / ADR อัปเดต วนซ้ำทุก 2-3 วัน
วงจรสุขภาพสถาปัตยกรรม: สแกน → รายงาน → เลือกลุย → ปรับ CONTEXT.md/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 แยกกัน
🎯 แบบฝึกหัด
  1. เปิด repo ของคุณ (NestJS หรือ Go ก็ได้) หา service/package หนึ่งจุดที่ controller/handler ต้องเรียกหลายเมธอดเรียงลำดับเอง — ลองเขียนใหม่ให้เหลือ entry point เดียว (เช่น placeOrder()) แล้วลองทำ deletion test: ถ้าลบ module นี้ทิ้ง ความซับซ้อนไปกองที่ไหน?
  2. เปิดไฟล์ CONTEXT.md ของโปรเจกต์ (หรือสร้างใหม่ถ้ายังไม่มี) เขียนนิยามคำศัพท์โดเมน 1 คำที่คุณเคยใช้แบบกำกวมมาก่อน (เช่น "user" vs "account") ให้ชัดเจนแบบที่ agent ท้าทายได้
  3. ลองพิมพ์กับ agent ว่า "รีวิว diff ตั้งแต่ main จนถึง HEAD ตามสอง axis คือ Standards กับ Spec" แล้วดูว่ารายงานที่ได้แยกสองแกนจริงไหม หรือมันดันเผลอรวมข้อสรุปเป็นอันเดียว