ข้าม​ไป​ยัง​เนื้อหา

Oddball Solution

ปัญหา​ที่​มี​ทางออก​ดี​ทาง​เดียว กลับ​ถูก​แก้​หลาย​วิธี​ปน​กัน

Oddball Solution เกิด​เมื่อ​ปัญหา​เดียวกัน​ถูก​แก้​ด้วยหลาย​วิธี​ที่​ต่าง​กันใน​จุด​ต่าง ๆ ของ codebase เดียวกัน — บ้าง​ใช้ helper method บ้าง inline logic เอง บ้าง​เรียก library คนละ​ตัว บ้าง copy-paste แล้ว​ปรับ​นิดหน่อย แต่ละ​จุด​อาจ​ทำงาน​ถูกต้อง​เมื่อ​ดู​แยก ๆ แต่​เมื่อ​มอง​รวม​แล้ว​คือ​ความ​ไม่​สอดคล้อง (inconsistency) ที่​ทำให้​ใคร​ก็ตาม​ที่​เข้า​มา​แก้ code ต้อง​เดา​ว่า “ครั้ง​นี้​ทีม​ใช้​วิธี​ไหน”

คำ​นี้​มา​จาก​หนังสือ Refactoring to Patterns ของ Joshua Kerievsky (2004) ซึ่ง​นิยาม​ไว้​ตรง ๆ ว่า: เมื่อ​ปัญหา​หนึ่ง​ถูก​แก้​ด้วย​วิธี​หนึ่ง​ทั่ว​ทั้ง​ระบบ แล้ว​ปัญหา​เดียวกัน​นั้น​ถูก​แก้​ด้วย​อีก​วิธี​หนึ่ง​ใน​บาง​จุด​ของ​ระบบ​เดียวกัน วิธี​ที่​แปลก​แยก​ออก​ไป​นั้น​คือ oddball หรือ “solution ที่​ไม่​สอดคล้อง”

จุด​ที่​ทำให้ Oddball Solution ต่าง​จาก Duplicate Code ตรง ๆ คือ มัน​ไม่ใช่​กา​รก็อป​วาง​บรรทัด​ต่อ​บรรทัด แต่​เป็นการแก้​ปัญหา​เชิง​ตรรกะ​เดียวกัน​ด้วย​แนวทาง​คนละ​แบบ — ซ่อน​ตัว​แนบเนียน​กว่า duplicate code ทั่วไป จึง​ตรวจ​จับ​ยาก​กว่า

ใน​หนังสือ​เล่ม​เดียวกัน Kerievsky ยัง​ตั้ง​ชื่อ​กลิ่น​พี่น้อง​อีก​ตัว​ว่า Solution Sprawl ซึ่ง​เกิด​เมื่อ​การ​แก้​ปัญหา​หนึ่ง ๆ ถูก​กระจาย​ไป​หลาย class หลาย module จน​ต้อง​กระโดด​ไป​มา​หลาย​ที่​กว่า​จะ​เข้าใจ​ภาพ​รวม — Oddball Solution มัก​เป็น​จุด​เริ่มต้น​ที่​นำ​ไป​สู่ Solution Sprawl เพราะ​เมื่อ​ไม่มี​จุดศูนย์กลาง​เดียว แต่ละ​ทีม​ก็​แตก​ตัว​ออก​ไป​แก้​ปัญหา​ซ้ำ​ใน​ที่​ของ​ตัวเอง

flowchart LR
    Problem[ปัญหาเดียวกัน การตรวจอีเมล] --> PathA[RegistrationService ทำแบบหนึ่ง]
    Problem --> PathB[NewsletterSubscriber ทำอีกแบบ]
    Problem --> PathC[SupportTicketForm ทำอีกแบบ]
    PathA -.-> Unify[รวมเป็น EmailValidator ตัวเดียว]
    PathB -.-> Unify
    PathC -.-> Unify
  • Method หรือ function ที่​ทำ​หน้าที่​เดียวกัน​แต่​ตั้ง​ชื่อ​ไม่​เหมือน​กัน เช่น class หนึ่ง​มี Ask() อีก class มี Read() ทั้ง​ที่​ทำงาน​แบบ​เดียวกัน
  • class ที่​ควร​มี constructor หรือ interface คล้าย​กัน (เพราะ​ทำงาน​ประเภท​เดียวกัน) แต่ signature ต่าง​กัน​โดย​ไม่มี​เหตุผล​ด้าน domain
  • algorithm เดียวกัน (เช่น การ parse วัน​ที่, การ​คำนวณ​ราคา​ส่วนลด, การ validate อีเมล) ถูก​เขียน​ซ้ำ​คนละ​แบบ​ใน​หลาย file
  • เวลา​จะ​เรียก​ใช้​ชุด class ที่​ทำงาน​คล้าย​กัน (เช่น repository หลาย​ตัว, validator หลาย​ตัว) กลับ​ต้อง​เรียก​ด้วย​รูปแบบ​ไม่​เหมือน​กัน​ทุก​ตัว
  • Code review เจอ​คอมเมนต์​ซ้ำ ๆ ว่า “ทำไม​ตรง​นี้​ไม่​ใช้ function ที่​มี​อยู่​แล้ว” หรือ “อ้าว ที่​จริง​มี helper ตัว​นี้​อยู่​แล้ว​เหรอ”
  • เกิด​บ่อย​เมื่อ​ทีม​โต​ขึ้น​เร็ว​โดย​ไม่มี​มาตรฐาน​ร่วม, developer หลาย​คน​แก้​ปัญหา​คล้าย​กัน​แบบ​แยก​กัน​โดย​ไม่รู้​ว่า​มี​คน​แก้​ไป​แล้ว, หรือ code ค่อย ๆ วิวัฒนาการ​มา​นาน​โดย​ไม่​เคย​ถูก refactor ให้​เป็น​หนึ่ง​เดียว

