Skip to main content

5.2 — 1. SOLID Principles

Summary

SOLID không phải năm quy tắc rời rạc mà là năm góc nhìn của cùng một mục tiêu: làm cho phần mềm đổi được mà không phải sửa những chỗ đang chạy tốt. Mỗi nguyên tắc quy về một câu hỏi kiểm tra rất cụ thể — "ai yêu cầu thay đổi class này?", "thêm một loại mới thì phải sửa mấy file?", "lớp con có thay thế được lớp cha không?". Bài này cũng dành một mục cho điều ít tài liệu nói thẳng: SOLID bị áp dụng sai rất nhiều, và một hệ thống bị chia thành 200 class mỗi class ba dòng thì khó hiểu hơn hẳn một class 300 dòng viết rõ ràng.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Phát biểu từng nguyên tắc kèm câu hỏi kiểm tra tương ứng.
  • Áp dụng SRP bằng tiêu chí "ai yêu cầu thay đổi" thay vì đếm số dòng.
  • Dùng OCP để thêm loại mới mà không sửa code cũ.
  • Nhận ra ba kiểu áp dụng SOLID sai thường gặp.
  • Quyết định khi nào chưa cần áp dụng.

Nội dung bài học​

5.2.1 — Năm nguyên tắc, năm câu hỏi​

Nguyên tắcCâu hỏi kiểm tra
SSingle ResponsibilityAi yêu cầu thay đổi class này?
OOpen/ClosedThêm một loại mới thì phải sửa mấy file?
LLiskov SubstitutionLớp con có thay thế được lớp cha ở mọi chỗ không?
IInterface SegregationNgười cài đặt có bị ép viết phương thức họ không cần không?
DDependency InversionMũi tên phụ thuộc chỉ vào hạ tầng hay chỉ ra khỏi nó?

Ba nguyên tắc cuối đã được đào ở Module 4: Liskov ở bài 4.4, còn Interface Segregation và Dependency Inversion ở bài 4.5. Bài này tập trung vào hai cái đầu và vào phần phê phán.

5.2.2 — S: một lý do để thay đổi​

Phát biểu quen thuộc "một class chỉ làm một việc" quá mơ hồ — "một việc" là gì? Phát biểu chính xác hơn:

Một class chỉ nên có một lý do để thay đổi, nghĩa là chỉ phục vụ một nhóm người dùng.

// VI PHẠM — ba nhóm người có thể yêu cầu sửa class này
public class OrderService
{
public decimal CalculateTotal(Order o) { } // kế toán yêu cầu đổi cách tính thuế
public byte[] ExportToExcel(Order o) { } // vận hành yêu cầu đổi cột trong file
public void SendConfirmation(Order o) { } // marketing yêu cầu đổi nội dung email
}

Khi marketing đổi mẫu email, bạn phải sửa file mà kế toán cũng đang phụ thuộc — và mọi bài test của phần tính tiền phải chạy lại.

// TUÂN THỦ — mỗi class một nhóm người yêu cầu
public class OrderCalculator { } // kế toán
public class OrderExporter { } // vận hành
public class OrderNotifier { } // marketing

Chú ý tiêu chí: không phải số dòng. Một class 400 dòng chỉ phục vụ kế toán vẫn tuân thủ SRP. Một class 50 dòng phục vụ ba nhóm thì không.

5.2.3 — O: mở để mở rộng, đóng để sửa đổi​

// VI PHẠM — thêm một loại thanh toán là phải sửa hàm này
public decimal CalculateFee(Payment p)
{
if (p.Type == "card") return p.Amount * 0.022m;
if (p.Type == "transfer") return 11_000m;
if (p.Type == "cash") return 0m;
throw new NotSupportedException();
}

Mỗi loại mới là một lần sửa hàm đang chạy tốt — và mỗi lần sửa là một cơ hội làm hỏng ba loại cũ.

// TUÂN THỦ — thêm loại mới là THÊM file, không SỬA file
public interface IFeeCalculator
{
bool CanHandle(PaymentType type);
decimal Calculate(Payment p);
}

