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

Frankencode

code ที่​ไม่​เคย​ถูก​ออกแบบ​ให้​ทำงาน​ร่วม​กัน ถูก​ยัด​รวม​ด้วย​เทป​กาว

Frankencode หมาย​ถึง code ที่ ไม่​เคย​ถูก​ออกแบบ​ให้​ทำงาน​ร่วม​กัน แต่​ถูก​ดึง​มา​รวม​ใน application เดียว​และ​ยึด​ไว้​ด้วย​เทป​กาว ลวด และ Adapter pattern เล็กน้อย​ที่​ปะติดปะต่อ​อย่าง​เร่งรีบ ชื่อ​นี้​ยืม​มา​จาก​นิยาย Frankenstein ของ Mary Shelley — ร่าง​ที่​ประกอบ​ขึ้น​จาก​ชิ้น​ส่วน​ของ​ศพ​หลาย​ศพ แต่ละ​ชิ้น​แข็งแรง​ใน​ตัว​ของ​มัน​เอง ทว่า​เมื่อ​เย็บ​ติด​กัน​ด้วย​ความ​รีบเร่ง​กลับ​กลาย​เป็น​สัตว์​ประหลาด​ที่​ควบคุม​ไม่​ได้

ที่มา​ของ Frankencode มี​สอง​ทาง ทาง​แรก​คือ​การ​รวม library หรือ​ระบบ​ภายนอก​ที่​พัฒนา​แยก​กัน​โดย​สิ้นเชิง แต่ละ​ฝั่ง​ตั้ง​อยู่​บน​สมมติฐาน รูปแบบ​ข้อมูล และ​แบบแผนการ​เรียก​ใช้​ที่​ไม่​ตรง​กัน ทาง​ที่​สอง​ซึ่ง​พบ​บ่อย​ไม่​แพ้​กัน​คือ Frankencode ที่​เกิด​ขึ้น ภายใน project เดียวกันเอง เมื่อ​ทีม​ขาด​วิสัยทัศน์​เชิง​สถาปัตยกรรม ขาด​การ​รีวิว design และ​เพิ่ม feature ที​ละ​ชิ้น​แบบ​ต่าง​คน​ต่าง​ทำ จน​แต่ละ​ส่วน​ของ​ระบบ​ราวกับ​ถูก​เขียน​โดย​คนละ​บริษัท​ที่​ไม่​เคย​คุย​กัน

  • เร็ว​กว่า​ใน​ระยะ​สั้น — การ​ดึง library สำเร็จรูป​หรือ code ที่​มี​อยู่​แล้ว​มา​แปะ ดูเหมือน​ประหยัด​เวลา​กว่า​การ​ออกแบบ interface กลาง​ให้​เรียบร้อย​ตั้งแต่​ต้น โดย​เฉพาะ​ภาย​ใต้ deadline แบบ fixed-bid ที่​ต้อง​ส่ง​มอบ feature เป็น​รอบ ๆ
  • เลี่ยง​การ “reinvent the wheel” — ทีม​มัก​ถูก​สอน​ให้​ไม่​เขียน​สิ่ง​ที่​มี​อยู่​แล้ว​ซ้ำ (ดู Reinventing the Wheel) จึง​หยิบ​ของ​สำเร็จรูป​มา​ต่อ​โดย​ไม่ทัน​คิด​เรื่อง​ความ​เข้า​กัน​ได้
  • ไม่มี​ใคร​รับผิดชอบ​ภาพ​รวม — เมื่อ​แต่ละ feature ถูก​มอบหมาย​ให้​คนละ​คน​โดย​ไม่มี​การ​รีวิว​สถาปัตยกรรม​ร่วม​กัน ทุก​คน​ต่าง​คิด​ว่า code ของ​ตัวเอง “ทำงาน​ได้” — และ​มัน​ก็​ทำงาน​ได้​จริง เพียง​แค่​ทำงาน​ร่วม​กับ​ส่วน​อื่น​ไม่​ได้​อย่าง​สวยงาม
  • การ reuse ฟัง​ดู​เป็น​คุณธรรม​เสมอ — แม้ reuse และ composability จะ​เป็น​เป้าหมาย​ที่​ดี แต่​คำ​ว่า “reuse ได้” กับ “reuse ได้​อย่าง​เหมาะสม” ไม่ใช่​เรื่อง​เดียวกัน