ภาระ​ทาง​ปัญญา​เพิ่ม​ขึ้น — หลักการ​พื้นฐาน​คือ​ควร​มี “ทาง​เดียว” ใน​การ​จัดการ​ปัญหา​หนึ่ง​ตลอด​ทั้ง project เมื่อ​มี​หลาย​ทาง นัก​พัฒนา​ต้อง​จำ​ได้​ว่า​แต่ละ​จุด​ใช้​วิธี​ไหน และ​เดา​ไม่​ได้​ว่า code ใหม่​ที่​ตัวเอง​เขียน​ควร​เลียน​แบบ​จุด​ไหน

Duplication ที่​มอง​ไม่​ออก​ง่าย ๆ — แม้​ไม่ใช่​กา​รก็อปบรรทัด​ต่อ​บรรทัด แต่​ตรรกะ​เดียวกัน​ถูก​ดูแล​อยู่​หลาย​ที่ เท่ากับ​ต้นทุน​การ​ดูแล​รักษา​ซ้ำซ้อน​โดย​ไม่​จำเป็น

ความ​เสี่ยง​ตอน​แก้ไข — เมื่อ​กติกา​ของ​ปัญหา​นั้น​เปลี่ยน (เช่น สูตร​คำนวณ​ภาษี​เปลี่ยน) ต้อง​ไล่​แก้​ทุก​จุด​ที่​มี “วิธี​ของ​ตัวเอง” แยก​กัน มี​โอกาส​สูง​ที่​จะ​แก้​ไม่​ครบ เกิด bug ที่​จุด​หนึ่ง​แต่​จุด​อื่น​ถูกต้อง — อาการ​นี้​มัก​ลาก​ไป​สู่ Shotgun Surgery ที่​การ​เปลี่ยนแปลง​เล็ก ๆ กระจาย​แรง​กระเพื่อม​ไป​หลาย file

เมล็ด​พันธุ์​ของ abstraction ที่​น่า​สงสัย — เมื่อ​พยายาม​รวม​วิธี​ที่​ต่าง​กัน​เข้า​ด้วย​กัน​แบบ​เร่งรีบ อาจ​ได้ abstraction ที่​บิดเบี้ยว​เพื่อ “ครอบ” ทุก​วิธี​เข้า​ด้วย​กัน​แทนที่​จะ​เลือก​วิธี​ที่​ดี​ที่สุด​จริง ๆ

ทดสอบ​ยาก​ขึ้น​เป็น​ทวีคูณ — ถ้า​ปัญหา​หนึ่ง​มี​สาม​วิธี​แก้ ทีม​ต้อง​เขียน​และ​ดูแล test suite แยก​กัน​สาม​ชุด​สำหรับ​กรณี​ขอบเขต (edge case) เดียวกัน แทนที่​จะ​เขียน​ทดสอบ​เข้มข้น​แค่​ที่​จุด​เดียว​แล้ว​มั่นใจ​ได้​ทั้ง​ระบบ

Oddball Solution จึง​เป็น​ญาติ​ใกล้​ชิด​กับ Inconsistency — Inconsistency มอง​ภาพ​กว้าง​กว่า (สไตล์, การ​ตั้ง​ชื่อ, โครงสร้าง) ส่วน Oddball Solution เจาะจง​ไป​ที่ “วิธี​แก้​ปัญหา​เชิง​ตรรกะ​เดียวกัน”

สมมติ​ระบบ​มี​การ validate อีเมล​อยู่​สาม​จุด แต่ละ​จุด​เขียน​คนละ​แบบ:

