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

Single Responsibility Principle

class หนึ่ง​ควร​มี​เหตุผล​ให้​เปลี่ยนแปลง​เพียง​เหตุผล​เดียว

Single Responsibility Principle (SRP) คือ​ตัว “S” ตัว​แรก​ใน SOLID กล่าว​ไว้​ใน​ถ้อยคำ​ดั้งเดิม​ของ Robert C. Martin ว่า “A class should have only one reason to change” — class หนึ่ง​ควร​มี​เหตุผล​ให้​ต้อง​เปลี่ยนแปลง​เพียง​เหตุผล​เดียว​เท่านั้น หลักการ​นี้​ถูก​กล่าว​ถึง​ใน​รูปแบบ​นี้​ครั้ง​แรก​ใน​บทความ “The Principles of OOD” ของ Martin ก่อน​จะ​กลาย​เป็น​บท​หนึ่ง​ใน​หนังสือ Agile Software Development, Principles, Patterns, and Practices (2003) โดย​มี​รากฐาน​มา​จาก​แนวคิด​เรื่อง cohesion ของ Tom DeMarco และ Meilir Page-Jones

คำ​ว่า “เหตุผล” (reason) กลาย​เป็น​จุด​ที่​ทำให้​เกิด​ความ​เข้าใจ​ผิด​กัน​มาก เพราะ​ฟัง​ดู​เป็น​นามธรรม​เกิน​ไป ในปี 2014 Martin จึง​เขียน block post “The Single Responsibility Principle” เพื่อ​นิยาม​ใหม่​ให้​ชัด​ว่า “This principle is about people” — หลักการ​นี้​พูด​ถึง​คน ไม่ใช่ function เขา​เปลี่ยน​คำ​อธิบาย​เป็น actor: “A module should be responsible to one, and only one, actor” โดย actor หมาย​ถึง​กลุ่ม​ผู้​มี​ส่วน​ได้​ส่วน​เสีย (stakeholder) หนึ่ง​กลุ่ม​ที่​เป็นต้นตอ​ของ​การ​ร้องขอ​ให้​เปลี่ยน code ส่วน​นั้น

ตัวอย่าง​คลาสสิก​ของ Martin คือ class Employee ที่​มี3 method:

  • calculatePay() — คำนวณ​ค่า​จ้าง เปลี่ยนแปลง​ตาม​นโยบาย​ของ​ฝ่าย​บัญชี (CFO)
  • reportHours() — จัด​รูปแบบ​รายงาน​ชั่วโมง​ทำงาน​เพื่อ​การ​ตรวจสอบ เปลี่ยนแปลง​ตาม​ฝ่าย​ปฏิบัติการ (COO)
  • save() — บันทึก​ข้อมูล​ลง​ฐาน​ข้อมูล เปลี่ยนแปลง​ตาม​ฝ่าย IT/สถาปัตยกรรม​ข้อมูล (CTO)

ทั้ง3 method อยู่​ใน class เดียวกัน แต่​มี​ต้นตอ​การ​เปลี่ยนแปลง​คนละ​กลุ่ม (actor) หาก​ฝ่าย​บัญชี​ขอ​แก้​สูตร​คำนวณ​เงินเดือน นัก​พัฒนา​ต้อง​แตะ class Employee ทั้ง class และ​เสี่ยง​ที่​จะ​ทำให้ reportHours() หรือ save() พัง​ไป​ด้วย​โดย​ไม่​ตั้งใจ ทั้ง​ที่​ทั้ง​สอง​ฝ่าย​นั้น​ไม่​ได้​ขอ​ให้​เปลี่ยนแปลง​อะไร​เลย นี่​คือ​แก่น​ของ​ปัญหา​ที่ SRP พยายาม​ป้องกัน