ซอฟต์แวร์​ที่​พัฒนา​แยก​กัน​มัก​ตั้ง​อยู่​บน​สมมติฐาน​และ​การ​ตัดสิน​ใจ design ที่​เข้า​กัน​ไม่​ได้ — รูปแบบ error handling ไม่​ตรง​กัน (exception กับ error code), รูปแบบ​ข้อมูล​ไม่​ตรง​กัน (XML กับ JSON), model concurrency ไม่​ตรง​กัน (synchronous กับ asynchronous) เมื่อ​รวม​เข้า​ด้วย​กัน​โดย​ไม่มี​ชั้น​แปลง​กลาง ผลลัพธ์​คือ​ระบบ​ที่​งุ่มง่าม​และ​ออกแบบ​ไม่​ดี​เมื่อ​เทียบ​กับ​ซอฟต์แวร์​ที่​ถูก​ออกแบบ​มา​ให้​ทำงาน​ร่วม​กัน​ใน domain เดียวกัน​ตั้งแต่​ต้น

อาการ​ที่​พบ​บ่อย​ได้แก่

  • Cyclomatic complexity สูง​ผิด​ปกติ — เต็ม​ไป​ด้วย​เงื่อนไข​พิเศษ​เพื่อ​รองรับ​ความ​ไม่​ตรง​กัน​ระหว่าง​ส่วน​ต่าง ๆ
  • ประสิทธิภาพ​ต่ำ — ข้อมูล​ถูก​แปลง​ไป​มา​หลาย​รอบ​ระหว่าง​ชั้น​กาว (glue layer) ที่​ไม่​จำเป็น
  • บำรุง​รักษา​ยาก — โครงสร้าง​ไม่​เป็น​ระเบียบ แต่ละ feature ดู​โดดเดี่ยว​แทนที่​จะ​เป็น​เนื้อ​เดียวกัน (cohesive) กับ​ส่วน​อื่น
  • การ​เปลี่ยนแปลง​หนึ่ง​จุด​กระทบ​ทั้ง​ระบบ — เพราะ​ไม่มี​ขอบเขต​ชัดเจน​ว่า​ใคร​พึ่งพา​ใคร (คล้าย​กับ Big Ball of Mud ใน​ระดับ​สถาปัตยกรรม)
  • test ยาก — เพราะ dependency ของ​แต่ละ​ชิ้น​ส่วน​ถูก​ฝัง​ลึก​และ​ปน​กัน​จน​แยก unit ไม่​ออก

Foote และ Yoder ผู้​บัญญัติ​ศัพท์ Big Ball of Mud อธิบาย​ระบบ​ลักษณะ​นี้​ไว้​ตรง​กัน​ว่า​เป็น “haphazardly structured, sprawling, sloppy, duct-tape-and-baling-wire, spaghetti-code jungle” — คำ​อธิบาย​เดียว​กับ​ที่​ใช้​กับ Frankencode ทุก​ประการ เพียง​แต่ Frankencode เน้น​ที่​จุด​กำเนิด​จาก​การ​รวม​ชิ้น​ส่วน​ที่​ไม่​ได้​ออกแบบ​มา​ด้วย​กัน ใน​ขณะ​ที่ Big Ball of Mud อธิบาย​ผลลัพธ์​ปลายทาง​ของ​ระบบ​ทั้ง​ก้อน

