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

Witches’ Brew Architecture

codebase ที่​ผสม​เทคโนโลยี​และสไตล์มั่วซั่วอย่าง​ไร้​ความ​สอดคล้อง

Witches’ Brew Architecture คือ antipattern ที่ codebase หนึ่ง​ชุด ผสม​เทคโนโลยี design pattern และ​สไตล์​การ​เขียน​หลาย​แบบ​เข้า​ด้วย​กัน​อย่าง​สะเปะสะปะ​และ​ไม่​สอดคล้อง​กัน — เหมือน​แม่มด​โยน​วัตถุดิบ​ทุก​อย่าง​ที่​มี​ลง​หม้อ​ต้ม​โดย​ไม่​สนใจ​ว่า​มัน​จะ​เข้า​กัน​ไหม

ชื่อ​นี้​บอก​อาการ​ได้​ตรง​ตัว: ใน​ระบบ​เดียวกัน​อาจ​มี module หนึ่ง​เขียน​ด้วย raw ADO.NET อีก module ใช้ Entity Framework module ถัด​ไป​ใช้ Dapper; controller หนึ่ง​ชุด​ต่อ dependency ผ่าน container อีก​ชุด new ขึ้น​มาตรงๆ แบบ static; การ​จัดการ error บาง​ที่ throw exception บาง​ที่​คืน​ค่า null เงียบๆ บาง​ที่​คืน error code — ไม่มี “วิธี​มาตรฐาน” ของ​ทีม​เลย​สัก​อย่าง

ข้อ​ควร​แยก​ให้​ชัด​คือ ความ​หลากหลาย​ทาง​เทคโนโลยี​ไม่ใช่​ปัญหา​ใน​ตัว​มัน​เอง Martin Fowler อธิบาย​แนวคิด polyglot persistence ว่าการ​เลือก​ใช้ storage ต่าง​ชนิด​กัน​ใน​ระบบ​เดียว​เป็น​เรื่อง​ดี​ได้ ถ้า​เป็นการ​เลือก​อย่าง​มี​เจตนา โดย​พิจารณา​จาก​ลักษณะ​ข้อมูล​และ​วิธี​เข้าถึง​ข้อมูลนั้นๆ ก่อน​แล้ว​ค่อย​เลือก​เทคโนโลยี​ที่​เหมาะสม ไม่ใช่​เลือก​ตาม​อำเภอ​ใจ​ของ​แต่ละ​คน — Witches’ Brew คือ version “ไม่มี​เจตนา” ของ​ความ​หลากหลาย​นั้น: ไม่มี​ใคร​ตัดสิน​ใจ ไม่มี​ใคร​บันทึก​เหตุผล มัน​แค่ “เกิด​ขึ้น” ที​ละ​นิด​จาก​การ​ตัดสิน​ใจ​เฉพาะ​หน้า​ของ​แต่ละ​คน​แต่ละ​ยุค

Witches’ Brew มัก​ไม่​ได้​เกิด​จาก​ความ​ประมาท​เดียว แต่​เกิด​จาก​การ​ตัดสิน​ใจ​ที่ “ดู​สม​เหตุ​สม​ผล” ที​ละ​ครั้ง​สะสม​กัน:

  • อิสระ​ของ​ทีม/นัก​พัฒนา​แต่ละ​คน — ปล่อย​ให้​แต่ละ​คน​เลือก​เครื่องมือ​ที่​ตัวเอง​ถนัด​หรือ​อยาก​ลอง ฟัง​ดูเหมือน​ให้ autonomy และ​เพิ่ม motivation ใน​ระยะ​สั้น
  • แรง​กดดัน​เรื่อง deadline — เมื่อ​ต้อง ship feature ด่วน การ​หยิบ library หรือ pattern ที่​ตัวเอง​คุ้น​มือ​ที่สุด​มา​แก้​ปัญหา​เฉพาะ​หน้า​ดู​เร็ว​กว่า​การ​เรียนรู้​มาตรฐาน​เดิม​ของ​ทีม
  • การ​ควบ​รวม​ทีม/บริษัท หรือ​ซื้อ code จาก​ภายนอก — เมื่อ M&A เกิด​ขึ้น การ​เอา code เดิม​ของ​แต่ละ​ฝั่ง​มา​เสียบ​เข้า​ด้วย​กันตรงๆ ดูเหมือน​ประหยัด​เวลา​กว่า​การ rewrite ทั้งหมด​ให้​เข้า​กัน
  • “Resume-Driven Development” — เทคโนโลยี​ใหม่​แต่ละ​ตัว​เวลา​ถูก​หยิบ​มา​ใช้​ครั้ง​แรก​มัก​มี​เหตุผล​ทาง​เทคนิค​ที่​ฟัง​ขึ้น​อยู่​เสมอ (เร็ว​กว่า อ่าน​ง่าย​กว่า ทันสมัย​กว่า) แต่​ไม่มี​ใคร​ถาม​ว่า “แล้ว​ของ​เดิม​ที่​มี​อยู่​แล้ว​ล่ะ จะ​ทิ้ง​ไหม หรือ​จะ​อยู่​คู่​กัน​ตลอด​ไป”
  • ทีม​เปลี่ยน​คน​บ่อย — วิศวกร​ที่มา​ใหม่​แต่ละ​รุ่น​นำ​สไตล์​และ​เครื่องมือ​ที่​ตัวเอง​คุ้น​เคย​ติดตัว​มา​ด้วย โดย​ไม่มี​ใคร​คอย onboard ให้​ยึด​มาตรฐาน​เดิม