public class CardFeeCalculator : IFeeCalculator
{
public bool CanHandle(PaymentType t) => t == PaymentType.Card;
public decimal Calculate(Payment p) => p.Amount * 0.022m;
}

// Đăng ký tất cả, chọn cái phù hợp lúc chạy
public class FeeService(IEnumerable<IFeeCalculator> calculators)
{
public decimal Calculate(Payment p)
=> calculators.First(c => c.CanHandle(p.Type)).Calculate(p);
}

Thêm CryptoFeeCalculator giờ chỉ là thêm một file và một dòng đăng ký. Không file nào đang chạy bị đụng tới, nên không test nào cần chạy lại ngoài test của loại mới.

Nhưng — và đây là phần quan trọng — cấu trúc trên đắt hơn đoạn if ban đầu: nhiều file hơn, khó lần luồng chạy hơn, khó debug hơn. Nó chỉ đáng khi bạn thật sự sẽ thêm loại mới. Với ba loại cố định suốt năm năm, chuỗi if là lựa chọn đúng.

Đây là chỗ nối với cảnh báo ở bài 4.6 về trừu tượng hoá quá sớm.

5.2.4 — Ba nguyên tắc còn lại, tóm tắt​

L — Liskov: lớp con phải dùng thay lớp cha được ở mọi chỗ, mà code gọi không cần biết. Ba dấu hiệu vi phạm: lớp con ném ngoại lệ cho phương thức cha hỗ trợ, lớp con override rồi không làm gì, và code gọi phải kiểm tra kiểu. Chi tiết kèm ví dụ hình vuông–hình chữ nhật ở bài 4.4.

I — Interface Segregation: đừng ép người cài đặt viết những phương thức họ không dùng. Chia interface theo vai trò người dùng, không theo class cài đặt. Triệu chứng rõ nhất: bản giả trong test phải cài đặt bảy phương thức trong khi bài test chỉ gọi một.

D — Dependency Inversion: module cấp cao không phụ thuộc module cấp thấp; cả hai phụ thuộc vào trừu tượng. Trong thực tế: interface thuộc về tầng nghiệp vụ, tầng hạ tầng đi theo nó — nên mũi tên phụ thuộc chỉ ra khỏi database chứ không chỉ vào.

5.2.5 — SOLID bị áp dụng sai ở đâu​

Phần này ít tài liệu nói thẳng, nhưng gặp rất nhiều trong code thật.

1. Chia nhỏ quá mức nhân danh SRP.

OrderCreator.cs            (8 dòng)
OrderValidator.cs (12 dòng)
OrderValidationRule1.cs (6 dòng)
OrderValidationRule2.cs (6 dòng)
OrderTotalCalculator.cs (9 dòng)
OrderTotalRounder.cs (4 dòng)
...

Để hiểu một luồng nghiệp vụ, người đọc phải mở 15 file và ghép lại trong đầu. Một class 200 dòng viết rõ ràng dễ đọc hơn nhiều.

SRP nói "một lý do để thay đổi", không nói "một phương thức mỗi class". Nếu hai thứ luôn thay đổi cùng nhau, chúng thuộc về nhau.

2. Interface cho mọi thứ.

Một IFooService chỉ có FooService cài đặt, không vượt ranh giới hạ tầng nào, không cần thay bằng bản giả — đó là nghi lễ. Ba câu hỏi quyết định nằm ở bài 4.5.

3. Áp dụng OCP cho thứ không bao giờ mở rộng.

Dựng một hệ thống chiến lược đầy đủ cho ba trường hợp đã cố định nhiều năm. Bạn trả chi phí phức tạp ngay hôm nay để mua một khả năng không bao giờ dùng tới.

Câu hỏi kiểm tra trước khi áp dụng bất kỳ nguyên tắc nào:

Nguyên tắc này đang giải quyết vấn đề gì tôi thật sự đang có?

Không trả lời được bằng một vấn đề cụ thể thì đó là áp dụng theo nghi thức.

5.2.6 — Thứ tự nên quan tâm​

