Big Ball of Mud
สถาปัตยกรรมที่ไร้ modular design จนเป็นกอง code ไร้โครงสร้าง
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”Big Ball of Mud (BBoM) เป็น antipattern เชิงสถาปัตยกรรมที่หมายถึงระบบซึ่ง ไร้ modular design จนกลายเป็นมวลของ code ไร้ระเบียบที่ไม่มีโครงสร้างที่แท้จริง คำนี้มาจากบทความชื่อดังของ Brian Foote และ Joseph Yoder ที่นำเสนอในงาน Pattern Languages of Programs (PLoP) ปี 1997 ซึ่งให้นิยามไว้อย่างเจ็บแสบและน่าจดจำว่าเป็นระบบที่ “haphazardly structured, sprawling, sloppy, duct-tape-and-baling-wire, spaghetti-code jungle” — โครงสร้างถูกกำหนดโดยความสะดวกฉับพลันมากกว่าการออกแบบ
ลักษณะเด่นที่สังเกตได้คือข้อมูลถูก share กันอย่าง “promiscuous” ระหว่างส่วนต่าง ๆ ของระบบที่ไม่ควรรู้จักกัน จนแทบทุกอย่างกลายเป็น global state หรือถูก duplicate ไปมา หากลองวาดไดอะแกรมสถาปัตยกรรมแล้วขีดเส้นแบ่งขอบเขตของ subsystem หรือ responsibility ไม่ได้เลย นั่นคือสัญญาณชัดเจนว่ากำลังเผชิญ BBoM
มักเป็นผลจากการพอก feature ใหม่ตามเวลาโดยไม่ลงแรงออกแบบว่า feature เหล่านั้นควรทำงานร่วมกันอย่าง modular อย่างไร เกี่ยวข้องใกล้ชิดกับ spaghetti code ในระดับ code-level และเป็นปลายทางตามธรรมชาติของ YOLO Architecture ในระดับ architecture-level
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”Foote และ Yoder ชี้ให้เห็นว่า BBoM ไม่ใช่แค่ความผิดพลาดของทีมที่ไร้ฝีมือ แต่เป็นผลลัพธ์ที่ “สมเหตุสมผล” ในระยะสั้นจากแรงกดดันหลายทาง และนั่นคือเหตุผลที่มันเป็นสถาปัตยกรรมที่พบบ่อยที่สุดในทางปฏิบัติ:
- Throwaway Code — code ต้นแบบที่ตั้งใจเขียนแบบขว้างทิ้ง (prototype) กลับรอดเข้าสู่ production เพราะ “มันก็ทำงานได้” ทั้งที่ไม่เคยมีการออกแบบโครงสร้างมาก่อน
- Piecemeal Growth — การเพิ่ม feature ทีละชิ้นตามความต้องการของธุรกิจ โดยแต่ละครั้งดูสมเหตุสมผลในตัวเอง แต่สะสมกันแล้วกัดกร่อนความสมบูรณ์ของสถาปัตยกรรมเดิม
- Keep It Working — เมื่อระบบกลายเป็น mission-critical แล้ว การรักษาให้ระบบทำงานต่อไปได้จะสำคัญกว่าการหยุดมา refactor เสมอ สถาปัตยกรรมที่มีปัญหาจึงถูก “แช่แข็ง” ไว้
- Sweeping It Under the Rug — แทนที่จะแก้ปัญหาโครงสร้างตรง ๆ ทีมมักกั้นส่วนที่ยุ่งเหยิงไว้หลัง facade แล้วเดินหน้าต่อ
- แรงกดดันทางธุรกิจ ทีมงานหมุนเวียนบ่อย — deadline ที่บีบรัดและ developer ที่เข้า-ออกทีมตลอดเวลา ทำให้ไม่มีใครถือ “ภาพรวม” ของสถาปัตยกรรมไว้ได้นานพอ
ผลคือ ในช่วงเริ่มต้นของ project ทีมมักรีบสร้าง feature ให้เร็วที่สุดโดยแทบไม่วางแผนล่วงหน้า เพราะ “ทำงานได้ก่อน” ดูสำคัญกว่า “ทำงานได้ดี” — และ Foote & Yoder เองก็ยอมรับว่านี่คือสถาปัตยกรรมที่พบได้บ่อยที่สุดในโลกจริง ไม่ใช่ข้อยกเว้น
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- แก้ไขจุดหนึ่งกระทบทั้งระบบ — เพราะ component ต่าง ๆ ผูกกันแน่นและพึ่งพา global state ร่วมกัน การเปลี่ยนแปลงเล็ก ๆ จุดหนึ่งอาจทำให้ระบบพังในจุดที่ไม่เกี่ยวข้องกันเลย
- ทดสอบยาก — ไม่มีขอบเขตที่ชัดเจนให้ mock หรือแยกทดสอบเป็นหน่วยย่อย ทำให้ต้องรัน integration test ทั้งระบบเพื่อยืนยันแม้การเปลี่ยนแปลงเล็กน้อย
- onboarding ช้า — สมาชิกใหม่ในทีมไม่มี mental model ของโครงสร้างให้ยึด เพราะ code ไม่ได้สะท้อนความตั้งใจการออกแบบใด ๆ
- technical debt สะสมแบบทบต้น — ยิ่งระบบโตขึ้น ยิ่งยากที่จะขีดเส้นแบ่ง subsystem ภายหลัง การชำระหนี้ทางเทคนิคจึงแพงขึ้นเรื่อย ๆ จนถึงจุดที่ทีมเลือก “เขียนใหม่ทั้งหมด” (Reconstruction) ซึ่งมีความเสี่ยงสูงและมักใช้เวลานานกว่าที่ประเมินไว้
- ความเร็วในการส่งมอบลดลงเรื่อย ๆ — ทุก feature ใหม่ต้องเจียดเวลาไปกับการทำความเข้าใจผลกระทบข้างเคียงที่คาดเดาไม่ได้ ทำให้ velocity ของทีมค่อย ๆ ลดต่ำลงตามอายุของระบบ
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”code ตัวอย่างด้านล่างจำลอง service ชั้นเดียวที่ผสมทุกอย่างเข้าด้วยกัน — validation, business rule, การเข้าถึงฐานข้อมูลตรง ๆ, การส่งอีเมล และ logging — ไม่มีขอบเขตให้แยก concern ใด ๆ เลย เป็นตัวอย่างคลาสสิกของการ “พอก” feature ทีละอย่างจนกลายเป็นก้อนโคลน
// ANTIPATTERN: OrderManager ทำหน้าที่ทุกอย่างในตัวเดียวpublic class OrderManager{ private SqlConnection _connection = new SqlConnection("...");
public void PlaceOrder(int customerId, List<int> productIds, string couponCode) { // validation ปนกับ business rule ปนกับ data access if (productIds.Count == 0) throw new Exception("no items");
var customerRow = _connection.Execute( "SELECT * FROM Customers WHERE Id = " + customerId); // SQL string ต่อกันตรง ๆ
decimal total = 0; foreach (var pid in productIds) { var priceRow = _connection.Execute("SELECT Price FROM Products WHERE Id = " + pid); total += (decimal)priceRow["Price"]; }
// ส่วนลดคูปองฝังตรรกะไว้ตรงนี้ เพราะ "แก้เร็วกว่า" if (couponCode == "SAVE10") total *= 0.9m; if (couponCode == "VIP") total *= 0.8m;
_connection.Execute("INSERT INTO Orders (CustomerId, Total) VALUES (" + customerId + "," + total + ")");
// ส่งอีเมลแทรกอยู่กลาง flow ของการสั่งซื้อ var smtp = new SmtpClient("smtp.example.com"); smtp.Send("orders@shop.com", (string)customerRow["Email"], "Order placed", "Thanks!");
// logging เขียน file ตรง ๆ ปนกับ logic ทางธุรกิจ File.AppendAllText("log.txt", $"Order placed for {customerId} total {total}\n"); }}ปัญหา: ทุก concern (validation, pricing rule, persistence, notification, logging) อยู่ใน method เดียว เชื่อมกันแน่นจนแก้กติกาคูปองก็เสี่ยงพังการส่งอีเมล จะเขียน unit test แยกส่วนไม่ได้เลยเพราะทุกอย่างพึ่ง SqlConnection และ SmtpClient จริงตรง ๆ
ทางแก้: แยก concern ออกเป็นชั้น ๆ
หัวข้อที่มีชื่อว่า “ทางแก้: แยก concern ออกเป็นชั้น ๆ”// REFACTORED: แยกความรับผิดชอบตาม concern ด้วย interface ที่ inject เข้ามาpublic interface IOrderRepository{ Customer GetCustomer(int customerId); IReadOnlyList<Product> GetProducts(IEnumerable<int> productIds); void SaveOrder(Order order);}
public interface IDiscountPolicy{ decimal Apply(decimal total, string couponCode);}
public interface INotificationService{ void NotifyOrderPlaced(Customer customer, Order order);}
public class OrderService{ private readonly IOrderRepository _repository; private readonly IDiscountPolicy _discountPolicy; private readonly INotificationService _notifications; private readonly ILogger<OrderService> _logger;
public OrderService( IOrderRepository repository, IDiscountPolicy discountPolicy, INotificationService notifications, ILogger<OrderService> logger) { _repository = repository; _discountPolicy = discountPolicy; _notifications = notifications; _logger = logger; }
public Order PlaceOrder(int customerId, IReadOnlyList<int> productIds, string couponCode) { if (productIds.Count == 0) throw new ArgumentException("ต้องมีอย่างน้อยหนึ่งสินค้า");
var customer = _repository.GetCustomer(customerId); var products = _repository.GetProducts(productIds); var subtotal = products.Sum(p => p.Price); var total = _discountPolicy.Apply(subtotal, couponCode);
var order = new Order(customerId, total); _repository.SaveOrder(order);
_notifications.NotifyOrderPlaced(customer, order); _logger.LogInformation("Order placed for {CustomerId} total {Total}", customerId, total);
return order; }}ตอนนี้แต่ละ concern แยก interface ของตัวเอง ทดสอบ OrderService ได้ด้วย mock ล้วน ๆ เปลี่ยนกติกาคูปองใน IDiscountPolicy โดยไม่กระทบการส่งอีเมลหรือ persistence เลย และเห็นขอบเขต subsystem ได้ชัดเจนจากชื่อ interface
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”การกู้คืนระบบที่เป็น BBoM ไปแล้วแทบไม่มีทางลัด แต่มีแนวทางที่ยืนยันได้ผลจริงในทางปฏิบัติ:
- Piecemeal Refactoring (refactor ทีละชิ้น) — อย่าพยายามแก้ทั้งระบบพร้อมกัน ให้หาจุดที่เจ็บปวดที่สุด (pain point) ก่อน แล้วค่อย ๆ ดึง concern ออกมาเป็น module ทีละส่วน พร้อม cover ด้วย test ก่อนแตะ code
- Strangler Fig Pattern — แนวคิดของ Martin Fowler ที่เปรียบกับต้นไทรที่ค่อย ๆ พันรัดต้นไม้เดิมจนแทนที่ได้ทั้งหมด: สร้าง code ใหม่คลุมทับของเก่าทีละส่วน ให้ของใหม่รับ traffic แทนไปเรื่อย ๆ จนของเก่าไม่ถูกใช้แล้วค่อยลบทิ้ง เป็นทางเลือกที่เสี่ยงน้อยกว่าการเขียนใหม่ทั้งระบบ (big-bang rewrite)
- ตั้ง Anti-Corruption Layer (ACL) — กัน code ใหม่ที่สะอาดไม่ให้ปนเปื้อนกับก้อนโคลนเดิม ด้วยชั้นแปลข้อมูล/สัญญาที่ชัดเจนระหว่างสองฝั่ง
- บังคับใช้ Separation of Concerns และ SOLID เป็นวินัยต่อเนื่อง ไม่ใช่ทำครั้งเดียวจบ — ทุก feature ใหม่ต้องผ่านการถามว่า “นี่ควรอยู่ layer ไหน”
- จัดช่วง “consolidation” ให้ทีมพักจากการไล่ feature เพื่อกลับมาจัดระเบียบสถาปัตยกรรมเป็นระยะ แทนที่จะปล่อยให้ debt สะสมจนต้อง Reconstruction (เขียนใหม่ทั้งหมด) ซึ่งมีต้นทุนและความเสี่ยงสูงกว่ามาก
- ป้องกันตั้งแต่ต้น ด้วยการวาง Architectural Agility และหลีกเลี่ยง YOLO Architecture ตั้งแต่วันแรก เพราะการป้องกันถูกกว่าการซ่อมเสมอ
flowchart LR
Legacy[ระบบเดิมแบบ Big Ball of Mud]
Facade[Facade ชั้นบาง]
NewModule[module ใหม่ที่ถูกออกแบบ]
Legacy --> Facade
Facade --> NewModule
Facade -.เรียกของเดิมเท่าที่ยังไม่ย้าย.-> Legacy
NewModule -.ค่อยรับ traffic แทนที่.-> Legacy