ปัญหา​คือ​การ​ตัดสิน​ใจ​แต่ละ​ครั้ง​อาจ “ดู​สม​เหตุ​สม​ผล​ใน​บริบท​แคบๆ” แต่​ไม่มี​ใคร​มอง​ภาพ​รวม​ทั้ง​ระบบ​เลย​สัก​ครั้ง

flowchart TB
    App[Application] --> ModuleA[Module A ADO NET and Singleton]
    App --> ModuleB[Module B EF Core and DI Container]
    App --> ModuleC[Module C Dapper and Static Helpers]
    App --> ModuleD[Module D MVC and Inline SQL]
    ModuleA --> DB[Shared Database]
    ModuleB --> DB
    ModuleC --> DB
    ModuleD --> DB

ผลกระทบ​สะสม​ของ​ความ​ไม่​เป็น​เอกภาพ​นี้​ร้ายแรง​กว่า​ที่​คิด:

  • ต้นทุน​การ​สลับ​บริบท (cognitive load) — วิศวกร​ที่​ต้อง​แตะ​หลาย module ใน feature เดียว​ต้อง​สลับ “โหมด​คิด” ไป​มาระหว่าง mental model ของ​แต่ละ​เทคโนโลยี ทั้ง​ที่​งาน​คือ​ปัญหา​ทาง business เดียวกัน
  • Onboarding ช้า​ลง​มหาศาล — สมาชิก​ใหม่​ต้อง​เรียนรู้​ไม่ใช่​แค่ domain แต่​ต้อง​เรียนรู้ “สำเนียง” หลาย​แบบ​ของ codebase เดียวกัน
  • บังคับ​ใช้ cross-cutting concern ไม่​ได้ — logging, authentication, error handling, caching ที่​ควร​ทำงาน​เหมือน​กัน​ทั้ง​ระบบ กลับ​ต้อง​ทำซ้ำ​หลาย​แบบ เพราะ​แต่ละ​มุม​ของ​ระบบ “พูด​ภาษา​ไม่​เดียวกัน” จุด​อ่อน​ด้าน​ความ​ปลอดภัย​จึง​หลุด​รอด​ง่าย​ขึ้น เพราะ patch หนึ่ง​จุด​ไม่​ครอบคลุม​อีก​จุด​ที่​ทำงาน​คนละ​แบบ
  • ย้าย​คน​ข้าม​ทีม​ไม่​ได้​จริง — แม้​จะ​เป็น​ระบบ​เดียวกัน แต่​ทักษะ​ที่​ใช้​ใน​แต่ละ​มุม​ต่าง​กัน​จน​วิศวกร​กลาย​เป็น​ผู้เชี่ยวชาญ​เฉพาะ module (bus factor สูง)
  • มาตรฐาน​คุณภาพ (CI/CD, linting, testing) กระจัดกระจาย — แต่ละ stack ต้องการ pipeline, dependency management และ​เครื่องมือ​ทดสอบ​ของ​ตัวเอง ทำให้​ต้นทุน​การ​ดูแล​โครงสร้าง​พื้นฐาน​คูณ​ขึ้น​ตาม​จำนวน​เทคโนโลยี

