ตัวอย่างใช้ TypeScript เพื่อสื่อแนวคิด และนำไปประยุกต์กับ Java, C#, PHP, Python หรือภาษาเชิงวัตถุอื่นได้

info-solid

#SOLID คืออะไร

SOLID คือแนวทางออกแบบซอฟต์แวร์ 5 ข้อที่ช่วยจัดหน้าที่ของคลาสและความสัมพันธ์ระหว่างส่วนต่าง ๆ ของระบบ เมื่อความต้องการเปลี่ยน เราจะระบุจุดที่ต้องแก้ได้ง่ายขึ้น ลดผลกระทบต่อส่วนอื่น และทดสอบแต่ละส่วนได้สะดวกขึ้น SOLID ไม่ใช่กฎว่าทุกคลาสต้องมีขนาดเล็ก หรือทุกกรณีต้องสร้าง interface

ตัวอักษร ชื่อหลักการ คำถามสำคัญ
S Single Responsibility Principle (SRP) ส่วนนี้มีเหตุผลให้เปลี่ยนกี่เรื่อง?
O Open/Closed Principle (OCP) เพิ่มพฤติกรรมใหม่ได้โดยแก้โค้ดเดิมให้น้อยลงหรือไม่?
L Liskov Substitution Principle (LSP) ใช้ชนิดลูกแทนชนิดแม่แล้วสัญญาการทำงานยังเหมือนเดิมหรือไม่?
I Interface Segregation Principle (ISP) ผู้ใช้ต้องพึ่งพาเมธอดที่ตนไม่ใช้หรือไม่?
D Dependency Inversion Principle (DIP) ตรรกะหลักพึ่งรายละเอียดเฉพาะทางโดยตรงหรือพึ่งสัญญากลาง?

#S — Single Responsibility Principle

คลาสหรือโมดูลควรมีเหตุผลหลักที่ทำให้ต้องเปลี่ยนเพียงเรื่องเดียว โดยดูจากผู้รับผิดชอบและเหตุของการเปลี่ยนแปลง ไม่ได้แปลว่า 1 คลาสต้องมี 1 เมธอด

สมมติคลาส Invoice คำนวณยอด บันทึกฐานข้อมูล และส่งอีเมล หากสูตรภาษี โครงสร้างฐานข้อมูล หรือรูปแบบอีเมลเปลี่ยน คลาสเดียวกันต้องถูกแก้ในสามกรณี

// ก่อน: ความรับผิดชอบหลายเรื่องอยู่ในคลาสเดียว
class Invoice {
  calculateTotal() { /* กฎราคาและภาษี */ }
  save() { /* SQL */ }
  emailCustomer() { /* ส่งอีเมล */ }
}

// หลัง: แยกตามเหตุที่ทำให้ต้องแก้ไข
class InvoiceCalculator {
  calculateTotal() { /* กฎราคาและภาษี */ }
}
class InvoiceRepository {
  save() { /* จัดเก็บข้อมูล */ }
}
class InvoiceMailer {
  send() { /* ส่งอีเมล */ }
}

ใช้เมื่อ: คลาสเดียวถูกแก้ซ้ำ ๆ ด้วยเหตุผลคนละเรื่อง หรือการทดสอบกฎธุรกิจต้องเตรียมฐานข้อมูลและระบบส่งข้อความโดยไม่จำเป็น

#O — Open/Closed Principle

ส่วนประกอบควรเปิดให้เพิ่มพฤติกรรม แต่ลดความจำเป็นต้องแก้ตรรกะเดิม เช่น เพิ่มรูปทรงใหม่โดยให้รูปทรงนั้นคำนวณพื้นที่เอง แทนการเพิ่ม if ในตัวคำนวณกลางทุกครั้ง

interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
}

class Circle implements Shape {
  constructor(private radius: number) {}
  area() { return Math.PI * this.radius ** 2; }
}

function totalArea(shapes: Shape[]): number {
  return shapes.reduce((sum, shape) => sum + shape.area(), 0);
}

เมื่อต้องเพิ่ม Triangle เราสร้างคลาสที่ทำตาม Shape ได้โดยไม่ต้องแก้ totalArea อย่างไรก็ตาม ถ้าระบบมีรูปทรงเดียวและไม่มีสัญญาณว่าจะขยาย การสร้างชั้นนามธรรมล่วงหน้าอาจทำให้โค้ดซับซ้อนเกินประโยชน์

#L — Liskov Substitution Principle

วัตถุของชนิดลูกต้องใช้แทนชนิดแม่ได้โดยไม่ทำลายสัญญาที่ผู้เรียกคาดหวัง สัญญานี้ครอบคลุมเงื่อนไขก่อนเรียก ผลลัพธ์ และข้อผิดพลาด ไม่ใช่แค่ชื่อเมธอดหรือชนิดข้อมูลตรงกัน

interface ShippingCalculator {
  // สัญญา: รับน้ำหนักที่มากกว่า 0 ทุกค่าและคืนค่าขนส่งที่ไม่ติดลบ
  cost(weightKg: number): number;
}

// ผิดสัญญา: ผู้เรียกที่ส่ง 8 กก. ใช้ ShippingCalculator ได้ตามสัญญา
// แต่ implementation นี้กลับโยนข้อผิดพลาด
class BikeShipping implements ShippingCalculator {
  cost(weightKg: number): number {
    if (weightKg > 5) throw new Error("Too heavy");
    return weightKg * 20;
  }
}

// ทางแก้หนึ่ง: เปลี่ยนสัญญาให้รองรับการปฏิเสธงานอย่างชัดเจน
type Quote = { accepted: true; price: number } | { accepted: false; reason: string };
interface ShippingQuote {
  quote(weightKg: number): Quote;
}
class BikeQuote implements ShippingQuote {
  quote(weightKg: number): Quote {
    return weightKg > 5
      ? { accepted: false, reason: "น้ำหนักเกิน 5 กก." }
      : { accepted: true, price: weightKg * 20 };
  }
}

