ซอฟต์แวร์ทุกตัวเปลี่ยนแปลงตามเวลา การเปลี่ยนแปลงอาจก่อให้เกิด Cascading Issues ที่ไม่คาดคิด แต่การเปลี่ยนแปลงเป็นสิ่งหลีกเลี่ยงไม่ได้ เราไม่สามารถสร้างซอฟต์แวร์ที่ไม่เปลี่ยนแปลงได้ Software Requirements เปลี่ยนไปเรื่อยๆ ขณะที่ซอฟต์แวร์โตขึ้น สิ่งเราทำได้คือออกแบบซอฟต์แวร์ให้ทนทานต่อการเปลี่ยนแปลง การออกแบบที่ดีอาจใช้เวลาและความพยายามตอนเริ่ม แต่ Long-term ประหยัดเวลาและความพยายาม Software ที่ Tightly Coupled แตกหักง่าย และเราไม่คาดการณ์ได้ว่าการเปลี่ยนแปลงจะเกิดอะไรขึ้น นี่คือผลกระทบของ Software ที่ออกแบบไม่ดี:
- ก่อให้เกิด Immobility (ย้าย/นำกลับมาใช้ใหม่ไม่ได้)
- การเปลี่ยนโค้ดมีต้นทุนสูง
- เพิ่ม Complexity ง่ายกว่าทำให้ Software เรียบง่ายขึ้น
- Code ไม่สามารถ Manage ได้
- Developer ใช้เวลานานมากเข้าใจ Functionality
- เปลี่ยนส่วนหนึ่งมักทำให้ส่วนอื่นพัง และไม่คาดเดา Issues ได้
Paper "Design Principles and Design Patterns" ระบุอาการของ Rotting Software:
- Rigidity (ความแข็งตัว): ยากมากที่จะเปลี่ยนโค้ดโดยไม่ก่อปัญหา เพราะเปลี่ยนส่วนหนึ่ง ต้องเปลี่ยนส่วนอื่นๆ ตาม
- Fragility (ความเปราะบาง): เปลี่ยนโค้ดมักทำให้ Behavior พัง แม้ส่วนที่ไม่เกี่ยวข้องโดยตรง
- Immobility (ความไม่เคลื่อนย้าย): แม้บางส่วนจะมี Behavior คล้ายกัน แต่ Reuse Code ไม่ได้ ต้อง Duplicate
- Viscosity (ความ粘稠): Software ยากต่อการเปลี่ยนแปลง เราก็เพิ่ม Complexity แทนที่จะปรับปรุง
จึงจำเป็นต้องออกแบบ Software ให้ Changes ควบคุมและคาดเดาได้
หลักการออกแบบ SOLID ช่วยแก้ปัญหาเหล่านี้โดย Decouple Software Programs Robert C. Martin เสนอใน Paper "Design Principles and Design Patterns" และ Michael Feathers สร้าง Acronym SOLID
หลักการ SOLID ประกอบ 5 原則:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
เราจะสำรวจแต่ละอย่าง เพื่อเข้าใจว่าช่วยสร้าง Well-designed Software ใน Ruby ได้อย่างไร
Single Responsibility Principle - SRP
สมมติ HR Management Software ต้องการ: สร้าง User, เพิ่ม Salary พนักงาน, สร้าง Payslip พนักงาน ตอนสร้าง อาจใส่ทั้งหมดใน Class เดียว แต่ Approach นี้ก่อ Unwanted Dependencies ระหว่าง Functionalities เริ่มต้น Simple แต่เมื่อ Requirements เปลี่ยน เราจะไม่คาดเดาได้ว่า Change จะทำลาย Functionality ไหน
A class should have one, and only one reason to change — Robert C Martin
ตัวอย่างโค้ดทุก Functionality ใน Class เดียว:
class User
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# Code to read from database,
# generate payslip
# and write it to a file
self.send_email
end
def send_email
# code to send email
employee.email
month
end
end
Generate Payslip และส่ง Email:
month = 11
user = User.new(employee, month)
user.generate_payslip
Requirement ใหม่: Generate Payslip แต่ไม่ส่ง Email ต้องเก็บ Existing Functionality และเพิ่ม Payslip Generator ใหม่สำหรับ Internal Reporting โดยไม่ส่ง Email (เพราะ Internal Purpose) ระหว่างนี้ต้องการ Existing Payslip ให้ทำงานต่อไป
สำหรับ Requirement นี้ Reuse Existing Code ไม่ได้ ต้องเพิ่ม Flag ใน generate_payslip บอกถ้า True ส่ง Email False ไม่ส่ง ทำได้ แต่เปลี่ยน Existing Code อาจทำลาย Existing Functionality
เพื่อไม่ให้พัง ต้อง Decouple Logics ออกเป็น Classes แยก:
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# Code to read from database,
# generate payslip
# and write it to a file
end
end
class PayslipMailer
def initialize(employee)
@employee = employee
end
def send_mail
# code to send email
employee.email
month
end
end
Initialize สอง Classes และเรียก Methods:
month = 11
# General Payslip
generator = PayslipGenerator.new(employee, month)
generator.generate_payslip
# Send Email
mailer = PayslipMailer.new(employee, month)
mailer.send_mail
Approach นี้ Decouple Responsibilities และให้ Predictable Change ถ้าต้องการเปลี่ยน Mailer Functionality ทำได้โดยไม่เปลี่ยน Report Generation ช่วย Predict การเปลี่ยนแปลง Functionality ได้
สมมติต้องเปลี่ยน Format Month Field ใน Email เป็น Nov แทน 11 กรณีนี้แก้ PayslipMailer Class และมั่นใจว่า PayslipGenerator ไม่เปลี่ยนแปลงหรือพัง
ทุกครั้งเขียนโค้ด ถามตัวเอง: Class นี้มี Responsibility อะไร? ถ้าคำตอบมี "และ" (And) ให้แยก Class เป็นหลาย Classes Smaller Classes ดีกว่า Large Generic Classes
Open/Closed Principle - OCP
Bertrand Meyer ก่อตั้ง OCP ในหนังสือ "Object-Oriented Software Construction"
หลักการระบุ: "Software Entities (Classes, Modules, Functions, etc.) ควร Open for Extension แต่ Closed for Modification" หมายถึงเราควรเปลี่ยน Behavior ได้โดยไม่เปลี่ยน Entity เอง
ในตัวอย่างก่อน มี Payslip-sending สำหรับ Employee แบบ Generic ทุกคน แต่ Requirement ใหม่: Generate Payslip ตาม Employee Type ต้องการ Payroll Logic แตกต่างสำหรับ Full-time และ Contractor กรณีนี้อาจแก้ PayslipGenerator เดิมและเพิ่ม Functionality:
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# Code to read from database,
# generate payslip
if employee.contractor?
# generate payslip for contractor
else
# generate a normal payslip
end
# and write it to a file
end
end
แต่นี่เป็น Bad Pattern เพราะเรา Modify Existing Class ถ้าต้องเพิ่ม Generation Logic ตาม Contract ใหม่ ต้อง Modify Class เดิม ซึ่งละเมิด OCP การ Modify Class เสี่ยง Unintended Changes เมื่อมีการเปลี่ยนแปลงหรือเพิ่มเติม อาจก่อ Issues ใน Existing Code If-else อาจอยู่หลายที่ใน Class เดียวกัน ตอนเพิ่ม Employee Type ใหม่ อาจ Miss ที่ที่ If-else อยู่ การหาและแก้ทั้งหมด Risky และอาจก่อปัญหา
เรา Refactor ให้ Extend Functionality ได้โดยไม่เปลี่ยน Entity สร้าง Class แยกสำหรับแต่ละ Type มี Method generate เหมือนกัน:
class ContractorPayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate
# Code to read from the database,
# generate payslip
# and write it to a file
end
end
class FullTimePayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate
# Code to read from the database,
# generate payslip
# and write it to a file
end
end
ให้ Method Name เหมือนกัน แล้วแก้ PayslipGenerator ให้ใช้ Classes นี้:
GENERATORS = {
'full_time' => FullTimePayslipGenerator,
'contractor' => ContractorPayslipGenerator
}
class PayslipGenerator
def initialize(employee, month)
@employee = employee
@month = month
end
def generate_payslip
# Code to read from database,
# generate payslip
GENERATORS[employee.type].new(employee, month).generate()
# and write it to a file
end
end
ที่นี่มี Constant GENERATORS Map Class ที่จะเรียกตาม Employee Type ใช้มัน決め เรียก Class ไหน ตอนเพิ่ม Functionality ใหม่ สร้าง Class ใหม่ เพิ่มใน GENERATORS Constant ช่วย Extend Class ได้โดยไม่พังอะไรหรือต้องคิด Existing Logic เพิ่ม/ลบ Payslip Generator Type ได้ง่าย
Liskov Substitution Principle - LSP
LSP ระบุ: "ถ้า S เป็น Subtype ของ T Object ของ Type T อาจแทนที่ด้วย Object ของ Type S ได้"
เข้าใจ Principle นี้ ต้องเข้าใจ Problem ก่อน ภายใต้ OCP เราออกแบบให้ Extend ได้ สร้าง Subclass Payslip Generator ทำงานเฉพาะเจาะจง สำหรับ Caller Class ที่เรียก Unknown Classes เหล่านี้ต้องมี Behavior เดียวกัน Caller จึงแยกความแตกต่างไม่ได้ Behavior หมายถึง Methods ใน Class ควร Consistent Methods ควรมีลักษณะ:
- ชื่อเดียวกัน
- รับ Arguments จำนวนเท่ากัน Data Type เดียวกัน
- Return Data Type เดียวกัน
ดูตัวอย่าง Payslip Generator มี 2 Generators Full-time และ Contractor เพื่อให้ Payslips มี Consistent Behavior ต้อง Inherit จาก Base Class สร้าง Base Class User:
class User
def generate
end
end
Subclass ใน OCP Example ไม่มี Base Class แก้ให้มี Base Class User:
class ContractorPayslipGenerator < User
def generate
# Code to generate payslip
end
end
class FullTimePayslipGenerator < User
def generate
# Code to generate payslip
end
end
กำหนด Set of Methods ที่ Subclass ที่ Inherit User ต้องมี Define ใน Base Class ในกรณีนี้ต้องแค่ Method เดียว: generate
class User
def generate
raise "NotImplemented"
end
end
Define generate Method มี raise Statement Subclass ที่ Inherit Base Class ต้องมี generate Method ถ้าไม่มี จะ Raise Error ว่า Method Not Implemented ด้วยวิธีนี้ ทำให้ Subclass Consistent Caller มั่นใจได้ว่า generate Method มีอยู่เสมอ
Principle นี้ช่วย Substitute Subclass ใดๆ ได้ง่ายโดยไม่พังและไม่ต้องเปลี่ยนแปลงมาก
Interface Segregation Principle - ISP
ISP ใช้กับ Static Languages Ruby เป็น Dynamic Language ไม่มี Interface Concept Interfaces กำหนด Abstraction Rules ระหว่าง Classes
หลักการระบุ:
Clients should not be forced to depend upon interfaces that they don't use. — Robert C. Martin
หมายถึง: ใช้หลาย Interfaces ดีกว่า Generalized Interface ที่ Class ใดๆ ใช้ได้ ถ้า Define Generalized Interface Class ต้อง Depend On Definition ที่มันไม่ใช้
Ruby ไม่มี Interfaces แต่ดู Class/Subclass Concept สร้างอะไรคล้ายกัน
ใน LSP Example เราเห็น FullTimePayslipGenerator Inherit จาก Generic Class User แต User Generic มาก อาจมี Methods อื่นๆ ถ้าต้องการ Functionality Leave ก็ต้องเป็น Subclass ของ User Leave ไม่ต้องมี generate Method แต่จะ Depend On Method นี้ แทน Generic Class ให้ Specific Class:
class Generator
def generate
raise "NotImplemented"
end
end
class ContractorPayslipGenerator < Generator
def generate
# Code to generate payslip
end
end
class FullTimePayslipGenerator < Generator
def generate
# Code to generate payslip
end
end
Generator นี้เฉพาะ Payslip Generation Subclass ไม่ต้อง Depend On Generic User Class
Dependency Inversion Principle - DIP
Dependency Inversion เป็น Principle Decouple Software Modules
High-level Module ไม่ควร Depend On Low-level Module ทั้งคู่ควร Depend On Abstraction
Design ด้วย Principles ข้างบน นำเราไปสู่ DIP Class ที่มี Single Responsibility ต้องการสิ่งจาก Classes อื่นๆ เพื่อทำงาน Generate Payroll ต้อง Access Database และ Write File หลัง Generate Report ด้วย SRP เราพยายามให้ Class เดียวมี Job เดียว แต Database Read และ File Write ต้องทำใน Class เดียวกัน
สำคัญที่จะ Remove Dependencies และ Decouple Main Business Logic จะช่วย Code Fluid ระหว่าง Change และ Change กลายเป็น Predictable Dependency ต้อง Invert Caller ของ Module ควร Control Dependency ใน Payslip Generator Dependency คือ Data Source สำหรับ Report Code ควร Organize ให้ Caller ระบุ Source ได้ Control ของ Dependency ต้อง Invert และแก้ไขได้ง่ายโดย Caller

