Clean Architecture
03 Phần II · SOLID Chương 7 & 8

SRP & OCP

Single Responsibility · Open-Closed

Hai nguyên lý đầu của SOLID đặt nền cho mọi quyết định kiến trúc sau này. SRP trả lời câu hỏi "chia code ở đâu?" — theo actor, không theo "việc". OCP trả lời câu hỏi "sắp xếp phụ thuộc thế nào?" — để hệ thống mở rộng được mà không phải đụng vào code cũ. Cả hai đều bị hiểu lầm nhiều hơn ta tưởng.

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

Single Responsibility Principle

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

Open-Closed Principle

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."
  • Đó 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.
  • 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()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êngPayCalculator, 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:

Python · tách theo actor
# ✗ 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, 1988

Nó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ệttí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:

Python · mở rộng qua interface, không sửa pipeline
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.

Interactor business rules · cấp cao nhất Controller Database Presenter View mọi phụ thuộc chĩa vào trong →
Mọi quan hệ một chiều, mũi tên hướng về Interactor. Thêm một View mới (vd PDF) không gây thay đổi nào cho business rules ở Interactor — đó là OCP ở tầm component.

Information Hiding

che giấu thông tin

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

hierarchy of protection

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.

NguồnChương 7 (Single Responsibility Principle) & Chương 8 (Open-Closed Principle), Clean Architecture — Robert C. Martin, Prentice Hall 2017.