bash - staycrafting.dev

~$ cat chu-o-trong-solid-them-mon-moi-ma-khong-phai-son-lai-ca-cai-bien.md

Chữ O trong SOLID: thêm món mới mà không phải sơn lại cả cái biển

Chữ O trong SOLID (Open/Closed): thêm tính năng mới bằng cách viết code mới, không phải mổ code cũ đang chạy ngon. Bài thứ hai series SOLID quán trà đá.

Bà chủ quán trà đá đầu ngõ (sau bài trước, bà đã thuê thêm người trông xe) muốn bán thêm trà chanh. Bà gọi thợ đến sơn lại cái biển trên tường. Thợ sơn xong chữ "TRÀ CHANH 10K" rất đẹp, tiện tay sơn đè luôn lên số điện thoại gọi giữ xe. Món mới lên biển, dịch vụ cũ ra đi.

Anh em nào từng thêm một tính năng nhỏ rồi làm hỏng một tính năng chẳng liên quan, xin mời ngồi xuống đây, mình gọi thêm ly trà.

Lời dẫn

Đây là bài thứ hai của series SOLID, mỗi bài một chữ:

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 Thêm món thì gài thêm thẻ, đừng sơn lại biển
L Liskov Substitution Principle
I Interface Segregation Principle
D Dependency Inversion Principle

Bài trước về chữ S nói chuyện chia việc: mỗi class chỉ nên có một lý do để thay đổi. Hôm nay chữ O nói chuyện tiếp theo: khi phải thay đổi, thay đổi bằng cách nào để không làm hỏng cái đang chạy.

Chưa đọc bài S cũng không sao, bài này đọc riêng vẫn hiểu. Nhưng đọc rồi thì sẽ thấy hai chữ này đi với nhau như trà đá với hạt hướng dương.

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.

1. O là gì?

Open/Closed Principle (OCP), nguyên tắc đóng/mở. Ý gốc của Bertrand Meyer, trong sách Object-Oriented Software Construction năm 1988 (nguyên văn của ông là "Modules should be both open and closed"). Cách phát biểu quen thuộc nhất hiện nay đến từ bài viết năm 1996 của bác Bob (Robert C. Martin):

Một module nên mở cho việc mở rộng, nhưng đóng cho việc sửa đổi.

Nghe như câu đố. Vừa mở vừa đóng là sao? Quán đóng cửa nhưng vẫn bán qua khe cửa à?

Hồi đó Meyer tính chuyện mở rộng bằng kế thừa. Về sau bác Bob phổ biến cách hiểu hiện nay: mở rộng bằng cách cắm thêm các class cùng chung một "phích cắm". Bài này đi theo cách hiểu sau.

Dịch cabin: khi có yêu cầu mới, anh em thêm tính năng bằng cách viết code mới, chứ không phải mổ code cũ ra sửa.

  • Mở cho mở rộng: muốn thêm một loại mới (món mới, khuyến mãi mới, kênh thanh toán mới...) thì thêm được.
  • Đóng cho sửa đổi: phần code đang chạy ngon, đã test, đã lên production thì để yên, không phải đụng vào.

Tại sao phải khổ vậy? Vì code cũ là code đã được kiểm chứng. Nó đã sống sót qua test, qua review, qua cả trăm ngàn request thật. Mỗi lần mổ ra sửa là một lần có thể sơn đè lên số điện thoại giữ xe.

Hiểu nhầm phổ biến

"Đóng cho sửa đổi" nghĩa là cấm sửa code cũ. Không phải. Có bug thì cứ sửa, đổi một luật đang có thì cứ sửa. O chỉ nói về một tình huống cụ thể: khi thêm một biến thể mới của thứ đã có (thêm loại, thêm kênh, thêm một luật cùng kiểu với các luật đã có), thì không nên phải sửa những chỗ đang xử lý các biến thể cũ.

"Phải thiết kế để mở rộng được mọi thứ ngay từ đầu." Cũng không phải. Không ai đoán được tương lai, kể cả sếp. Mở rộng mọi thứ thì code thành một cái mê cung toàn interface, factory, plugin mà chẳng ai cắm gì vào. Chỉ mở ở chỗ thực sự hay thay đổi. Mục 6 sẽ nói kỹ.

Kết luận phần này: thêm biến thể mới thì viết thêm, không sửa lại.

