01 SOLID là gì & hai nguyên lý hôm nay
SOLID là tập 5 nguyên lý hướng dẫn cách sắp xếp hàm và cấu trúc dữ liệu vào các lớp, rồi liên kết các lớp đó sao cho phần mềm chịu đựng được thay đổi, dễ hiểu và tái sử dụng được như component. Chúng hoạt động ở "tầm giữa" — không phải code từng dòng, cũng chưa phải kiến trúc toàn hệ. Trang này gói hai nguyên lý đầu:
S · SRP
Một module chỉ nên có một lý do để thay đổi — tức chỉ chịu trách nhiệm trước một actor. Đây là nguyên lý về sự cố kết.
O · OCP
Mở để mở rộng, đóng để sửa đổi. Đây là nguyên lý về hướng phụ thuộc — lý do nền tảng nhất khiến ta học kiến trúc.
Đừng đọc SOLID như "mẹo viết code đẹp". Mục tiêu cuối cùng là tạo ra những module cấp trung chịu được thay đổi, dễ hiểu và dùng lại được — đúng tinh thần kiến trúc.
02 SRP — phát biểu đúng
Trong cả 5 nguyên lý SOLID, SRP bị hiểu nhầm nhiều nhất, phần lớn vì cái tên dễ gây ngộ nhận. Rất nhiều lập trình viên nghe "single responsibility" rồi vội kết luận:
Hiểu lầm phổ biến
- "Một module chỉ nên làm một việc."
- Đó là một nguyên lý có thật — nhưng dành cho hàm khi refactor hàm lớn thành hàm nhỏ ở cấp thấp.
- Nó không phải SRP của SOLID.
Phát biểu đúng
- "Một module chỉ nên có MỘT, và chỉ một, lý do để thay đổi."
- "Lý do để thay đổi" = người/nhóm yêu cầu thay đổi ⟶ gọi là actor.
- Bản cuối: "Một module chịu trách nhiệm trước một, và chỉ một, actor."
Actor = một lý do để thay đổi. Một actor là một người hoặc một nhóm có cùng nhu cầu thay đổi (vd: phòng kế toán dưới quyền CFO). Cohesion (sự cố kết) chính là lực gắn kết phần code phục vụ cùng một actor lại với nhau — đó là linh hồn của SRP.
Lưu ý "module" ở đây hiểu đơn giản nhất là một file mã nguồn; với ngôn ngữ không tổ chức theo file thì là một tập hàm và cấu trúc dữ liệu gắn bó với nhau.
03 Triệu chứng vi phạm & cách sửa
Cách tốt nhất để hiểu SRP là nhìn vào triệu chứng khi vi phạm. Ví dụ kinh điển: lớp Employee trong một ứng dụng tính lương, gom 3 phương thức phục vụ 3 actor hoàn toàn khác nhau.
flowchart TD CFO["CFO · Kế toán"]:::act -->|calculatePay| E COO["COO · Nhân sự"]:::act -->|reportHours| E CTO["CTO · DBA"]:::act -->|save| E E["class Employee
(một file · ba lý do để đổi)"]:::bad E -.dùng chung.-> R["regularHours()
thuật toán giờ thường"]:::shared classDef act fill:#efe7f7,stroke:#7a4fb0,color:#1c1a14; classDef bad fill:#f4dcd5,stroke:#b23a2e,color:#1c1a14; classDef shared fill:#fcf6ec,stroke:#c08a2e,color:#1c1a14;
Employee vi phạm SRP: ba phương thức chịu trách nhiệm trước ba actor. Lớp này có ba lý do để thay đổi nằm chen chúc trong một file.Triệu chứng 1 · Accidental Duplication
accidental duplication Giả sử calculatePay() và reportHours() cùng gọi một hàm nội bộ regularHours(). Đội CFO yêu cầu chỉnh cách tính giờ thường; lập trình viên sửa regularHours(), test kỹ theo yêu cầu CFO, deploy. Nhưng họ không để ý hàm đó cũng được reportHours() (của COO) gọi:
Báo cáo của đội COO âm thầm sai dữ liệu. Đến khi phát hiện, dữ liệu hỏng đã ngốn của họ hàng triệu đô. Gốc rễ: code mà các actor khác nhau phụ thuộc bị đặt sát nhau — SRP yêu cầu tách chúng.
Triệu chứng 2 · Merge Conflicts
Đội DBA (CTO) muốn đổi schema bảng Employee; cùng lúc đội HR (COO) muốn đổi định dạng báo cáo giờ. Hai lập trình viên từ hai đội cùng checkout lớp Employee và sửa — thay đổi va nhau, sinh ra một cuộc merge đầy rủi ro đặt cả CTO lẫn COO (và có thể cả CFO) vào nguy hiểm. Lại cùng một nghiệm: tách code phục vụ actor khác nhau ra.
Cách sửa · tách hàm khỏi dữ liệu + Facade
Tách dữ liệu khỏi hàm
Đưa ba phương thức vào ba lớp riêng — PayCalculator, HourReporter, EmployeeSaver — cùng chia sẻ một cấu trúc dữ liệu thuần EmployeeData (không có method).
Ba lớp không biết nhau
Các lớp không được biết về nhau ⟶ mọi accidental duplication bị triệt tiêu, mỗi lớp chỉ còn một lý do để thay đổi.
Bọc bằng Facade
EmployeeFacade chứa rất ít code: chỉ khởi tạo & ủy quyền (delegate) tới đúng lớp chức năng, để bên gọi không phải quản lý ba đối tượng. (Biến thể: giữ method quan trọng nhất trong Employee gốc, dùng chính nó làm Facade cho các hàm phụ.)
SRP qua lăng kính RAG
RAG Một pipeline RAG cũng có nhiều actor: đội vận hành quyết khi nào nạp & re-index tài liệu; người dùng cuối quyết cách truy hồi & sinh câu trả lời. Gom hết vào một lớp RagService = đúng cái bẫy Employee:
# ✗ Một lớp, ba lý do để đổi (vận hành · người hỏi · cấu hình store)
class RagService:
def index(self, docs): ... # actor: đội vận hành
def retrieve(self, q): ... # actor: người dùng cuối
def answer(self, q): ... # actor: người dùng cuối
# ✓ Tách theo actor — chia sẻ một VectorStore thuần dữ liệu
class Indexer: # đổi khi quy trình nạp/chunk đổi
def __init__(self, store): self.store = store
def index(self, docs): ...
class Retriever: # đổi khi chiến lược truy hồi đổi
def __init__(self, store): self.store = store
def search(self, q): ...
class AnswerGenerator: # đổi khi prompt/model đổi
def generate(self, q, ctx): ...
Indexer phục vụ đội vận hành, Retriever/AnswerGenerator phục vụ người hỏi. Sửa cách chunk lại không còn vô tình làm hỏng đường truy hồi.04 OCP — mở để mở rộng, đóng để sửa đổi
Một software artifact nên mở để mở rộng, nhưng đóng để sửa đổi.
Bertrand Meyer, 1988Nói cách khác: hành vi của hệ thống phải kéo dài được mà không cần đụng vào code cũ. Nếu một thay đổi yêu cầu nhỏ lại buộc phải sửa khối lượng code khổng lồ, thì kiến trúc sư đã thất bại. Đây chính là lý do nền tảng nhất để ta nghiên cứu kiến trúc phần mềm.
Thử nghiệm tư duy · hệ thống báo cáo tài chính
Một hệ thống hiển thị báo cáo tài chính trên web (cuộn được, số âm tô đỏ). Nay cần bản in trên giấy: có header/footer, đánh số trang, số âm để trong ngoặc đơn. Phải viết thêm code — nhưng bao nhiêu code cũ phải đổi? Kiến trúc tốt sẽ giảm con số đó về mức tối thiểu, lý tưởng là số 0.
Mấu chốt: sinh báo cáo gồm hai trách nhiệm tách biệt — tính toán dữ liệu (do Interactor đảm nhận) và trình bày dữ liệu (do Presenter/View đảm nhận). Áp SRP để tách, rồi sắp phụ thuộc đúng hướng (DIP) để hai trách nhiệm này không kéo theo nhau.
RAG Tương tự, muốn thêm một reranker hay một loader định dạng mới (PDF, HTML…) vào pipeline RAG mà không sửa pipeline cũ — ta mở rộng qua một interface, đúng tinh thần OCP:
class Reranker(Protocol): # điểm mở rộng (open)
def rerank(self, q, docs) -> list: ...
class Pipeline: # đóng để sửa đổi (closed)
def __init__(self, retriever, reranker: Reranker, gen):
self.retriever, self.reranker, self.gen = retriever, reranker, gen
def answer(self, q):
docs = self.reranker.rerank(q, self.retriever.search(q))
return self.gen.generate(q, docs)
# Thêm hành vi mới = thêm lớp, KHÔNG chạm Pipeline
class CrossEncoderReranker: def rerank(self, q, docs): ...
class NoopReranker: def rerank(self, q, docs): return docs
Pipeline đóng với sửa đổi, mở với mở rộng: gắn reranker mới chỉ là truyền một implementation khác vào — code điều phối không thay đổi một dòng.05 Hướng phụ thuộc & phân cấp bảo vệ
OCP ở tầm kiến trúc đạt được bằng cách chia hệ thống thành component và sắp các component đó vào một cây phụ thuộc (dependency hierarchy) sao cho component cấp cao được bảo vệ khỏi thay đổi của component cấp thấp. Quy tắc vàng gói gọn trong một câu:
Nếu component A cần được bảo vệ khỏi thay đổi của component B, thì B phải phụ thuộc vào A. Mọi mũi tên phụ thuộc đều chĩa về phía thứ ta muốn bảo vệ — và đều một chiều.
Information Hiding
Interface như FinancialReportRequester bảo vệ Controller khỏi biết quá nhiều nội bộ của Interactor. Thiếu nó, Controller sẽ phụ thuộc bắc cầu (transitive) vào những thực thể nó không trực tiếp dùng — điều cần tránh.
Phân cấp Level
OCP tạo ra phân cấp bảo vệ theo "level": Interactor cấp cao nhất ⟶ bảo vệ tối đa; View cấp thấp nhất ⟶ bảo vệ ít nhất; Presenter ở giữa. Cấp càng cao càng được chắn khỏi thay đổi của cấp thấp.
06 Ghi nhớ nhanh
SRP = một actor, không phải "một việc". Module chỉ nên có một lý do để thay đổi; tách code mà các actor khác nhau phụ thuộc.
Vi phạm SRP lộ ra qua accidental duplication & merge conflict. Sửa bằng cách tách hàm khỏi dữ liệu rồi gói lại sau một Facade.
OCP = mở để mở rộng, đóng để sửa đổi. Thêm hành vi mới chỉ nên là thêm code, không phải sửa code cũ.
Nếu A cần được bảo vệ khỏi B, thì B phụ thuộc A. Sắp hướng phụ thuộc để business rules (Interactor) được chắn khỏi UI & database.
Dùng interface để che giấu thông tin — chặn phụ thuộc bắc cầu, dựng nên phân cấp bảo vệ theo level.