งาน​วิจัย “Microservices Anti-Patterns: A Taxonomy” ของ Taibi, Lenarduzzi และ Pahl ก็​จัด​กลุ่ม “การ​ใช้​เทคโนโลยี​หลากหลาย​เกิน​จำเป็น​โดย​ไม่มี​นโยบาย​ร่วม” ไว้​เป็น​หนึ่ง​ใน antipattern ระดับ​องค์กร​ที่​พบ​บ่อย​ที่สุด​เมื่อ​ทีม​ย้าย​ไป microservices โดย​ไม่มี​วินัย​ด้าน​สถาปัตยกรรม — ยืนยัน​ว่า​นี่​ไม่ใช่​ปัญหา​เฉพาะ​โม​โน​ลิธ​เก่าๆ เท่านั้น

ควร​แยก Witches’ Brew ออก​จาก Big Ball of Mud ให้​ชัด: Big Ball of Mud (Foote & Yoder) พูด​ถึง​การ​ที่ โครงสร้าง​และ​ขอบเขต​ของ​ระบบ สึก​กร่อน​จน​ไม่​เหลือ module แยก​ส่วน​ที่​ชัดเจน ส่วน Witches’ Brew พูด​ถึง​การ​ที่ เทคโนโลยี​และ​สไตล์​การ​เขียน ไม่​สอดคล้อง​กัน — สอง​อย่าง​นี้​มัก​เกิด​ร่วม​กัน​และ​เสริม​แรง​กัน​จน​ระบบ​ยาก​จะ​กอบ​กู้

code ต่อ​ไป​นี้​จำลอง​โซลูชัน​เดียว​ที่​มี3 module เข้าถึง​ข้อมูล​ด้วย​สาม​วิธี​คนละ​แบบ พร้อม​สไตล์ error handling ที่​ไม่​สอดคล้อง​กัน — เป็น​อาการ​คลาสสิก​ของ Witches’ Brew:

// Module A: ใช้ raw ADO.NET และ static Singleton ตรงๆ
public static class CustomerDataAccess
{
public static DataTable GetCustomers()
{
var table = new DataTable();
using var conn = new SqlConnection(ConfigurationManager.ConnectionStrings["Db"].ConnectionString);
using var cmd = new SqlCommand("SELECT * FROM Customers", conn);
conn.Open();
table.Load(cmd.ExecuteReader());
return table;
}
}
// Module B: ใช้ Entity Framework ผ่าน DI container
public class OrderService
{
private readonly AppDbContext _db;
public OrderService(AppDbContext db) => _db = db;
public async Task<List<Order>> GetOrdersAsync(int customerId)
=> await _db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
}
// Module C: ใช้ Dapper และกลืน exception ทิ้งเงียบๆ
public class InvoiceRepository
{
public Invoice GetInvoice(int id)
{
try
{
using var conn = new SqlConnection(Config.ConnString);
return conn.QuerySingleOrDefault<Invoice>(
"SELECT * FROM Invoices WHERE Id = @id", new { id });
}
catch
{
return null; // กลืน exception ทิ้งไปเงียบๆ ไม่ log ไม่ throw ต่อ
}
}
}

3 module นี้​แก้​ปัญหา​ชนิด​เดียวกัน (ดึง​ข้อมูล​จาก​ฐาน​ข้อมูล) ด้วย​สาม​เทคโนโลยี สาม​รูปแบบ​การ​ฉีด dependency และ​สาม​นโยบาย error handling คนละ​แบบ — วิศวกร​ที่​ต้อง​ไล่ debug feature เดียว​ข้าม3 module นี้​ต้อง​ถือ mental model สาม​ชุด​พร้อม​กัน

