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

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 จริง​ตรง ๆ

// 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

การ​กู้​คืน​ระบบ​ที่​เป็น 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