ในตัวอย่าง ContractorPayslipGenerator Module Control Dependency การ决定 อ่าน Data ที่ไหน และ Store Output ยังไง Controlled โดย Class ให้ Invert สร้าง UserReader Class อ่าน User Data:
class UserReader
def get
raise "NotImplemented"
end
end
สมมติอยากอ่านจาก Postgres สร้าง Subclass ของ UserReader:
class PostgresUserReader < UserReader
def get
# Code to read data from Postgres
end
คล้ายกัน สร้าง FileUserReader, InMemoryUserReader หรือ Reader Types อื่นๆ ได้ ตอนนี้แก้ FullTimePayslipGenerator ให้ใช้ PostgresUserReader เป็น Dependency:
class FullTimePayslipGenerator < Generator
def initialize(datasource)
@datasource = datasource
end
def generate
# Code to generate payslip
data = datasource.get()
end
end
Caller สามารถ Pass PostgresUserReader เป็น Dependency:
datasource = PostgresUserReader.new()
FullTimePayslipGenerator.new(datasource)
Caller มี Control Over Dependency และเปลี่ยน Source ได้ง่ายตอนต้องการ
Inverting Dependency ไม่ได้แค่ Classes Configurations ก็ต้อง Invert เช่น เชื่อม Postgres ต้อง Configs เฉพาะ: DBURL, Username, Passwords แทน Hardcode ใน Class ให้ Pass Down จาก Caller
class PostgresUserReader < UserReader
def initialize(config)
config = config
end
def get
# initialize DB with the config
self.config
# Code to read data from Postgres
end
end
Provide Config โดย Caller:
config = { url: "url", user: "user" }
datasource = PostgresUserReader.new(config)
FullTimePayslipGenerator.new(datasource)
Caller มี Complete Control Over Dependency Change Management ง่ายและน้อยเจ็บปวด
สรุป
SOLID Design ช่วย Decouple Code ทำให้ Change น้อยเจ็บปวด สำคัญที่จะออกแบบ Programs ให้ Decoupled, Reusable, Responsive to Change ทั้ง 5 Principles เสริมสร้างกัน และควร Co-exist Well-designed Codebase ยืดหยุ่น เปลี่ยนง่าย สนุกต่อการทำงาน Developer ใหม่ Jump in เข้าใจ Code ได้ง่าย
สำคัญมากที่จะเข้าใจว่า SOLID แก้ปัญหา Type ไหน และทำไมเราทำสิ่งนี้ การเข้าใจ Problem ช่วยให้คุณยอมรับ Design Principles และออกแบบ Software ได้ดีขึ้น