2. Chuyện đời thường: biển sơn và bảng gài thẻ

Quay lại quán trà đá. Có hai kiểu làm menu:

Biển sơn lên tường Bảng gài thẻ
Thêm món mới Gọi thợ sơn lại, cả biển Viết một tấm thẻ, gài vào
Bỏ món hết hàng Sơn đè, để lại vết loang Rút thẻ ra
Rủi ro Sơn nhầm, sơn lem, sơn đè số điện thoại Gài nhầm chỗ thì gài lại
Món cũ có bị ảnh hưởng không? Có, vì cả biển là một khối Không, mỗi món một thẻ

Cái bảng gài thẻ chính là chữ O. Cái khung bảng thì đóng: không ai phải sửa nó. Cái khe gài thì mở: thẻ nào đúng kích thước thì gài vào được.

Một ví dụ nữa trong nhà anh em: ổ cắm điện. Mua thêm cái nồi chiên không dầu, anh em không phải gọi thợ đục tường đi dây lại. Chỉ cần nồi có phích cắm đúng chuẩn là cắm vào chạy. Tường đóng, ổ cắm mở. Cái phích cắm chuẩn, trong code, gọi là interface (hay trong Ruby là "biết trả lời đúng method").

Mẹo nhỏ: Mỗi lần thêm tính năng, đếm xem phải sửa bao nhiêu file cũ và thêm bao nhiêu file mới. Nếu thêm một loại mới mà phải sửa 5 file cũ, chỗ đó đang là biển sơn.

3. Vi phạm O thì đã sao?

Vẫn chạy được, như mọi lần. Cái giá nằm ở lần thêm tiếp theo:

  • Thêm cái mới, hỏng cái cũ. Anh em chen thêm một khối if cho khuyến mãi mới, lỡ tay viết discount = ... thay vì discount += ..., thế là mấy khuyến mãi phía trên bị xóa sạch. Phát hiện ra khi khách quen chửi ở quầy.
  • Test lại từ đầu. Sửa vào một hàm dùng chung thì mọi tính năng đi qua hàm đó đều phải test lại. QA nhìn anh em bằng ánh mắt của người sắp phải tăng ca.
  • Sửa một chỗ, quên ba chỗ. Cùng một case theo loại xuất hiện ở 4 file. Thêm loại mới, sửa được 3 file, quên file thứ 4. Lên production mới biết. Người ta gọi kiểu này là shotgun surgery: bắn một phát, mảnh văng khắp nơi.
  • Conflict liên miên. Hai người hai tính năng khác nhau nhưng cùng thêm nhánh vào một case. Merge xong là ngồi gỡ.

Dấu hiệu nhận biết

  • Chuỗi if/elsif hoặc case theo "loại" (type, kind, status, provider...) ngày càng dài.
  • Cùng một case như vậy lặp ở nhiều nơi.
  • Mỗi lần thêm loại mới, commit đụng vào hàng loạt file cũ.
  • Có comment kiểu # TODO: nhớ thêm vào đây khi có loại mới. Comment này nói thật đấy, nhưng là lời trăn trối của người viết trước.

4. Bài refactor: khuyến mãi quán trà đá

Code bằng Ruby như bài trước. Anh em ngôn ngữ khác cứ đọc như mã giả, ý tưởng y hệt.

Bối cảnh: quán trà đá giờ có phần mềm tính tiền. Bộ phận marketing (là cô con gái bà chủ, mới học xong khóa digital marketing) tuần nào cũng nghĩ ra khuyến mãi mới.

4.1. Code ban đầu: một hàm, một chuỗi if

class CheckoutCalculator
  def total(items, customer:, time:)
    subtotal = items.sum { |i| i[:price] * i[:qty] }
    discount = 0

    # Giờ vàng 14h-16h: giảm 20%
    if (14...16).cover?(time.hour)
      discount += subtotal * 20 / 100
    end

    # Khách quen: giảm thẳng 2k
    if customer[:regular]
      discount += 2_000
    end

    # Thứ Hai, mua từ 3 ly: tặng 1 ly trà đá (giảm 3k)
    if time.monday? && items.sum { |i| i[:qty] } >= 3
      discount += 3_000
    end

    [subtotal - discount, 0].max
  end
end