SRP เกี่ยวข้อง​อย่าง​ใกล้​ชิด​กับ​แนวคิด​เรื่อง coupling และ cohesion: coupling คือ​ระดับ​ความ​เชื่อม​โยง​ที่​แยก​ไม่​ออก​ระหว่าง​ส่วน​ต่าง ๆ ของ application ส่วน cohesion คือ​ความ​เกี่ยวข้อง​กัน​ของ​เนื้อหา​ภายใน class หรือ package หนึ่ง ๆ เนื้อหา​ทั้งหมด​ภายใน class เดียว​ย่อม​มี coupling สูง​ต่อ​กัน เพราะ​ตัว class เอง​เป็น​หน่วย​เดียว​ที่​ถูก compile ทดสอบ และ deploy ไป​ด้วย​กัน​ทั้งหมด เมื่อ class หนึ่ง​มี cohesion ต่ำ (รวม​ความ​รับผิดชอบ​ที่​ไม่​เกี่ยวข้อง​กัน​ไว้​ใน​ที่​เดียว) การ​เปลี่ยนแปลง​ส่วน​ใด​ส่วน​หนึ่ง​ก็​บังคับ​ให้​ทุก class ที่​พึ่งพา​ต้อง​ถูก​ทดสอบ​ซ้ำ​ทั้งหมด พื้นที่​ที่​ได้​รับ​ผลกระทบ​จาก​การ​เปลี่ยนแปลง​จึง​กว้าง​เกิน​จำเป็น ใน​ทาง​กลับ​กัน หาก​แตก class ออก​เป็น​หลาย class ที่​มี cohesion สูง แต่ละ class จะ​ถูก​ใช้​โดย​องค์​ประกอบ​อื่น​น้อย​ลง การ​เปลี่ยนแปลง class ใด class หนึ่ง​จึง​กระทบ​ระบบ​โดย​รวม​น้อย​ลง

อย่างไร​ก็ตาม เช่น​เดียว​กับ​หลักการ​อื่น ๆ การ​พยายาม​ยัด SRP ให้​ทุก class ตั้งแต่​วัน​แรก​ไม่ใช่​เรื่อง​ฉลาด หาก class หนึ่ง​เสถียร​ดี​อยู่​แล้ว​และ​ไม่​ได้​สร้าง​ความ​เจ็บ​ปวด​จาก​การ​เปลี่ยนแปลง​บ่อย ๆ ก็​ไม่​จำเป็น​ต้อง​แตะ​ต้อง​มัน​ก่อน​เวลา​อัน​ควร จง​ฝึก​ใช้ Pain Driven Development — แยก​ความ​รับผิดชอบ​เมื่อ​มัน​เริ่ม​เจ็บ​จริง ๆ ไม่ใช่​แยก​ล่วงหน้า​ตาม​ทฤษฎี

  • ลด blast radius ของ​การ​เปลี่ยนแปลง — เมื่อ actor หนึ่ง​ขอ​แก้ไข การ​เปลี่ยนแปลง​จะ​จำกัด​อยู่​ใน class เดียว​ที่​เกี่ยวข้อง ไม่​ลาม​ไป​กระทบ behavior ที่ actor อื่น​พึ่งพา​อยู่ ลด​ความ​เสี่ยง​ของ regression ที่​ไม่​คาด​คิด
  • ทดสอบ​ง่าย​ขึ้น — class ที่​มี​ความ​รับผิดชอบ​เดียว​มัก​มี dependency น้อย ทำให้​เขียน unit test ได้​ตรง​จุด​และ mock ได้​ง่าย ต่าง​จาก class อ้วน​ที่​ต้อง setup สภาพ​แวดล้อม​ซับซ้อน​ก่อน​ทดสอบ​แม้แต่ function เดียว
  • อ่าน​และ​ทำความ​เข้าใจ​ง่าย — ชื่อ class ที่​สื่อ​ความ​รับผิดชอบ​เดียว​ชัดเจน ช่วย​ให้​ทีม​ค้นหา code ที่​ต้อง​แก้​ได้​เร็ว ลด​เวลา​ที่​ใช้​ไล่​อ่าน “iceberg class” ที่​ทำ​หลาย​อย่าง​เกิน​กว่า​ชื่อ​จะ​บอก​ได้
  • ป้องกัน merge conflict ระหว่าง​ทีม — เมื่อ​สอง​ทีม (เช่น​ทีม​บัญชี​กับ​ทีม​รายงาน) ไม่​ต้อง​แก้ file เดียวกัน​เพื่อ requirement คนละ​เรื่อง ความ​ขัดแย้ง​ใน​การ​รวม code จึง​ลด​ลง​ตาม​ธรรมชาติ
  • เป็น​รากฐาน​ของ SOLID ตัว​อื่น — class ที่​มี​ความ​รับผิดชอบ​เดียว​มัก​เปิด/ปิด​ต่อ​การ​ขยาย (OCP) ได้​ง่าย​กว่า และ​มัก inject dependency ได้​สะอาด​กว่า เพราะ interface ที่​พึ่งพา​มี​ขอบเขต​แคบ​และ​ชัดเจน

