bash - staycrafting.dev

~$ cat chu-s-trong-solid-dung-bat-ba-ban-tra-da-kiem-luon-trong-xe.md

Chữ S trong SOLID: đừng bắt bà bán trà đá kiêm luôn trông xe

Chữ S trong SOLID (Single Responsibility): mỗi class chỉ nên có một lý do để thay đổi, sửa chỗ này không hỏng chỗ kia. Mở màn series SOLID quán trà đá.

Đầ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?".

  1. Invoice có các method subtotal, tax, discount, total.
  2. User có full_name, save_to_database, to_json_for_api, send_welcome_email.
  3. PdfExporter có export_invoice, export_report, export_receipt.
  4. StringUtils có slugify, truncate, send_sms.
  5. Report chỉ có đúng một method generate, 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 Manager nà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.

Bạn có muốn xem đáp án không?
[y/]

~$ cd ..