Để ý: soi theo chữ S thì class này không tệ. Cách tính tổng tiền và luật "không trả tiền âm" gần như không bao giờ đổi, phần duy nhất hay bị đòi sửa là khuyến mãi, và chỉ bộ phận marketing đòi. Nhưng nó vẫn có vấn đề. Ổn ở S mà sai ở O, chuyện thường ngày ở huyện.

Vấn đề là gì? Thứ Hai tới, cô con gái nghĩ ra "trời mưa giảm 10%". Thứ Hai tiếp, "sinh nhật quán giảm 50%". Tháng sau, "giờ vàng" hết hạn, phải xóa. Mỗi lần như vậy, anh em lại mổ cái hàm total ra:

  • Thêm một khối if nữa, hy vọng không đụng vào khối cũ.
  • Test lại tất cả khuyến mãi, vì tất cả nằm chung một hàm.
  • Hàm dài dần, dài dần, như hóa đơn tiền điện tháng Sáu.

4.2. Bước 1: tìm chỗ hay thay đổi

Trước khi refactor, hỏi: cái gì trong đoạn code này hay thay đổi nhất?

Phần Bao lâu đổi một lần
Cách tính tổng tiền hàng (giá × số lượng) Gần như không bao giờ
Luật "tiền phải trả không được âm" Không bao giờ
Luật cộng dồn khuyến mãi (cộng hết, chỉ lấy cái lớn nhất, hay có trần) Hiếm
Danh sách khuyến mãi Hằng tuần

Chỗ hay đổi là danh sách khuyến mãi. Đó là chỗ cần mở. Phần còn lại cần đóng.

4.3. Bước 2: định nghĩa "phích cắm" chung

Mọi khuyến mãi, dù trời mưa hay sinh nhật, đều trả lời được đúng một câu hỏi: "Với đơn hàng này, giảm bao nhiêu tiền?"

Vậy quy ước: khuyến mãi nào cũng có method discount_for(order), trả về số tiền giảm (không được giảm thì trả về 0). Đây là cái phích cắm chuẩn.

Ruby không có từ khóa interface như Java hay TypeScript. Không sao. Ruby theo kiểu duck typing: con gì đi như vịt, kêu như vịt thì là vịt. Class nào có discount_for thì là khuyến mãi.

Trước tiên, gom dữ liệu đơn hàng lại cho gọn:

Item  = Struct.new(:name, :price, :qty, keyword_init: true)

Order = Struct.new(:items, :customer, :time, keyword_init: true) do
  def subtotal
    items.sum { |i| i.price * i.qty }
  end

  def cups
    items.sum(&:qty)
  end
end

4.4. Bước 3: mỗi khuyến mãi một tấm thẻ

class HappyHourPromotion
  def discount_for(order)
    (14...16).cover?(order.time.hour) ? order.subtotal * 20 / 100 : 0
  end
end

class RegularCustomerPromotion
  def discount_for(order)
    order.customer[:regular] ? 2_000 : 0
  end
end

class MondayFreeTeaPromotion
  TEA_PRICE = 3_000

  def discount_for(order)
    (order.time.monday? && order.cups >= 3) ? TEA_PRICE : 0
  end
end

Mỗi class nhỏ xíu, đọc 5 giây là hiểu, và không biết gì về nhau. Giờ vàng không biết khách quen là ai. Khách quen không biết hôm nay thứ mấy. Hàng xóm tốt là hàng xóm không hỏi han.

4.5. Bước 4: cái khung bảng

class CheckoutCalculator
  def initialize(promotions)
    @promotions = promotions
  end

  def total(order)
    discount = @promotions.sum { |p| p.discount_for(order) }
    [order.subtotal - discount, 0].max
  end
end

CheckoutCalculator giờ không biết có những khuyến mãi nào. Nó chỉ biết: đưa tao một danh sách thứ gì đó biết discount_for, tao cộng lại rồi trừ đi. Đây là cái khung bảng: đóng, thêm hay bỏ khuyến mãi thì không ai phải sửa nó nữa.

Lắp ráp:

PROMOTIONS = [
  HappyHourPromotion.new,
  RegularCustomerPromotion.new,
  MondayFreeTeaPromotion.new
]

calculator = CheckoutCalculator.new(PROMOTIONS)