Nếu chỉ chọn được vài thứ để làm tốt, hãy theo thứ tự này:

  1. D — Dependency Inversion. Lợi ích lớn nhất, cảm nhận ngay: code kiểm thử được.
  2. S — Single Responsibility. Ảnh hưởng nhiều nhất tới khả năng đọc và bảo trì.
  3. L — Liskov. Vi phạm nó gây bug thật, không chỉ gây khó chịu.
  4. I — Interface Segregation. Quan trọng khi interface bắt đầu phình.
  5. O — Open/Closed. Chỉ áp dụng khi đã thấy trục mở rộng thật.

Đặt O cuối là có chủ ý: nó là nguyên tắc dễ bị áp dụng sớm nhất, và cái giá của việc áp dụng sớm là cao nhất.

5.2.7 — Rà lại code của bạn​

Danh sách rà soát SOLID

  • •Mỗi class trả lời được câu hỏi 'ai yêu cầu thay đổi tôi' bằng một nhóm người duy nhất.
  • •Không có class nào trộn logic nghiệp vụ với xuất file và gửi thông báo.
  • •Chuỗi if theo loại chỉ tồn tại ở nơi số loại thật sự cố định.
  • •Mọi lớp con đều thay thế được lớp cha mà chỗ gọi không phải kiểm tra kiểu.
  • •Không có interface nào buộc bản giả trong test cài đặt phương thức không dùng.
  • •Interface nằm ở tầng nghiệp vụ, không nằm cùng chỗ với cài đặt hạ tầng.
  • •Không có nhóm file ba dòng nào phải mở cùng lúc mới hiểu được một luồng.
  • •Mỗi lần áp dụng một nguyên tắc đều chỉ ra được vấn đề cụ thể nó giải quyết.

Bài tập áp dụng​

Bài 1 — Hỏi "ai yêu cầu thay đổi"​

Lấy năm class lớn nhất trong dự án. Với mỗi class, liệt kê các nhóm người có thể yêu cầu sửa nó. Class nào có nhiều hơn một nhóm thì phác thảo cách tách.

Tiêu chí hoàn thành: bạn tách theo người yêu cầu, không theo số dòng, và giữ lại được một class lớn mà bạn kết luận là không cần tách.

Gợi ý và lời giải — Bài 1

Gợi ý. Bài này mở rộng bài tập ở bài 4.10. Điểm mới ở đây là yêu cầu bạn tìm được một phản ví dụ: một class lớn nhưng không nên tách.

Lời giải — ví dụ class cần tách:

InvoiceService.cs — 740 dòng

| Nhóm phương thức | Ai yêu cầu đổi | Tần suất |
|------------------------|-----------------------|----------|
| Tạo, sửa hoá đơn | Phòng kế toán | Trung bình |
| Tính thuế VAT | Quy định pháp luật | Thấp, gấp |
| Sinh file PDF | Phòng kế toán | Thấp |
| Gửi hoá đơn điện tử | Nhà cung cấp hoá đơn | Trung bình |
| Đối soát ngân hàng | Phòng tài chính | Cao |

-> 4 nhóm người khác nhau. Nên tách.

Ví dụ class lớn nhưng KHÔNG nên tách — đây mới là phần khó của bài:

VietnameseTaxCalculator.cs — 620 dòng

Toàn bộ file là các quy tắc tính thuế: VAT theo nhóm hàng, thuế thu nhập
cá nhân theo bậc, các trường hợp miễn giảm, quy tắc làm tròn.

| Nhóm phương thức | Ai yêu cầu đổi |
|------------------|--------------------|
| Tất cả | Quy định pháp luật |

-> Chỉ MỘT nguồn thay đổi. Dài nhưng gắn kết.

Tách file này thành VatCalculator, PitCalculator, RoundingRules sẽ làm tệ đi: khi luật thuế thay đổi, bạn phải sửa cả ba file thay vì một, và phải mở ba file mới hiểu được một quy tắc.

Đây là điểm mà SOLID hay bị hiểu nhầm nhất. Single Responsibility không nói "class phải ngắn". Nó nói "class chỉ nên có một lý do để thay đổi". Một class 600 dòng với một nguồn thay đổi tốt hơn ba class 200 dòng luôn phải sửa cùng lúc.

