- SOLID คืออะไร
- S — Single Responsibility Principle
- O — Open/Closed Principle
- L — Liskov Substitution Principle
- I — Interface Segregation Principle
- D — Dependency Inversion Principle
- SOLID ทำงานร่วมกันอย่างไร
- เช็กลิสต์สำหรับ code review
- สรุป
ตัวอย่างใช้ TypeScript เพื่อสื่อแนวคิด และนำไปประยุกต์กับ Java, C#, PHP, Python หรือภาษาเชิงวัตถุอื่นได้

#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 จากเดิมที่ใช้อีเมล:
- SRP: แยกกฎยืนยันคำสั่งซื้อออกจากการส่งข้อความ
- OCP: เพิ่ม
LineNotifierที่ทำตามNotifierโดยแก้OrderServiceให้น้อยที่สุด - LSP:
LineNotifierต้องทำตามสัญญาsend()ที่ผู้เรียกพึ่งพา เช่น จัดการความล้มเหลวตามที่สัญญากำหนด - ISP: ถ้าบางช่องทางส่งไฟล์ไม่ได้ อย่าบังคับทุกตัวให้มีเมธอด
sendFile() - DIP:
OrderServiceใช้Notifierที่รับเข้ามา แทนการสร้าง LINE SDK ด้วยตัวเอง
#เช็กลิสต์สำหรับ code review
- การเปลี่ยนกฎธุรกิจหนึ่งข้อทำให้ต้องแก้คลาสที่ทำเรื่องอื่นหรือไม่?
- เพิ่มชนิดใหม่แล้วต้องไล่แก้
if/switchหลายจุดหรือไม่? - implementation ใดรับประกันได้น้อยกว่าสัญญาที่ interface ระบุหรือไม่?
- มีเมธอดที่ implementation ต้องปล่อยว่างหรือโยน
Not supportedหรือไม่? - ส่วนที่เป็นกฎธุรกิจสร้างฐานข้อมูล, SDK หรือ HTTP client ด้วยตัวเองหรือไม่?
- ชั้นนามธรรมที่เพิ่มมีกรณีใช้งานจริงรองรับหรือเป็นเพียงการเผื่ออนาคต?
#สรุป
จำ SOLID แบบสั้น ๆ ได้ว่า แยกเหตุที่เปลี่ยน (S), เพิ่มความสามารถโดยลดการแก้ของเดิม (O), ใช้ชนิดลูกแทนได้ตามสัญญา (L), แยกสัญญาตามผู้ใช้ (I), และให้ตรรกะหลักพึ่งสัญญากลาง (D) เริ่มใช้จากจุดที่เปลี่ยนบ่อย ทดสอบยาก หรือผูกกับรายละเอียดมากเกินไป แล้วปรับโครงสร้างเมื่อมีเหตุผลที่ชัดเจน