order = Order.new(
  items: [
    Item.new(name: "Trà đá", price: 3_000, qty: 2),
    Item.new(name: "Trà chanh", price: 10_000, qty: 1)
  ],
  customer: { regular: true },
  time: Time.new(2026, 10, 5, 15, 0) # thứ Hai, 15h
)

calculator.total(order) # => 7800

Đơn 16k, giờ vàng giảm 3.200đ, khách quen giảm 2k, thứ Hai mua 3 ly giảm 3k. Còn 7.800đ. Khách quen đến đúng giờ vàng thứ Hai được giảm hơn nửa đơn. Bà chủ xót.

Bà chủ xót thì sẽ có yêu cầu kiểu "giảm tối đa 30% thôi" hoặc "không cộng dồn nữa, chỉ lấy khuyến mãi lớn nhất". Lần này phải sửa CheckoutCalculator:

def total(order)
  discount = @promotions.sum { |p| p.discount_for(order) }
  discount = [discount, order.subtotal * 30 / 100].min # luật mới: giảm tối đa 30%
  [order.subtotal - discount, 0].max
end

Và sửa là đúng. Đây là đổi một luật đang có (cách cộng khuyến mãi), không phải thêm một biến thể, đúng như phần "Hiểu nhầm" ở mục 1. Bảng ở bước 1 cũng đã ghi: luật cộng dồn thì hiếm đổi, nên không mở chỗ đó ra từ đầu.

Thành thật khai báo: chữ O chỉ bảo vệ anh em trước đúng kiểu thay đổi mình đã dự đoán, ở đây là thêm và bỏ khuyến mãi. Kiểu thay đổi khác thì vẫn phải sửa. Nếu một ngày luật cộng dồn cũng đổi hằng tuần, lúc đó mới tính cho nó một "phích cắm" riêng.

4.6. Thử thách: thứ Hai tới có khuyến mãi mới

Cô con gái: "Anh ơi, từ mai trời mưa giảm 10% nhé."

class RainyDayPromotion
  def initialize(weather)
    @weather = weather
  end

  def discount_for(order)
    @weather.raining? ? order.subtotal * 10 / 100 : 0
  end
end

Rồi thêm một dòng vào danh sách:

# Sửa lại danh sách cũ, thêm đúng một dòng
PROMOTIONS = [
  HappyHourPromotion.new,
  RegularCustomerPromotion.new,
  MondayFreeTeaPromotion.new,
  RainyDayPromotion.new(WeatherService.new) # WeatherService: class giả định, biết trả lời raining?
]

CheckoutCalculator không sửa dòng nào. Ba khuyến mãi cũ không sửa dòng nào. Giờ vàng hết hạn? Xóa một dòng trong danh sách, xong.

Thành thật khai báo: danh sách PROMOTIONS thì vẫn phải sửa. Không tránh được, và cũng không cần tránh. Ổ cắm điện mở đến đâu thì cái phích vẫn phải có người cắm vào, nó không tự bay vào được. Cái quan trọng là chỗ phải sửa chỉ là chỗ lắp ráp, một dòng, không có logic gì để làm hỏng.

4.7. Test

Mỗi khuyến mãi test riêng, không cần dựng cả hệ thống. Còn CheckoutCalculator thì test bằng khuyến mãi giả, không cần quan tâm khuyến mãi thật nào đang chạy:

require "minitest/autorun"
require_relative "checkout" # file chứa Item, Order, các khuyến mãi và CheckoutCalculator

class HappyHourPromotionTest < Minitest::Test
  def order_at(hour)
    Order.new(
      items: [Item.new(name: "Trà chanh", price: 10_000, qty: 1)],
      customer: {},
      time: Time.new(2026, 10, 6, hour, 0) # thứ Ba, để khuyến mãi thứ Hai không chen vào
    )
  end

  def test_giam_20_phan_tram_trong_gio_vang
    assert_equal 2_000, HappyHourPromotion.new.discount_for(order_at(15))
    assert_equal 0, HappyHourPromotion.new.discount_for(order_at(17))
  end
end