Ba câu hỏi để quyết định:

  1. Ai yêu cầu thay đổi? Nhiều hơn một nhóm người thì cân nhắc tách.
  2. Các phần có thay đổi cùng lúc không? Nếu sửa phần A luôn kéo theo sửa phần B, chúng thuộc về nhau — tách ra chỉ tạo thêm chi phí điều phối.
  3. Tách rồi có ai dùng riêng từng phần không? Không ai dùng riêng thì việc tách chỉ là chia file.

Dấu hiệu tách sai. Nếu sau khi tách, mọi thay đổi đều phải sửa tất cả các file vừa tách ra, thì bạn đã chia theo cấu trúc kỹ thuật thay vì theo lý do thay đổi. Đây chính là điều mà kiến trúc phân lát dọc ở bài 16.5 phản đối: chia theo tầng khiến mỗi tính năng mới phải chạm vào mọi tầng.

Bài 2 — Đo chi phí thật của nguyên tắc Open/Closed​

Lấy một chuỗi if theo loại trong dự án. Viết lại theo mẫu chiến lược. Đếm số file tăng thêm, và trả lời: trong hai năm qua đã thêm bao nhiêu loại mới?

Tiêu chí hoàn thành: bạn ra được quyết định dựa trên con số lịch sử, không dựa trên cảm giác.

Gợi ý và lời giải — Bài 2

Gợi ý. Câu hỏi cuối là câu quan trọng nhất, và nó trả lời được bằng Git:

git log --since="2 years ago" -S "enum PhuongThucThanhToan" --oneline
git log --since="2 years ago" -p -- '*PaymentMethod*.cs' | grep '^+.*= [0-9]*,' | sort -u

Lời giải — trước:

public decimal CalculateFee(PaymentType type, decimal amount) => type switch
{
PaymentType.CreditCard => amount * 0.025m,
PaymentType.BankTransfer => 11_000m,
PaymentType.EWallet => amount * 0.015m,
PaymentType.Cash => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(type))
};

Sau — mẫu chiến lược:

public interface IPaymentFee
{
PaymentType Type { get; }
decimal Calculate(decimal amount);
}

public class CreditCardFee : IPaymentFee
{
public PaymentType Type => PaymentType.CreditCard;
public decimal Calculate(decimal amount) => amount * 0.025m;
}
// ... ba lớp nữa

public class FeeCalculator
{
private readonly IReadOnlyDictionary<PaymentType, IPaymentFee> _map;

public FeeCalculator(IEnumerable<IPaymentFee> strategies) =>
_map = strategies.ToDictionary(x => x.Type);

public decimal Calculate(PaymentType type, decimal amount) =>
_map.TryGetValue(type, out var fee)
? fee.Calculate(amount)
: throw new ArgumentOutOfRangeException(nameof(type));
}

Chi phí đo được:

TrướcSau
Số file17 (1 interface + 4 chiến lược + 1 bộ điều phối + đăng ký DI)
Số dòng tổng8~70
Đọc hiểu toàn bộ logic phíMở 1 fileMở 5 file
Thêm một loại mớiSửa 1 fileThêm 1 file, không sửa file cũ

Quyết định dựa trên lịch sử Git:

Số loại thêm trong 2 nămKết luận
0 tới 1Giữ switch. Bảy file cho một thay đổi hai năm là chi phí thuần
2 tới 4Ranh giới — cân nhắc thêm yếu tố khác, ví dụ ai là người thêm
Từ 5 trở lênChuyển sang chiến lược. Chi phí đã được hoàn vốn nhiều lần

Một yếu tố quan trọng hơn cả tần suất: ai thêm loại mới. Nếu chỉ đội của bạn thêm, switch hoàn toàn ổn — bạn sửa file đó lúc nào cũng được. Nếu đội khác hoặc bên thứ ba cần thêm loại mới mà không được sửa code của bạn, thì mẫu chiến lược là bắt buộc, bất kể tần suất. Đây chính là tình huống mà Open/Closed sinh ra để giải quyết.

