Đầu ngõ nhà mình có một bà chủ quán trà đá siêu nhân: bán trà, bán thuốc lá, trông xe, bơm lốp, giữ chìa khóa hộ cả xóm, kiêm luôn trạm phát thanh tin đồn. Hôm bà ốm, cả ngõ tê liệt. Không ai uống được trà, không ai lấy được xe, và quan trọng nhất là không ai biết nhà số 7 vừa cãi nhau chuyện gì.
Anh em mở code của mình ra xem, chắc chắn có ít nhất một class đang sống cuộc đời của bà chủ quán này.
Lời dẫn
SOLID là 5 nguyên tắc thiết kế hướng đối tượng, mỗi chữ cái là một nguyên tắc:
| Chữ | Tên đầy đủ | Nói kiểu quán trà đá |
|---|---|---|
| S | Single Responsibility Principle | Mỗi người nghe một sếp |
| O | Open/Closed Principle | |
| L | Liskov Substitution Principle | |
| I | Interface Segregation Principle | |
| D | Dependency Inversion Principle |
Mấy ô trống kia là để dành cho các bài sau, mỗi bài một chữ. Hôm nay chỉ nói chữ S.
Bài này dành cho:
- Anh em mới đi làm, nghe các anh senior nói "SOLID" như nói "ăn cơm chưa" mà chưa dám hỏi lại.
- Anh em đi làm lâu rồi, thuộc định nghĩa, phỏng vấn trả lời trơn tru, nhưng mở code ra vẫn thấy một class 2000 dòng tên là
UserManager. Không phán xét, mình cũng từng như vậy (và có khi vẫn đang).
Trong bài có bài refactor mẫu đi từng bước, và bài tập nhanh ở cuối để anh em tự kiểm tra. Đọc mà không làm thì giống xem video tập gym: rất có cảm giác, nhưng bụng vẫn thế.
1. S là gì?
Single Responsibility Principle (SRP), nguyên tắc đơn trách nhiệm. Câu kinh điển của Robert C. Martin (bác Bob, Uncle Bob):
Một class chỉ nên có một lý do để thay đổi.
Nghe thì gọn, nhưng "lý do để thay đổi" là cái gì? Về sau chính bác Bob nói rõ hơn (trong bài blog The Single Responsibility Principle năm 2014, rồi sách Clean Architecture năm 2017): lý do để thay đổi chính là con người. Một module chỉ nên phục vụ một nhóm người (bác gọi là actor), tức là chỉ một nhóm người có thể bắt nó phải sửa.
Dịch cabin: mỗi khi nhìn một class, anh em tự hỏi:
"Ai sẽ là người bắt mình sửa cái class này?"
- Chỉ có một đáp án (kế toán, hoặc team marketing, hoặc ông DevOps...): ngon, đúng S.
- Có ba bốn đáp án khác nhau: class này đang ôm nhiều trách nhiệm. Bà chủ quán trà đá đây rồi.
Hiểu nhầm phổ biến: "mỗi class chỉ làm một việc"
Đây là cách hiểu sai phổ biến nhất. S không có nghĩa là:
- Mỗi class chỉ có một method.
- Mỗi class chỉ được dài 50 dòng.
- Thấy cái gì cũng phải tách ra class riêng.
Ví dụ class Calculator có add, subtract, multiply, divide: bốn method, nhưng chỉ một lý do để thay đổi (luật toán học, mà luật này thì mấy nghìn năm nay chưa đổi). Hoàn toàn đúng S.
Ngược lại, một class chỉ có đúng một method run dài 300 dòng, bên trong vừa đọc file, vừa tính tiền, vừa gửi email: một method thôi nhưng vi phạm S sấp mặt.
Kết luận phần này: đếm số lý do thay đổi, không đếm số method.
2. Chuyện đời thường: quán trà đá và quán cà phê chuỗi
Quay lại bà chủ quán. Hãy xem ai có thể "yêu cầu thay đổi" bà:
| Ai | Yêu cầu gì |
|---|---|
| Khách uống trà | Ít đá thôi bà ơi. Có trà chanh không? |
| Chủ xe | Gửi xe qua đêm tính bao nhiêu? |
| Hàng xóm | Bà giữ hộ cái chìa khóa, chiều con tôi đi học về lấy |
| Phường | Dẹp xe gọn vào, đừng lấn vỉa hè |
Bốn nhóm người, bốn lý do thay đổi, dồn hết lên một người. Hậu quả:
- Một thay đổi kéo theo cái khác: phường bắt dẹp xe, bà dọn xe vào thì chỗ ngồi uống trà mất một nửa. Sửa chỗ này, hỏng chỗ kia.
- Không thay thế được: bà ốm là cả hệ thống sập. Muốn thuê người làm thay thì người đó phải biết hết mọi thứ.
- Khó kiểm tra: muốn biết trà hôm nay có ngon không, phải đợi bà rảnh tay giữa lúc bơm lốp và trả chìa khóa.
Giờ nhìn sang quán cà phê chuỗi: có barista pha chế, thu ngân thu tiền, bác bảo vệ trông xe. Thu ngân nghỉ, bác bảo vệ vẫn trông xe bình thường. Đổi giá gửi xe, barista không cần biết. Mỗi người chỉ nghe lệnh từ một nhóm.
Đó chính là chữ S. Không có gì cao siêu, chỉ là phân công lao động, thứ mà người ta đã hiểu từ thời chưa có máy tính.
Mẹo nhỏ: Thử mô tả class của anh em trong một câu, không được dùng chữ "và". Nếu phải viết "class này đọc dữ liệu và tính tiền và gửi email", thì anh em đã có câu trả lời.
3. Vi phạm S thì đã sao? Vẫn chạy mà
Đúng, vẫn chạy. Bà chủ quán vẫn bán trà mỗi ngày. Vấn đề không nằm ở hôm nay, mà ở lần sửa tiếp theo:
- Sửa một chỗ, hỏng chỗ không liên quan. Team marketing nhờ đổi tiêu đề email, anh em sửa xong thì báo cáo tính tiền sai. Không ai hiểu tại sao, trừ cái class 800 dòng kia.
- Test khổ như đi cày không có ruộng. Muốn test đúng cái hàm tính tổng tiền, phải chuẩn bị file thật, database thật, chặn gửi email thật. Lỡ tay chạy test là sếp nhận 50 email hóa đơn. Xin chúc mừng, anh em vừa được sếp nhớ mặt.
- Git conflict liên miên. Ba người ba task khác nhau nhưng cùng sửa một file. Mỗi lần merge là một lần ngồi giải sớ táo quân.
- Đọc không nổi. Người mới vào dự án mở file ra, cuộn chuột mỏi tay mà chưa thấy đáy.
Dấu hiệu nhận biết class đang ôm đồm
- Tên class chung chung:
Manager,Helper,Utils,Processor,Service(không có gì phía trước). - Mô tả class phải dùng chữ "và" (xem mẹo phía trên).
- Một file
import/requiređủ thứ: database, HTTP client, thư viện email, thư viện PDF... - Test của class phải mock 5-6 thứ mới chạy được.
- Lịch sử git của file có commit từ team frontend, team backend, team data, và cả ông sếp.
4. Bài refactor: báo cáo hóa đơn trà đá
Đủ lý thuyết, giờ vào code. Code bằng Ruby (vì mình chỉ biết mỗi Ruby, như đã khai từ series trước), nhưng ý tưởng thì ngôn ngữ nào cũng y hệt. Anh em code Java, PHP, Python, TypeScript cứ đọc như đọc mã giả.
Bối cảnh lấy luôn từ series Blog trà đá (bài tính tiền sau 1 tuần có đủ bảng hóa đơn): mỗi tháng mình export file CSV billing từ GCP, muốn có một script tính tổng tiền, quy đổi ra số ly trà đá, rồi gửi email báo cáo cho mình.
4.1. Code ban đầu: một mình cân hết
require "csv"
class MonthlyBillReport
TEA_PRICE = 3_000 # giá 1 ly trà đá, VNĐ
def initialize(csv_path, email)
@csv_path = csv_path
@email = email
end
def run
# 1. Đọc dữ liệu từ file CSV export của GCP
rows = CSV.read(@csv_path, headers: true)
costs = rows.map do |row|
{ service: row["Service description"], amount: row["Cost"].to_f }
end
# 2. Tính toán
total = costs.sum { |c| c[:amount] }
tea_cups = total.fdiv(TEA_PRICE).ceil
# 3. Dựng nội dung báo cáo dạng bảng Markdown
lines = ["| Dịch vụ | Tiền |", "|---|---|"]
costs.each { |c| lines << "| #{c[:service]} | #{c[:amount].round}đ |" }
lines << "| **Tổng** | **#{total.round}đ** (#{tea_cups} ly trà đá) |"
body = lines.join("\n")
# 4. Gửi email qua Brevo (BrevoClient: wrapper tự viết, giả định)
BrevoClient.new(api_key: ENV["BREVO_API_KEY"]).send_email(
to: @email,
subject: "Hóa đơn tháng này: #{tea_cups} ly trà đá",
body: body
)
end
end
Nhìn qua thì gọn gàng, có comment đánh số hẳn hoi, chạy tốt. Code kiểu này có mặt ở mọi dự án, kể cả dự án của mình.
Nhưng để ý mấy cái comment # 1., # 2., # 3., # 4.. Khi anh em phải viết comment để chia một method thành các "phần", đó thường là cái class đang tự thú nó làm nhiều việc. Code không nói dối, comment thì đôi khi. Riêng mấy comment đánh số này thì thật thà đến đáng thương.
4.2. Bước 1: liệt kê lý do thay đổi
Trước khi tách, đừng vội tạo class mới. Ngồi liệt kê xem ai/điều gì có thể bắt mình sửa từng phần:
| Phần code | Ai bắt sửa | Ví dụ thay đổi |
|---|---|---|
| Đọc CSV | Google (định dạng file export) | Đổi tên cột Cost thành Cost (VND) |
| Tính toán | Luật tiền nong của mình | Trà đá lên giá 5k; muốn trừ phần free tier |
| Dựng nội dung | Người đọc báo cáo | Muốn bảng HTML cho đẹp, hoặc thêm biểu đồ |
| Gửi đi | Kênh nhận | Chuyển từ email sang Slack; Brevo hết quota 300 mail/ngày |
Bốn lý do thay đổi, từ bốn nguồn khác nhau. Một class, bốn ông chủ. Bà chủ quán trà đá phiên bản Ruby.
4.3. Bước 2: tách mỗi lý do thành một class
Đọc dữ liệu: chỉ biết file CSV của GCP trông thế nào
require "csv"
Cost = Struct.new(:service, :amount, keyword_init: true)
class GcpBillingCsvReader
def initialize(path)
@path = path
end
def costs
CSV.read(@path, headers: true).map do |row|
Cost.new(service: row["Service description"], amount: row["Cost"].to_f)
end
end
end
Mình thêm Cost làm kiểu dữ liệu chung. Từ giờ các class khác chỉ làm việc với Cost, không cần biết dữ liệu đến từ CSV, từ API hay từ trên trời rơi xuống.
Tính toán: chỉ biết luật tiền nong
class BillSummary
TEA_PRICE = 3_000
attr_reader :costs
def initialize(costs)
@costs = costs
end
def total
costs.sum(&:amount)
end
def tea_cups
total.fdiv(TEA_PRICE).ceil
end
end
Đây là phần quý nhất của cả chương trình: logic nghiệp vụ. Nó không đọc file, không gọi mạng, không biết email là gì. Sạch như ly trà đá vừa rót.
Trình bày: chỉ biết vẽ bảng Markdown
class MarkdownBillFormatter
def render(summary)
lines = ["| Dịch vụ | Tiền |", "|---|---|"]
summary.costs.each { |c| lines << "| #{c.service} | #{c.amount.round}đ |" }
lines << "| **Tổng** | **#{summary.total.round}đ** (#{summary.tea_cups} ly trà đá) |"
lines.join("\n")
end
end
Gửi đi: chỉ biết nói chuyện với Brevo
class EmailBillSender
def initialize(to:, client: BrevoClient.new(api_key: ENV["BREVO_API_KEY"]))
@to = to
@client = client
end
def deliver(subject:, body:)
@client.send_email(to: @to, subject: subject, body: body)
end
end
4.4. Bước 3: class gốc chỉ còn làm nhạc trưởng
class MonthlyBillReport
def initialize(reader:, formatter:, sender:)
@reader = reader
@formatter = formatter
@sender = sender
end
def run
summary = BillSummary.new(@reader.costs)
@sender.deliver(
subject: "Hóa đơn tháng này: #{summary.tea_cups} ly trà đá",
body: @formatter.render(summary)
)
end
end
MonthlyBillReport giờ không tự đọc, không tự tính, không tự gửi. Nó chỉ điều phối: gọi đúng người, đúng việc, đúng thứ tự. Giống quản lý quán cà phê: không pha cà phê, không trông xe, chỉ đảm bảo mọi người làm đúng phần mình.
Nó vẫn còn một lý do để thay đổi: quy trình (thứ tự các bước, tiêu đề email). Và thế là đúng, vì đó chính là trách nhiệm của nó.
Cách dùng:
MonthlyBillReport.new(
reader: GcpBillingCsvReader.new("billing-2026-09.csv"),
formatter: MarkdownBillFormatter.new,
sender: EmailBillSender.new(to: "hello@staycrafting.dev")
).run
4.5. Thử thách: giờ có yêu cầu thay đổi thì sao?
| Yêu cầu | Trước refactor | Sau refactor |
|---|---|---|
| Trừ phần free tier trước khi quy ra ly trà | Chen vào giữa run, ngay giữa đoạn tính tổng và đoạn dựng bảng; muốn thử phải chạy cả script, đọc CSV thật, gửi email thật |
Sửa BillSummary#total, có test riêng bảo vệ |
| Gửi báo cáo qua Slack thay email | Sửa thẳng vào run, vô tình xóa mất phần tính tiền |
Viết thêm SlackBillSender, chỉ đổi chỗ khởi tạo (*) |
| Google đổi tên cột CSV | Sửa trong run, test lại toàn bộ |
Sửa GcpBillingCsvReader, xong |
| Muốn báo cáo HTML | Viết lại phần 3, cạnh phần 4, hồi hộp | Thêm HtmlBillFormatter, chọn cái nào lúc khởi tạo (*) |
(*) Thành thật khai báo: khoản "viết thêm class mới, không đụng code cũ" này là công của chữ O, còn trò truyền reader, formatter, sender qua constructor là chữ D. Chữ S chỉ làm một việc: chia đúng chỗ, để mấy chữ sau có chỗ mà cắm. Hẹn anh em ở các bài sau.
Và phần ngon nhất: test.
require "minitest/autorun"
require_relative "bill_summary" # file chứa Cost và BillSummary
class BillSummaryTest < Minitest::Test
def test_quy_doi_ra_ly_tra_da
summary = BillSummary.new([
Cost.new(service: "Cloud Run", amount: 6_945),
Cost.new(service: "Artifact Registry", amount: 11_213)
])
assert_equal 18_158, summary.total
assert_equal 7, summary.tea_cups
end
end
Không file CSV, không API key, không gửi email nhầm cho sếp. Test chạy trong nháy mắt, nhanh như người yêu cũ lướt qua đời bạn.
4.6. So sánh trước và sau
| Trước | Sau | |
|---|---|---|
| Số class | 1 | 5 (+ 1 Struct) |
| Số lý do thay đổi mỗi class | 4 | 1 |
| Test logic tính tiền | Cần file thật + chặn email | Truyền mảng vào là xong |
| Thêm kênh gửi mới | Sửa code cũ | Viết code mới, code cũ để yên (công của chữ O) |
| Số dòng code | Ít hơn | Nhiều hơn một chút |
Thành thật khai báo: số dòng code tăng lên, số file tăng lên. Đó là cái giá. Đổi lại, mỗi lần sửa anh em chỉ cần mở đúng một file nhỏ, đọc trong 30 giây là hiểu.
5. Đừng tách quá tay
Học xong S, rất nhiều anh em (bao gồm mình ngày xưa) lên đồng, tách mọi thứ: mỗi dòng một class, mỗi class một file, thư mục sâu 7 tầng. Kết quả là đọc một chức năng phải mở 15 file, nhảy qua nhảy lại như lướt TikTok. Người ta gọi kiểu này là ravioli code, theo tên món mì Ý nhân nhỏ: như đĩa sủi cảo, viên nào cũng nhỏ xinh, ăn cả đĩa vẫn chưa no.
Vài nguyên tắc để không quá tay:
- Tách khi có lý do thay đổi thật, không tách vì "biết đâu sau này". Script chạy một lần rồi vứt thì cứ viết một cục, không ai trách.
- Đợi đau rồi mới tách cũng được. Lần đầu viết gộp không sao. Đến lần thứ hai, thứ ba phải sửa và thấy đau, đó là lúc tách.
- Hai thứ luôn thay đổi cùng nhau thì để cùng nhau. Tách chúng ra chỉ làm mỗi lần sửa phải mở hai file.
S là để code dễ sửa hơn, không phải để code trông kiến trúc hơn. Một class AbstractBillReportGeneratorFactoryProvider không làm anh em giỏi hơn, chỉ làm đồng nghiệp ghét anh em hơn. Enterprise-grade, cloud-native, metaverse-ready, AI-native, nhưng không ai dám sửa.
Nói riêng với anh em Rails
Rails có một cái bẫy kinh điển: fat model. Model User vừa validate, vừa callback gửi email chào mừng, vừa tạo avatar, vừa đồng bộ sang CRM. Mỗi lần User.create trong test là một lần cầu nguyện.
Tin vui là Rails đã chuẩn bị sẵn chỗ để tách:
- Gửi mail → Mailer
- Việc chạy nền → Job
- Logic nghiệp vụ nhiều bước → service object (class Ruby thuần trong
app/services) - Định dạng dữ liệu trả ra → serializer, presenter, view
Chỗ có sẵn rồi, chỉ cần chịu khó dọn vào.
6. Bài tập nhanh
Đến lượt anh em. Một bài khởi động nhẹ, không cần mở editor, chỉ cần một ly trà đá: đọc từng tình huống và đoán xem đúng hay sai. Bài tập sửa code dài hơn sẽ gom vào một bài tổng ôn riêng của series.
Cố tự làm trước rồi hãy xem lời giải. Xem lời giải trước khi làm thì giống chép đáp án đề thi thử: cảm giác rất giỏi, cho đến hôm thi thật.
Class nào vi phạm S? Giải thích bằng câu hỏi "ai bắt sửa?".
Invoicecó các methodsubtotal,tax,discount,total.Usercófull_name,save_to_database,to_json_for_api,send_welcome_email.PdfExportercóexport_invoice,export_report,export_receipt.StringUtilscóslugify,truncate,send_sms.Reportchỉ có đúng một methodgenerate, dài 300 dòng: query database, tính toán, vẽ biểu đồ, xuất file Excel.
Túm cái váy lại
| Ghi nhớ | |
|---|---|
| Định nghĩa | Một class chỉ có một lý do để thay đổi |
| Câu hỏi thần chú | "Ai sẽ bắt mình sửa class này?" |
| Mẹo nhanh | Mô tả class không được dùng chữ "và" |
| Hiểu nhầm | Đếm số method, đếm số dòng. Sai, phải đếm lý do thay đổi |
| Lợi ích | Sửa chỗ nào đúng chỗ đó, test dễ, ít conflict |
| Cái giá | Nhiều file hơn, nhiều class hơn |
| Đừng | Tách vụn như ravioli khi chưa có lý do |
Sau bài này, anh em đã sở hữu:
- Một câu hỏi để soi mọi class: ai bắt sửa?
- Một quy trình refactor 3 bước: liệt kê lý do, tách theo lý do, để class gốc làm nhạc trưởng.
- Và hy vọng là một class
Managernào đó trong dự án sắp được về hưu.
Chúc anh em code dễ sửa, test chạy xanh, và trà luôn đủ đá. Hẹn anh em ở chữ O.
À mà, nếu anh em đang định comment "class 2000 dòng của em vẫn chạy ngon mà", thì mình tin. Bà chủ quán trà đá cũng chạy ngon được hai chục năm rồi. Cho đến hôm bà ốm.