ในระบบจริงต้องกำหนดเงื่อนไขน้ำหนักที่ไม่ถูกต้อง (weightKg <= 0) ให้ชัดเจนด้วย ตัวอย่างนี้เน้นว่าข้อจำกัดใหม่ของชนิดลูกต้องไม่แอบทำลายคำมั่นของชนิดแม่

#I — Interface Segregation Principle

ผู้ใช้ไม่ควรถูกบังคับให้พึ่งเมธอดที่ตนไม่จำเป็นต้องใช้ เช่น เครื่องพิมพ์พื้นฐานไม่ควรถูกบังคับให้ทำ scan() และ fax() เพียงเพราะ interface เดียวรวมทุกความสามารถ

// ก่อน: BasicPrinter อาจต้องมี scan() ที่โยนข้อผิดพลาด
interface AllInOneMachine {
  print(document: string): void;
  scan(): string;
  fax(number: string): void;
}

// หลัง: ผู้ใช้และอุปกรณ์เลือกพึ่งเฉพาะความสามารถที่ต้องใช้
interface Printer { print(document: string): void; }
interface Scanner { scan(): string; }
interface Fax { fax(number: string): void; }

class BasicPrinter implements Printer {
  print(document: string): void { console.log(document); }
}

เครื่องมัลติฟังก์ชันยังสามารถ implements Printer, Scanner, Fax ได้ การแยก interface ทำให้ผู้ใช้ Printer ไม่ต้องรับผลกระทบเมื่อสัญญาของ Fax เปลี่ยน

#D — Dependency Inversion Principle

ตรรกะระดับสูงและรายละเอียดระดับต่ำควรพึ่งสัญญากลางที่เหมาะสม แทนการให้ตรรกะหลักสร้างอีเมลไคลเอนต์หรือเชื่อมฐานข้อมูลด้วยตัวเอง การส่ง dependency ผ่าน constructor เป็นวิธีหนึ่งที่ใช้หลักการนี้

interface Notifier {
  send(recipient: string, message: string): Promise<void>;
}

class EmailNotifier implements Notifier {
  async send(recipient: string, message: string): Promise<void> {
    // เรียกผู้ให้บริการส่งอีเมล
  }
}

class OrderService {
  constructor(private notifier: Notifier) {}

  async confirm(email: string): Promise<void> {
    // ตรรกะยืนยันคำสั่งซื้อ
    await this.notifier.send(email, "ยืนยันคำสั่งซื้อแล้ว");
  }
}

const service = new OrderService(new EmailNotifier());

เมื่อทดสอบ OrderService สามารถส่งตัวแทนของ Notifier ที่เก็บข้อความไว้ในหน่วยความจำ เมื่อเปลี่ยนผู้ให้บริการอีเมล ตรรกะใน OrderService ไม่จำเป็นต้องรู้รายละเอียดใหม่ คำว่า dependency inversion เป็นหลักการออกแบบ ส่วน dependency injection คือวิธีส่ง dependency จากภายนอก ซึ่งมักใช้เพื่อทำตามหลักการนี้

#SOLID ทำงานร่วมกันอย่างไร

สมมติระบบเพิ่มช่องทางแจ้งเตือน LINE จากเดิมที่ใช้อีเมล:

  1. SRP: แยกกฎยืนยันคำสั่งซื้อออกจากการส่งข้อความ
  2. OCP: เพิ่ม LineNotifier ที่ทำตาม Notifier โดยแก้ OrderService ให้น้อยที่สุด
  3. LSP: LineNotifier ต้องทำตามสัญญา send() ที่ผู้เรียกพึ่งพา เช่น จัดการความล้มเหลวตามที่สัญญากำหนด
  4. ISP: ถ้าบางช่องทางส่งไฟล์ไม่ได้ อย่าบังคับทุกตัวให้มีเมธอด sendFile()
  5. DIP: OrderService ใช้ Notifier ที่รับเข้ามา แทนการสร้าง LINE SDK ด้วยตัวเอง

#เช็กลิสต์สำหรับ code review

  • การเปลี่ยนกฎธุรกิจหนึ่งข้อทำให้ต้องแก้คลาสที่ทำเรื่องอื่นหรือไม่?
  • เพิ่มชนิดใหม่แล้วต้องไล่แก้ if/switch หลายจุดหรือไม่?
  • implementation ใดรับประกันได้น้อยกว่าสัญญาที่ interface ระบุหรือไม่?
  • มีเมธอดที่ implementation ต้องปล่อยว่างหรือโยน Not supported หรือไม่?
  • ส่วนที่เป็นกฎธุรกิจสร้างฐานข้อมูล, SDK หรือ HTTP client ด้วยตัวเองหรือไม่?
  • ชั้นนามธรรมที่เพิ่มมีกรณีใช้งานจริงรองรับหรือเป็นเพียงการเผื่ออนาคต?

#สรุป

จำ SOLID แบบสั้น ๆ ได้ว่า แยกเหตุที่เปลี่ยน (S), เพิ่มความสามารถโดยลดการแก้ของเดิม (O), ใช้ชนิดลูกแทนได้ตามสัญญา (L), แยกสัญญาตามผู้ใช้ (I), และให้ตรรกะหลักพึ่งสัญญากลาง (D) เริ่มใช้จากจุดที่เปลี่ยนบ่อย ทดสอบยาก หรือผูกกับรายละเอียดมากเกินไป แล้วปรับโครงสร้างเมื่อมีเหตุผลที่ชัดเจน