Điểm dễ bỏ sót về đăng ký. Với mẫu chiến lược, đừng quên rằng thêm một lớp thôi chưa đủ — phải đăng ký nó vào DI. Quên đăng ký thì TryGetValue trả false và ném ngoại lệ lúc chạy, thay vì lỗi biên dịch như switch vét cạn ở bài 4.9. Có thể giảm rủi ro bằng cách quét assembly tự động:

services.Scan(scan => scan.FromAssemblyOf<IPaymentFee>()
.AddClasses(c => c.AssignableTo<IPaymentFee>())
.AsImplementedInterfaces()
.WithSingletonLifetime());

Kết luận thực dụng. Open/Closed không phải mục tiêu tự thân. Nó là một khoản đầu tư: trả trước bằng số file và độ phức tạp, thu về khi có thêm loại mới. Đầu tư khi chưa biết có thu về không thì đó là đầu cơ.

Bài 3 — Tìm chỗ chia nhỏ quá mức​

Tìm một luồng nghiệp vụ phải mở từ năm file trở lên mới hiểu được. Thử gộp những phần luôn thay đổi cùng nhau và so sánh độ dễ đọc.

Tiêu chí hoàn thành: bạn nêu được tiêu chí phân biệt "tách đúng" với "tách vụn".

Gợi ý và lời giải — Bài 3

Gợi ý. Chọn một tính năng cụ thể, ví dụ "tạo một lead mới", rồi đếm số file phải mở để hiểu trọn vẹn nó từ lúc nhận request tới lúc lưu vào database.

Lời giải — ví dụ luồng bị chia vụn:

Tạo một lead — phải mở 9 file:

1. LeadController.cs nhận request
2. CreateLeadRequest.cs DTO đầu vào
3. CreateLeadRequestValidator.cs
4. ILeadService.cs interface, 1 cài đặt
5. LeadService.cs gọi thẳng repository, không có logic gì thêm
6. ILeadRepository.cs interface, 1 cài đặt
7. LeadRepository.cs gọi thẳng DbContext
8. LeadMapper.cs ánh xạ DTO sang entity
9. LeadDto.cs DTO đầu ra

Trong đó LeadService chỉ có:

public class LeadService : ILeadService
{
private readonly ILeadRepository _repo;
public LeadService(ILeadRepository repo) => _repo = repo;

public Task<Lead> CreateAsync(Lead lead, CancellationToken ct)
=> _repo.AddAsync(lead, ct); // chuyển tiếp, không thêm gì
}

Một lớp chỉ chuyển tiếp lời gọi không phải một trách nhiệm — nó là một tầng thừa. Cùng nhận xét cho ILeadService và ILeadRepository: mỗi cái chỉ có một cài đặt và không vượt ranh giới hạ tầng nào ngoài cái mà DbContext đã đại diện.

Sau khi gộp — 4 file:

1. CreateLead.cs        gộp request, validator, handler vào một file
2. Lead.cs entity, đã chứa quy tắc nghiệp vụ
3. LeadDto.cs DTO đầu ra
4. ILeadRepository.cs + EfLeadRepository.cs giữ, vì vượt ranh giới hạ tầng
// CreateLead.cs — một tính năng, một file
public record CreateLeadRequest(string Name, string Email, decimal Value);

public class CreateLeadValidator : AbstractValidator<CreateLeadRequest>
{
public CreateLeadValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}

public class CreateLeadHandler(ILeadRepository repo)
{
public async Task<Result<LeadDto>> HandleAsync(CreateLeadRequest req, CancellationToken ct)
{
var lead = Lead.Create(req.Name, new Email(req.Email), req.Value);
if (lead.IsFailure) return Result<LeadDto>.Fail(lead.Error!);

await repo.AddAsync(lead.Value, ct);
return Result<LeadDto>.Ok(LeadDto.From(lead.Value));
}
}

Tiêu chí phân biệt tách đúng với tách vụn:

Tách đúngTách vụn
Mỗi phần có thay đổi độc lập khôngCóKhông — luôn sửa cùng lúc
Có nhiều hơn một cài đặt, hoặc sẽ cóCóKhông
Có vượt ranh giới hạ tầng khôngCóKhông
Có ai dùng riêng từng phần khôngCóKhông