class CheckoutCalculatorTest < Minitest::Test
  FixedPromotion = Struct.new(:amount) do
    def discount_for(_order)
      amount
    end
  end

  def order
    Order.new(items: [Item.new(name: "Trà chanh", price: 10_000, qty: 1)], customer: {}, time: Time.now)
  end

  def test_cong_don_moi_khuyen_mai
    calculator = CheckoutCalculator.new([FixedPromotion.new(1_000), FixedPromotion.new(500)])
    assert_equal 8_500, calculator.total(order)
  end

  def test_khong_bao_gio_tra_tien_am
    calculator = CheckoutCalculator.new([FixedPromotion.new(99_000)])
    assert_equal 0, calculator.total(order)
  end
end

Test cái khung một lần là xong. Từ giờ thêm bao nhiêu khuyến mãi, cái khung vẫn đúng.

Mẹo nhỏ: Để ý giờ vàng tính subtotal * 20 / 100 chứ không phải subtotal * 0.2. Tiền thì tính bằng số nguyên (hoặc BigDecimal), đừng để số thực chen vào, không thì có ngày hóa đơn ra 7799.999999đ. Chia nguyên thì phần lẻ bị bỏ đi; tiền Việt toàn số tròn trăm nên không sao, còn tính tiền có số lẻ thì dùng BigDecimal kèm quy tắc làm tròn rõ ràng.

4.8. So sánh trước và sau

Trước Sau
Thêm khuyến mãi mới Sửa hàm total Viết class mới, thêm 1 dòng vào danh sách
Bỏ khuyến mãi hết hạn Xóa khối if giữa hàm, rồi test lại cả hàm Xóa 1 dòng trong danh sách
Test khi thêm khuyến mãi Test lại tất cả Test mỗi khuyến mãi mới
Hai người cùng thêm khuyến mãi Conflict trong cùng một hàm Mỗi người một file, cùng lắm đụng nhau ở danh sách lắp ráp, gỡ trong một nốt nhạc
Số class 1 1 khung + mỗi khuyến mãi 1 class

Cái giá vẫn như bài trước: nhiều class hơn. Đổi lại, tuần nào cô con gái bà chủ nghĩ ra khuyến mãi mới, anh em cũng bình thản như người đã gài thẻ cả đời.

Bài trước anh em đã làm O mà không biết

Nhớ bài S không? Sau khi tách, muốn gửi báo cáo qua Slack thay vì email, anh em chỉ cần viết thêm SlackBillSender và đổi một dòng ở chỗ khởi tạo, không class cũ nào phải sửa. Đó chính là chữ O.

Nhớ câu Invoice ở bài tập bài S không? discount do marketing quản và đổi liên tục, nên tách ra. Thứ được tách ra đó chính là cái danh sách khuyến mãi trong bài này.

S và O hay đi cùng nhau: S chia code thành từng mảnh theo lý do thay đổi, O làm cho từng mảnh đó thay thế, cắm thêm được. Chia đúng chỗ thì cắm mới dễ.

5. O không chỉ là chuyện xóa if

Đọc tới đây, dễ tưởng chữ O chỉ có nghĩa là "thay chuỗi if bằng nhiều class". Đó là cách hay gặp nhất, nhưng chỉ là một cách. Chữ O rộng hơn: thêm hành vi mới mà không phải mổ code cũ. Kiểu thay đổi khác nhau thì cách mở cũng khác nhau.

Kiểu thay đổi Cách mở Ở quán trà đá
Thêm một loại mới của thứ đã có Cắm biến thể (mục 4) Thêm khuyến mãi mới
Thêm việc bao quanh một việc cũ Bọc thêm lớp ngoài Ghi log mỗi lần tính tiền
Thêm việc xảy ra sau một việc cũ Phát sự kiện Tích điểm sau mỗi đơn đã trả
Thêm một khâu cho mọi lượt xử lý Chuỗi xử lý (middleware) Kiểm tra giờ mở cửa trước mọi đơn
Luật đổi liên tục, người không biết code muốn tự đổi Đưa luật ra dữ liệu Cô con gái tự thêm khuyến mãi trên trang admin

Hai cách hay dùng nhất sau "cắm biến thể" là bọc thêm lớp ngoài và phát sự kiện. Xem nhanh từng cách.

Bọc thêm lớp ngoài

Cuối ngày bà chủ muốn đối soát, cần ghi log mỗi lần tính tiền. Không cần đụng vào CheckoutCalculator, chỉ cần bọc nó lại:

