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 containerpublic 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 ใหม่ในอนาคตเดินตามรอยเดียวกัน ไม่ใช่แค่ใช้ถูกครั้งนี้แล้วลืม
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”- สถาปนาวิสัยทัศน์สถาปัตยกรรมร่วม — ทีมทั้งหมดต้องมีชุดหลักการและเทคโนโลยีที่ตกลงร่วมกัน ไม่ใช่ให้แต่ละคนเลือกเอง
- เขียน coding standard และบังคับด้วย linter/analyzer อัตโนมัติ — อย่าพึ่งความจำหรือมารยาทของแต่ละคน ให้ CI ปฏิเสธ code ที่ไม่ตรงมาตรฐานตั้งแต่ pull request
- จำกัดความหลากหลายของเทคโนโลยีอย่างมีสติ — ถ้าจะเพิ่มเครื่องมือใหม่ ต้องมีเหตุผลชัดเจนว่าทำไมของเดิมไม่พอ และต้องมีแผนว่าจะ deprecate ของเก่าอย่างไร ไม่ใช่ปล่อยให้อยู่คู่กันตลอดไปแบบไม่มีที่สิ้นสุด
- refactor แบบค่อยเป็นค่อยไปด้วย pattern Strangler Fig — แทนที่ module เก่าทีละส่วนด้วยมาตรฐานใหม่ แทนที่จะพยายาม rewrite ทั้งระบบพร้อมกันซึ่งเสี่ยงสูงและมักไม่จบ
- ยึดกฎลูกเสือ — ทุกครั้งที่แตะ code เก่าที่ไม่ตรงมาตรฐาน ให้ปรับให้เข้ากับมาตรฐานปัจจุบันไปในตัว แม้จะเล็กน้อยก็ตาม
- ลงทุนกับการถ่ายทอดความรู้ — code review ข้ามทีม, pairing, และเอกสารมาตรฐานที่เข้าถึงง่าย ช่วยลดผลกระทบเมื่อคนย้ายทีมหรือลาออก และป้องกันไม่ให้สมาชิกใหม่นำสไตล์เดิมของตัวเองมาปะปนเพิ่ม