สมมติ​ทีม​ต้อง​เชื่อม​ระบบ​คำนวณ​ราคา​ของ​บุคคล​ที่​สาม (คืน​ค่า​เป็น int error code แบบ​เก่า) เข้า​กับ​ระบบ​สต็อก​สินค้า​ภายใน​ที่​เพิ่ง​เขียน​ใหม่ (คืน​ค่า​เป็น Task แบบ async/await) โดย​ไม่มี​การ​ออกแบบ interface กลาง — เพียง​แค่​เขียน code เรียก​ใช้​ตรง ๆ แล้ว​ยัด​ทุก​อย่าง​ลง​ใน class เดียว

// Antipattern: Frankencode — เอา library สองระบบที่ไม่เคยออกแบบมาด้วยกันมาต่อกันตรง ๆ
public class CheckoutService
{
private readonly LegacyPricingEngine _legacyPricing; // คืนค่า int error code, synchronous
private readonly InventoryClientV2 _inventoryClient; // คืนค่า Task, throw exception
public CheckoutService()
{
// สร้าง instance ตรงในคอนสตรักเตอร์ — ผูกติดกับ implementation ทั้งสองระบบ
_legacyPricing = new LegacyPricingEngine();
_inventoryClient = new InventoryClientV2();
}
public async Task<decimal> CalculateTotalAsync(string sku, int quantity)
{
// ระบบเก่าใช้ error code แทน exception ต้องเช็คแบบ manual
int status = _legacyPricing.TryGetUnitPrice(sku, out double rawPrice);
if (status != 0)
{
// ต้องรู้ magic number ของอีกระบบเพื่อแปลความหมาย error
throw new InvalidOperationException($"pricing failed code={status}");
}
// ระบบใหม่ throw exception ตอน sku ไม่มีในสต็อก ต้อง try/catch แยกสไตล์
bool inStock;
try
{
inStock = await _inventoryClient.CheckStockAsync(sku, quantity);
}
catch (InventoryNotFoundException)
{
inStock = false;
}
if (!inStock) return 0m;
// ต้องแปลง double (ของเก่า) เป็น decimal (ของใหม่) เอง ตรงกลาง method
return (decimal)rawPrice * quantity;
}
}

ปัญหา: CheckoutService ต้อง​รู้​รายละเอียด​ภายใน​ของ​ทั้ง​สอง​ระบบ — รูปแบบ error, ชนิด​ข้อมูล, และ model concurrency ถ้า​วัน​หนึ่ง​ต้อง​เปลี่ยน library ราคา ต้อง​ไล่​แก้ code ที่​ผสม​สไตล์​กัน​ทั่ว​ทั้ง method และ test ต้อง mock ทั้ง​สอง​ระบบ​พร้อม​กัน​เสมอ

แนวทาง​แก้​คือ​กัน​ความ​ไม่​เข้า​กัน​ไว้​ใน​ชั้น​เดียว ด้วย​การ​นิยาม port (interface) ที่​ตรง​กับ​ภาษา domain ของ​เรา​เอง แล้ว​เขียน Adapter แยก​ต่างหาก​สำหรับ​แต่ละ​ระบบ​ภายนอก — แนวคิด​เดียว​กับ Anti-Corruption Layer ใน DDD ที่​กัน model ของ Bounded Context อื่น​ไม่​ให้​รั่วไหล​เข้า​มา​ปน​กับ model ของ​เรา

flowchart TB
  subgraph Before
    Core1[CheckoutService]
    Glue1[Inline Glue Code]
    LibA1[LegacyPricingEngine]
    LibB1[InventoryClientV2]
    Core1 --> Glue1
    Glue1 --> LibA1
    Glue1 --> LibB1
  end
  subgraph After
    Core2[CheckoutService]
    PortA[IPricingPort]
    PortB[IInventoryPort]
    AdapterA[LegacyPricingAdapter]
    AdapterB[InventoryV2Adapter]
    LibA2[LegacyPricingEngine]
    LibB2[InventoryClientV2]
    Core2 --> PortA
    Core2 --> PortB
    PortA --> AdapterA
    PortB --> AdapterB
    AdapterA --> LibA2
    AdapterB --> LibB2
  end