class LoggedCheckout
  def initialize(calculator, logger)
    @calculator = calculator
    @logger = logger
  end

  def total(order)
    result = @calculator.total(order)
    @logger.info("Đơn #{order.subtotal}đ, khách trả #{result}đ")
    result
  end
end

require "logger"

calculator = LoggedCheckout.new(CheckoutCalculator.new(PROMOTIONS), Logger.new($stdout))

Người gọi vẫn gọi calculator.total(order) như cũ, không biết bên trong có thêm một lớp. Mai muốn thêm cache, thêm đo thời gian, thì bọc thêm lớp nữa. Giống ly trà mang đi: ly vẫn là ly cũ, chỉ bọc thêm cái túi nilon. Người ta gọi kiểu này là decorator. Class được bọc có nhiều method thì Ruby có sẵn SimpleDelegator để khỏi phải viết lại từng method.

Phát sự kiện

Tuần này bà chủ muốn tích điểm cho khách quen sau mỗi đơn. Tuần sau muốn trừ tồn kho đá. Tháng sau muốn in phiếu cho bếp. Nếu mỗi lần lại chen thêm một dòng vào code thanh toán, code thanh toán lại thành biển sơn.

Thay vào đó, code thanh toán chỉ thông báo "đơn này đã trả tiền", ai quan tâm thì tự đăng ký nghe:

class OrderEvents
  def initialize
    @listeners = []
  end

  def subscribe(listener)
    @listeners << listener
  end

  def order_paid(order)
    @listeners.each { |listener| listener.order_paid(order) }
  end
end

class LoyaltyPoints
  def order_paid(order)
    # cộng điểm cho khách quen
  end
end

events = OrderEvents.new
events.subscribe(LoyaltyPoints.new)
events.subscribe(IceStockTracker.new) # class giả định, tuần sau thêm, code thanh toán không đổi

# Trong code thanh toán, chỉ có đúng một dòng:
events.order_paid(order)

Thêm tính năng mới là thêm một class biết order_paid và đăng ký nó. Rails có sẵn ActiveSupport::Notifications dùng được cho việc này, dù nó sinh ra chủ yếu để đo đạc (instrumentation). Muốn một thư viện chuyên cho sự kiện nghiệp vụ thì có gem wisper.

Thành thật khai báo: sự kiện nhiều quá thì khó lần theo. Đọc code thanh toán không thấy ai đang nghe, có lỗi thì phải đi tìm từng người nghe. Dùng cho những việc "phụ" xảy ra sau việc chính, đừng dùng cho luồng chính.

Ba cách còn lại, nói nhanh

  • Cắm biến thể: chính là mục 4 ở trên, mỗi khuyến mãi một class.
  • Chuỗi xử lý: trong Rails, mỗi request đi qua một dây chuyền Rack middleware. Muốn thêm một khâu cho mọi request (ghi log, chặn bot) thì thêm một middleware, không sửa controller nào.
  • Đưa luật ra dữ liệu: khuyến mãi kiểu "giảm X% từ giờ A đến giờ B" có thể lưu trong database. Cô con gái bà chủ tự thêm trên trang admin, không cần dev, không cần deploy. Cái giá: luật nào phức tạp quá thì dữ liệu không diễn tả nổi, lúc đó vẫn phải quay về code.

Kết luận phần này: if không có tội. Có tội là chỗ cứ thêm tính năng là phải mổ code cũ. Chữa thì có nhiều cách, chọn cách theo kiểu thay đổi đang gặp.

6. Đừng mở quá tay

Học xong O, anh em sẽ có cảm giác muốn biến mọi if thành plugin. Kìm lại.

  • Cái gì chưa từng thay đổi thì đừng mở. if user.admin? hai nhánh, mấy năm nay vẫn hai nhánh, thì để nguyên. Biến nó thành RolePolicyStrategyRegistry là tự làm khổ mình và đồng nghiệp.
  • Quy tắc ba lần (rule of three, Martin Fowler nhắc trong sách Refactoring). Lần đầu thêm một loại: viết if cũng được. Lần thứ hai: bắt đầu để ý. Lần thứ ba: đây là chỗ hay thay đổi thật, mở nó ra.
  • Mở sai chỗ còn tệ hơn không mở. Sandi Metz có câu nổi tiếng (bài blog The Wrong Abstraction, 2016): duplication is far cheaper than the wrong abstraction, code lặp còn rẻ hơn nhiều so với một abstraction sai. Đoán sai chỗ mở, đến lúc yêu cầu thật đến thì phải phá cái khung ra làm lại, đau hơn cả sơn lại biển.