ทาง​แก้: กำหนด​มาตรฐาน​เดียว​และ​ยึดถือ​ร่วม​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “ทาง​แก้: กำหนด​มาตรฐาน​เดียว​และ​ยึดถือ​ร่วม​กัน”
// เลือก EF Core และ Repository pattern เป็นมาตรฐานเดียวของทั้งโซลูชัน
public interface ICustomerRepository
{
Task<IReadOnlyList<Customer>> GetAllAsync();
}
public class CustomerRepository : ICustomerRepository
{
private readonly AppDbContext _db;
public CustomerRepository(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<Customer>> GetAllAsync()
=> await _db.Customers.AsNoTracking().ToListAsync();
}
public interface IOrderRepository
{
Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId);
}
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId)
=> await _db.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
}
public interface IInvoiceRepository
{
Task<Invoice?> GetAsync(int id);
}
public class InvoiceRepository : IInvoiceRepository
{
private readonly AppDbContext _db;
private readonly ILogger<InvoiceRepository> _logger;
public InvoiceRepository(AppDbContext db, ILogger<InvoiceRepository> logger)
{
_db = db;
_logger = logger;
}
public async Task<Invoice?> GetAsync(int id)
{
// คืน null ให้ผู้เรียกตัดสินใจต่อ แต่ต้อง log เสมอ ห้ามกลืน exception เงียบๆ
var invoice = await _db.Invoices.FindAsync(id);
if (invoice is null)
_logger.LogWarning("ไม่พบ Invoice {InvoiceId}", id);
return invoice;
}
}

ทั้ง3 repository ตอน​นี้​ใช้ ORM เดียวกัน (EF Core) รูปแบบ DI เดียวกัน (constructor injection ผ่าน interface) นโยบาย async เดียวกัน และ​นโยบาย error handling เดียวกัน (คืน​ค่า nullable พร้อม log เสมอ) — มาตรฐาน​นี้​ควร​ถูก​บันทึก​ไว้​เป็น​เอกสาร​หรือ Architecture Decision Record เพื่อ​ให้ module ใหม่​ใน​อนาคต​เดิน​ตาม​รอย​เดียวกัน ไม่ใช่​แค่​ใช้​ถูก​ครั้ง​นี้​แล้ว​ลืม

  1. สถาปนาวิสัยทัศน์​สถาปัตยกรรม​ร่วม — ทีม​ทั้งหมด​ต้อง​มี​ชุด​หลักการ​และ​เทคโนโลยี​ที่​ตกลง​ร่วม​กัน ไม่ใช่​ให้​แต่ละ​คน​เลือก​เอง
  2. เขียน coding standard และ​บังคับ​ด้วย linter/analyzer อัตโนมัติ — อย่า​พึ่ง​ความ​จำ​หรือ​มารยาท​ของ​แต่ละ​คน ให้ CI ปฏิเสธ code ที่​ไม่​ตรง​มาตรฐาน​ตั้งแต่ pull request
  3. จำกัด​ความ​หลากหลาย​ของ​เทคโนโลยี​อย่าง​มี​สติ — ถ้า​จะ​เพิ่ม​เครื่อง​มือใหม่ ต้อง​มี​เหตุผล​ชัดเจน​ว่า​ทำไม​ของ​เดิม​ไม่​พอ และ​ต้อง​มี​แผน​ว่า​จะ deprecate ของ​เก่า​อย่างไร ไม่ใช่​ปล่อย​ให้​อยู่​คู่​กัน​ตลอด​ไป​แบบ​ไม่มี​ที่​สิ้นสุด
  4. refactor แบบ​ค่อย​เป็น​ค่อย​ไป​ด้วย pattern Strangler Fig — แทนที่ module เก่า​ที​ละ​ส่วน​ด้วย​มาตรฐาน​ใหม่ แทนที่​จะ​พยายาม rewrite ทั้ง​ระบบ​พร้อม​กัน​ซึ่ง​เสี่ยง​สูง​และ​มัก​ไม่​จบ
  5. ยึดกฎ​ลูกเสือ — ทุก​ครั้ง​ที่​แตะ code เก่า​ที่​ไม่​ตรง​มาตรฐาน ให้​ปรับ​ให้​เข้า​กับ​มาตรฐาน​ปัจจุบัน​ไป​ใน​ตัว แม้​จะ​เล็กน้อย​ก็ตาม
  6. ลงทุน​กับ​การ​ถ่ายทอด​ความ​รู้ — code review ข้าม​ทีม, pairing, และ​เอกสาร​มาตรฐาน​ที่​เข้าถึง​ง่าย ช่วย​ลด​ผลกระทบ​เมื่อ​คน​ย้าย​ทีม​หรือ​ลาออก และ​ป้องกัน​ไม่​ให้​สมาชิก​ใหม่​นำ​สไตล์​เดิม​ของ​ตัวเอง​มา​ปะปน​เพิ่ม