class InvoiceProcessor ด้าน​ล่าง​มี​ถึง​สี่​เหตุผล​ให้​เปลี่ยนแปลง: กฎ​การ​คำนวณ​ภาษี รูปแบบ​การ​พิมพ์​ใบ​แจ้ง​หนี้ วิธีการ​บันทึก​ลง​ฐาน​ข้อมูล และ​ช่อง​ทางการ​แจ้ง​เตือน​ลูกค้า

// ละเมิด SRP: รวมสี่ความรับผิดชอบไว้ใน class เดียว
public class InvoiceProcessor
{
public decimal CalculateTotal(Invoice invoice)
{
// เหตุผลที่ 1: เปลี่ยนตามนโยบายภาษีของฝ่ายบัญชี
decimal subtotal = invoice.Items.Sum(i => i.Price * i.Quantity);
return subtotal + subtotal * 0.07m; // VAT 7%
}
public string FormatAsHtml(Invoice invoice)
{
// เหตุผลที่ 2: เปลี่ยนตามทีมออกแบบเอกสาร
return $"<html><body><h1>Invoice {invoice.Id}</h1></body></html>";
}
public void SaveToDatabase(Invoice invoice)
{
// เหตุผลที่ 3: เปลี่ยนตามทีมโครงสร้างพื้นฐานข้อมูล
using var connection = new SqlConnection("...");
connection.Execute("INSERT INTO Invoices ...", invoice);
}
public void SendEmailNotification(Invoice invoice)
{
// เหตุผลที่ 4: เปลี่ยนตามทีม CRM/การแจ้งเตือน
var client = new SmtpClient();
client.Send("billing@shop.com", invoice.CustomerEmail, "Invoice Ready", "...");
}
}

หาก​ฝ่าย​บัญชี​ขอ​เปลี่ยน​อัตรา​ภาษี นัก​พัฒนา​ต้อง​เปิด file นี้ ทดสอบ​ทั้ง class และ​เสี่ยง​ที่​จะ​ทำให้ logic การ​ส่ง​อีเมล​พัง​ไป​ด้วย ทั้ง​ที่​ทีม CRM ไม่​ได้​ร้องขอ​อะไร​เลย

// แต่ละ class รับผิดชอบต่อ actor เดียว เปลี่ยนแปลงอิสระจากกัน
public class InvoiceCalculator
{
// เปลี่ยนเมื่อ actor: ฝ่ายบัญชี ขอแก้กฎภาษี
public decimal CalculateTotal(Invoice invoice)
{
decimal subtotal = invoice.Items.Sum(i => i.Price * i.Quantity);
return subtotal + subtotal * 0.07m;
}
}
public class InvoiceHtmlFormatter
{
// เปลี่ยนเมื่อ actor: ทีมออกแบบเอกสาร ขอปรับรูปแบบ
public string Format(Invoice invoice)
=> $"<html><body><h1>Invoice {invoice.Id}</h1></body></html>";
}
public class InvoiceRepository
{
// เปลี่ยนเมื่อ actor: ทีมโครงสร้างพื้นฐานข้อมูล ขอเปลี่ยนวิธีจัดเก็บ
public void Save(Invoice invoice)
{
using var connection = new SqlConnection("...");
connection.Execute("INSERT INTO Invoices ...", invoice);
}
}
public class InvoiceNotifier
{
// เปลี่ยนเมื่อ actor: ทีม CRM ขอเปลี่ยนช่องทางแจ้งเตือน
public void SendEmail(Invoice invoice)
{
var client = new SmtpClient();
client.Send("billing@shop.com", invoice.CustomerEmail, "Invoice Ready", "...");
}
}
// class ที่ประสานงาน (orchestrator) เชื่อมทุกอย่างเข้าด้วยกัน
// โดยตัวมันเองมีความรับผิดชอบเดียวคือ "ควบคุมลำดับขั้นตอน"
public class InvoiceWorkflow
{
private readonly InvoiceCalculator _calculator;
private readonly InvoiceRepository _repository;
private readonly InvoiceNotifier _notifier;
public InvoiceWorkflow(
InvoiceCalculator calculator,
InvoiceRepository repository,
InvoiceNotifier notifier)
{
_calculator = calculator;
_repository = repository;
_notifier = notifier;
}
public void Process(Invoice invoice)
{
invoice.Total = _calculator.CalculateTotal(invoice);
_repository.Save(invoice);
_notifier.SendEmail(invoice);
}
}