// จุดที่ 1 — ใน RegistrationService
public bool IsValidEmail(string email)
{
return email.Contains("@") && email.Contains(".");
}
// จุดที่ 2 — ใน NewsletterSubscriber
public bool CheckEmail(string address)
{
var pattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$";
return Regex.IsMatch(address, pattern);
}
// จุดที่ 3 — ใน SupportTicketForm (inline ตรง ๆ ไม่มีแม้แต่ method)
if (!ticket.ContactEmail.Contains("@"))
{
throw new ValidationException("อีเมลไม่ถูกต้อง");
}

ทั้ง​สาม​จุด​แก้​ปัญหา​เดียวกัน (“อีเมล​นี้​ถูก​รูปแบบ​ไหม”) ด้วย​ความ​เข้มงวด​ต่าง​กัน ชื่อ method ต่าง​กัน และ​บาง​จุด​ไม่มี method เลย​ด้วย​ซ้ำ — ถ้า​วัน​หนึ่ง​กฎ​การ validate ต้อง​เปลี่ยน (เช่น ต้อง​รองรับ email ที่​มี + หรือ domain ภาษา​ไทย) ทีม​มี​โอกาส​สูง​ที่​จะ​แก้​ไม่​ครบ​ทั้ง​สาม​จุด

ขั้นตอน​แก้​ตาม​แนวทาง​ของ Kerievsky คือ (1) เลือก​วิธี​ที่​ดี​ที่สุด​หนึ่ง​เดียว — ไม่​จำเป็น​ต้อง​เป็น​วิธี​ที่​ถูก​ใช้​บ่อย​ที่สุด บาง​ครั้ง​วิธี​ที่​ใช้​น้อย​ที่สุด​กลับ​เป็น​วิธี​ที่​คุณภาพ​ดี​กว่า (2) ใช้ Substitute Algorithm แทนที่​ทุก​จุด​ด้วย​วิธี​เดียวกัน และ (3) ย้าย​ตรรกะ​ไป​รวม​ไว้​ที่​เดียว (Extract Method/Class) เพื่อ​ไม่​ให้​เกิด​การ​ดูแล​ซ้ำซ้อน​อีก:

// รวมเป็นจุดเดียว — ใช้ EmailValidator ตัวเดียวทั้งระบบ
public static class EmailValidator
{
private static readonly Regex Pattern =
new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled);
public static bool IsValid(string email) => Pattern.IsMatch(email);
}
// ทุกจุดเรียกใช้แบบเดียวกัน
public bool IsValidEmail(string email) => EmailValidator.IsValid(email);
public bool CheckEmail(string address) => EmailValidator.IsValid(address);
if (!EmailValidator.IsValid(ticket.ContactEmail))
{
throw new ValidationException("อีเมลไม่ถูกต้อง");
}

กรณี​ที่​ซับซ้อน​กว่า​นั้น​คือ​เมื่อ​วิธี​ที่​แตก​ต่าง​กัน​มา​จากclass ที่​มี interface ไม่​ตรง​กัน เช่น repository สาม​ตัว​ที่​ทำงาน​คล้าย​กัน​แต่​มีชื่อ method คนละ​ชุด (Fetch(), Load(), GetById()) — กรณี​นี้ Kerievsky แนะนำ​ให้ Unify Interfaces with Adapter คือ​สร้าง interface ร่วม​แล้ว​ห่อ​แต่ละ class ด้วย Adapter เพื่อ​ให้ code ฝั่ง​ที่​เรียก​ใช้​เห็น​เป็น​หน้าตา​เดียวกัน จาก​นั้น​ค่อย​มอง​หา​โอกาส​รวม​ตรรกะ​ที่​ซ้ำซ้อน​กัน​จริง ๆ ต่อ​ไป สถานการณ์​แบบ​นี้​มัก​ถูก​เรียก​ว่า Alternative Class with Different Interfaces ซึ่ง​เป็นกลิ่น​พี่น้อง​กัน​โดยตรง

ข้อ​ควร​ระวัง​ตอน refactor คือ ก่อน​ลบ​วิธี​เก่า​ออก ควร​เขียน test ครอบ​วิธี​เก่า​ทุก​จุด​ก่อน (characterization test) เพื่อ​ยืนยัน​ว่า​พฤติกรรม​เดิม​ของ​แต่ละ​จุด​คือ​อะไร แล้ว​ค่อย​แทนที่​ที​ละ​จุด​พร้อม​รัน test ยืนยัน​ทุก​ครั้ง — การ​รวม​วิธี​แก้​เข้า​ด้วย​กัน​แบบ​รวด​เดียว​ทั้ง​ระบบ​มี​ความ​เสี่ยง​สูง เพราะ​บาง​จุด​อาจ​แอบ​พึ่งพา edge case ของ​วิธี​เดิม​อยู่​โดย​ไม่มี​ใคร​รู้ตัว