O là để thêm tính năng yên tâm hơn, không phải để code trông kiến trúc hơn. Khung plugin cho một thứ chỉ có một loại thì giống lắp ổ cắm 10 lỗ cho một cái quạt.

Nói riêng với anh em Rails

Rails dùng chữ O ở khắp nơi, chỉ là anh em không để ý:

  • Active Storage: ảnh blog này nằm trên GCS. Đổi từ lưu trên ổ đĩa (Disk) sang GCS, code của blog không phải sửa dòng nào: Active Storage có sẵn các service Disk, S3, GCS cùng chung một phích cắm, khai báo trong config/storage.yml là đổi.
  • Active Job: đổi từ Sidekiq sang Solid Queue thì đổi adapter trong config, code job không đổi (miễn là job viết theo API của Active Job, không dùng tính năng riêng của Sidekiq).
  • Mailer delivery method: SMTP, chế độ test, hay adapter của dịch vụ gửi mail... cũng là cắm adapter.

Mấy ông viết Rails đã đóng cái khung, mở cái khe. Anh em chỉ việc gài thẻ.

7. 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 chỗ nào nên mở, chỗ nào chưa cần. 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.

Trường hợp nào nên áp dụng O (mở chỗ đó ra cho mở rộng), trường hợp nào chưa cần? Giải thích bằng câu hỏi "chỗ này có hay thêm biến thể mới không?".

  1. Class ShippingFee có case carrier với 3 nhánh: "ghn", "ghtk", "viettel_post". Sếp vừa báo tháng sau ký thêm J&T và Ahamove.
  2. Quán chỉ bán đồ uống, chịu đúng một mức thuế. Class TaxCalculator tính VAT bằng hằng số VAT_PERCENT = 10, lâu lâu mới phải đổi con số này.
  3. Ba hàm export_data(format), content_type(format), file_extension(format), hàm nào cũng có case format với "csv", "json", "xlsx". Khách vừa xin thêm định dạng PDF.
  4. Class AppLogger nhận vào một danh sách output, mỗi output có method write(message). Hiện có ConsoleOutput và FileOutput, giờ muốn log thêm ra Slack.
  5. View có if current_user.admin? để hiện nút xóa bài. Hệ thống chỉ có admin và người thường, ba năm nay chưa thêm vai trò nào.

Túm cái váy lại

Ghi nhớ
Định nghĩa Mở cho mở rộng, đóng cho sửa đổi
Dịch cabin Thêm biến thể mới bằng cách viết thêm, không sửa lại
Câu hỏi thần chú "Thêm một loại mới thì phải sửa mấy file cũ?"
Dấu hiệu case/if theo loại ngày càng dài, lặp ở nhiều nơi
Cách làm Tìm chỗ hay đổi, định nghĩa "phích cắm" chung, mỗi biến thể một class, cái khung chỉ làm việc với phích cắm
Nhiều cách mở Cắm biến thể, bọc lớp ngoài, phát sự kiện, chuỗi xử lý, đưa luật ra dữ liệu. Chọn theo kiểu thay đổi
Hiểu nhầm Cấm sửa code cũ. Sai, sửa bug và đổi luật thì cứ sửa
Đừng Mở mọi thứ "phòng khi sau này". Chỉ mở chỗ đã thay đổi thật

Sau bài này, anh em đã sở hữu:

  • Một câu hỏi để soi code: thêm loại mới thì phải sửa mấy file cũ?
  • Một quy trình 4 bước: tìm chỗ hay đổi, định nghĩa phích cắm, tách từng biến thể, để cái khung chỉ biết phích cắm.
  • Và hy vọng là một chuỗi elsif dài hơn cả menu quán lẩu nào đó sắp được nghỉ hưu.

Chúc anh em thêm tính năng không hỏng tính năng, deploy không giật mình, và trà luôn đủ đá. Hẹn anh em ở chữ L.

À mà, nếu anh em đang nghĩ "dự án em toàn case mà vẫn chạy ngon", thì mình tin. Biển sơn cũng treo được hai chục năm. Chỉ là đừng ai hỏi số điện thoại giữ xe.

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

~$ cd ..