ตอน​นี้​แต่ละ class มี actor เดียว​เป็น​เจ้าของ​เหตุผล​ใน​การ​เปลี่ยนแปลง ทดสอบ​แยก​กัน​ได้ และ​เปลี่ยน​อัตรา​ภาษี​ได้​โดย​ไม่​ต้อง​แตะ code การ​ส่ง​อีเมล​เลย ส่วน InvoiceWorkflow ทำ​หน้าที่​เป็น​ตัว​ประสาน​งาน (คล้าย​รูปแบบ Facade) ซึ่ง​เป็น​ความ​รับผิดชอบ​ของ​มัน​เอง​ที่​ชัดเจน​คือ “ควบคุม​ลำดับ​การ​ทำงาน” ไม่ใช่ “ทำ​ทุก​อย่าง​เอง”

graph LR
  Workflow[InvoiceWorkflow]
  Calc[InvoiceCalculator]
  Repo[InvoiceRepository]
  Notify[InvoiceNotifier]
  Format[InvoiceHtmlFormatter]

  Workflow --> Calc
  Workflow --> Repo
  Workflow --> Notify
  Workflow --> Format
  • ชื่อ method ใน class เดียวกัน​พูด​ถึง​คนละ domain กัน เช่น CalculateTax, RenderPdf, SendSms ใน class เดียว — สัญญาณ​ของ Blob หรือ God Class
  • แก้ bug หนึ่ง​จุด​แล้ว​ของ​อีก​จุด​หนึ่ง​พัง ทั้ง​ที่​ดู​ไม่​เกี่ยวข้อง​กัน — อาการ​คลาสสิก​ของ cohesion ต่ำ
  • คุณ​อธิบาย​ว่า class ทำ​อะไร​ด้วย​คำ​ว่า “และ” (and) เช่น “class นี้ validate ข้อมูล และ บันทึก​ลง​ฐาน​ข้อมูล และ ส่ง​อีเมล” — ถ้า​ประโยค​อธิบาย​ต้อง​ใช้ “และ” หลาย​ครั้ง นั่น​คือ​หลาย​ความ​รับผิดชอบ
  • การ​เปลี่ยนแปลง​เล็ก ๆ จาก​ทีม​หนึ่ง​บังคับ​ให้​ทีม​อื่น​ต้อง re-test/re-deploy ด้วย — สัญญาณ​ของ Shotgun Surgery หรือ Divergent Change
  • file class ยาว​หลาย​ร้อย​หรือ​หลาย​พัน​บรรทัด และ​มี field/dependency จำนวน​มาก​ที่​ใช้​เฉพาะ​บาง method เท่านั้น
  • ยาก​ที่​จะ​ตั้ง​ชื่อ class ให้​สื่อ​ความหมาย​เดียว​ได้ — ถ้า​ต้อง​ใช้​ชื่อ​กำกวม​อย่าง Manager, Processor, Helper, Utility แบบ​ไม่​จำกัด​ขอบเขต มัก​เป็น​สัญญาณ​ว่า​กำลัง​ยัด​หลาย​หน้าที่​ไว้​ด้วย​กัน
  • SOLID — SRP เป็น​หลักการ​ตัว​แรก​ใน​ชุด​หลักการ​นี้
  • Separation of Concerns — แนวคิด​กว้าง​กว่า​ที่ SRP นำ​ไป​ประยุกต์​ใช้​ใน​ระดับ class
  • Open/Closed Principle — class ที่​มี​ความ​รับผิดชอบ​เดียว​มัก​เปิด​ขยาย​ได้​ง่าย​กว่า​โดย​ธรรมชาติ
  • Blob (God Class) — แอนตี้ pattern ที่​เกิด​จาก​การ​ละเลย SRP อย่าง​ต่อ​เนื่อง
  • Shotgun Surgery — กลิ่น code ที่​มัก​เป็น​ผล​ข้าง​เคียง​ของ​การ​รวม​ความ​รับผิดชอบ​ผิด​ที่
  • Pain Driven Development — แนวทาง​ตัดสิน​ใจ​ว่า​เมื่อไร​ควร​แยก​ความ​รับผิดชอบ​จริง ๆ