// ทางแก้: นิยาม port ตามภาษา domain ของเราเอง
public interface IPricingPort
{
Task<decimal> GetUnitPriceAsync(string sku);
}
public interface IInventoryPort
{
Task<bool> IsInStockAsync(string sku, int quantity);
}
// Adapter กันความประหลาดของ LegacyPricingEngine ไว้ในที่เดียว
public class LegacyPricingAdapter : IPricingPort
{
private readonly LegacyPricingEngine _legacyPricing;
public LegacyPricingAdapter(LegacyPricingEngine legacyPricing)
=> _legacyPricing = legacyPricing;
public Task<decimal> GetUnitPriceAsync(string sku)
{
int status = _legacyPricing.TryGetUnitPrice(sku, out double rawPrice);
if (status != 0)
throw new PricingUnavailableException(sku, status);
return Task.FromResult((decimal)rawPrice);
}
}
// Adapter กันสไตล์ exception-based ของ InventoryClientV2 ไว้ในที่เดียวเช่นกัน
public class InventoryV2Adapter : IInventoryPort
{
private readonly InventoryClientV2 _inventoryClient;
public InventoryV2Adapter(InventoryClientV2 inventoryClient)
=> _inventoryClient = inventoryClient;
public async Task<bool> IsInStockAsync(string sku, int quantity)
{
try
{
return await _inventoryClient.CheckStockAsync(sku, quantity);
}
catch (InventoryNotFoundException)
{
return false;
}
}
}
// CheckoutService รู้จักแค่ port ของตัวเอง ไม่รู้จักระบบภายนอกเลย
public class CheckoutService
{
private readonly IPricingPort _pricing;
private readonly IInventoryPort _inventory;
public CheckoutService(IPricingPort pricing, IInventoryPort inventory)
{
_pricing = pricing;
_inventory = inventory;
}
public async Task<decimal> CalculateTotalAsync(string sku, int quantity)
{
if (!await _inventory.IsInStockAsync(sku, quantity)) return 0m;
decimal unitPrice = await _pricing.GetUnitPriceAsync(sku);
return unitPrice * quantity;
}
}

ตอน​นี้ CheckoutService พูด​ภาษา domain ของ​ตัวเอง​ล้วน ๆ (IPricingPort, IInventoryPort) ไม่รู้เรื่อง error code หรือ exception type ของ​ระบบ​ภายนอก​เลย ถ้า​วัน​หนึ่ง​เปลี่ยน​ไป​ใช้ pricing engine เจ้า​อื่น แก้​แค่ adapter ตัว​เดียว test CheckoutService ก็ mock แค่2 interface เล็ก ๆ แทนที่​จะ​ต้อง mock ทั้ง2 library จริง

หลักการ​ที่​ช่วย​ป้องกัน Frankencode ตั้งแต่​ต้นทาง

  • วาง​วิสัยทัศน์​เชิง​สถาปัตยกรรม​ก่อน​เริ่ม​รวม​ระบบ ไม่ใช่​ปล่อย​ให้​แต่ละ feature ต่อ​กันเอง​แบบ ad-hoc
  • รักษา​ความ​เป็น loosely coupled และ high cohesion (ดู Separation of Concerns)
  • ใช้ Dependency Inversion — ให้ core domain นิยาม interface เอง แล้ว​ให้ adapter ไป implement ตาม ไม่ใช่​ให้ core ไป​ปรับ​ตัว​ตาม library ภายนอก
  • เมื่อ​จะ​ย้าย​ออก​จาก​ระบบ​เก่า​ที​ละ​ส่วน ใช้ Strangler Fig แทน​การ​รื้อ​ทั้ง​ก้อน​พร้อม​กัน
  • ตรวจสอบ code ที่​ดึง​มา​จาก​ภายนอก​ก่อน​รวม (ดู Found on the Internet) และ​มี code review / unit test คุม​ทุก​จุด​ต่อ