Phép thử một câu: khi thêm một trường vào lead, tôi phải sửa mấy file? Với cấu trúc 9 file, câu trả lời là 6. Với cấu trúc 4 file, là 2. Con số đó đo trực tiếp chi phí của việc chia vụn.

Vì sao chia vụn lại phổ biến. Nó trông giống code sạch. Mỗi file ngắn, mỗi lớp một việc, có interface đầy đủ — mọi dấu hiệu bề mặt của một kiến trúc tốt. Nhưng SOLID nói về lý do thay đổi, không nói về số file. Bốn lớp luôn thay đổi cùng nhau là một trách nhiệm được trải ra bốn file.

Hướng tổ chức thay thế. Nhóm code theo tính năng thay vì theo tầng kỹ thuật:

Features/
├── Leads/
│ ├── CreateLead.cs
│ ├── UpdateLead.cs
│ └── GetLeadById.cs
└── Orders/
├── CreateOrder.cs
└── ApproveOrder.cs

Thêm một tính năng là thêm một file, ở một chỗ. Đây là kiến trúc phân lát dọc, chủ đề của bài 16.5.

Tự kiểm tra​

Frequently asked questions

Single Responsibility nên hiểu thế nào cho đúng?

Không phải một class chỉ làm một việc, vì một việc là khái niệm mơ hồ. Chính xác hơn là một class chỉ nên có một lý do để thay đổi, tức chỉ phục vụ một nhóm người dùng. Một class 400 dòng chỉ phục vụ kế toán vẫn tuân thủ; một class 50 dòng phục vụ ba nhóm thì không.

Open/Closed nghĩa là gì trong thực tế?

Thêm một loại mới nên là thêm file chứ không phải sửa file đang chạy. Thay chuỗi if theo loại bằng một interface với nhiều cài đặt, rồi chọn cái phù hợp lúc chạy. Nhờ đó không file nào đang chạy bị đụng tới và không test nào cần chạy lại ngoài test của loại mới.

Khi nào KHÔNG nên áp dụng Open/Closed?

Khi số loại thật sự cố định. Cấu trúc chiến lược đắt hơn chuỗi if: nhiều file hơn, khó lần luồng chạy hơn, khó debug hơn. Với ba loại không đổi suốt nhiều năm thì chuỗi if là lựa chọn đúng, và áp dụng OCP lúc đó là trả chi phí phức tạp để mua một khả năng không dùng tới.

Chia nhỏ quá mức nhân danh SRP gây hại thế nào?

Để hiểu một luồng nghiệp vụ, người đọc phải mở mười lăm file ba dòng và ghép lại trong đầu. Một class hai trăm dòng viết rõ ràng dễ đọc hơn nhiều. SRP nói một lý do để thay đổi chứ không nói một phương thức mỗi class; nếu hai thứ luôn thay đổi cùng nhau thì chúng thuộc về nhau.

Nên ưu tiên nguyên tắc nào trước?

Dependency Inversion trước vì lợi ích cảm nhận ngay là code kiểm thử được. Rồi Single Responsibility vì nó ảnh hưởng nhiều nhất tới khả năng đọc. Liskov thứ ba vì vi phạm nó gây bug thật. Interface Segregation khi interface bắt đầu phình. Open/Closed cuối cùng, vì nó dễ bị áp dụng sớm nhất và cái giá của việc áp dụng sớm là cao nhất.

Câu hỏi nào nên tự đặt trước khi áp dụng một nguyên tắc?

Nguyên tắc này đang giải quyết vấn đề gì mà tôi thật sự đang có. Nếu không trả lời được bằng một vấn đề cụ thể thì đó là áp dụng theo nghi thức, và bạn đang trả chi phí phức tạp mà không nhận lại gì.

Kết luận​

Ba điều đáng nhớ nhất:

  1. SRP đo bằng "ai yêu cầu thay đổi", không đo bằng số dòng.
  2. OCP đắt. Chỉ áp dụng khi đã thấy trục mở rộng thật, không áp dụng theo dự đoán.
  3. Chia nhỏ quá mức tệ hơn một class lớn viết rõ ràng. Thứ luôn đổi cùng nhau thì thuộc về nhau.

Tham khảo​

Điều hướng​