Bạn đã bao giờ sửa một dòng code ở chức năng thanh toán và rồi chức năng gửi email bỗng dưng hỏng? Hay muốn thêm một phương thức giao hàng mới mà phải sửa tới năm file khác nhau? Đó là dấu hiệu code đang dính chặt vào nhau — và SOLID chính là bộ nguyên tắc sinh ra để gỡ những nút thắt ấy.
Trong bài này, chúng ta đi qua từng nguyên tắc với cùng một công thức: ý tưởng cốt lõi → dấu hiệu vi phạm → code sai → code đúng, tất cả viết bằng C#.
Tổng quan
SOLID là gì?
5 nguyên tắc thiết kế hướng đối tượng được Robert C. Martin (Uncle Bob) tổng hợp, giúp phần mềm dễ bảo trì và mở rộng.
SOLID là viết tắt của năm nguyên tắc:
| Chữ cái | Nguyên tắc | Hiểu nhanh |
|---|---|---|
| S | Single Responsibility Principle | Mỗi class chỉ có một lý do để thay đổi. |
| O | Open/Closed Principle | Mở để mở rộng, đóng để sửa đổi. |
| L | Liskov Substitution Principle | Class con phải thay thế được class cha mà không làm sai chương trình. |
| I | Interface Segregation Principle | Nhiều interface nhỏ, chuyên biệt tốt hơn một interface "ôm đồm". |
| D | Dependency Inversion Principle | Phụ thuộc vào abstraction, không phụ thuộc vào class cụ thể. |
SOLID không phải luật bắt buộc mà là la bàn. Mục tiêu cuối cùng luôn là: khi yêu cầu thay đổi, ta sửa ít chỗ nhất và ít rủi ro nhất.
Nguyên tắc 1
S — Single Responsibility Principle
"A class should have one, and only one, reason to change."
Single Responsibility
Đơn trách nhiệm 1 class · 1 lý do thay đổiMỗi class chỉ nên phục vụ một nhóm người / một mối quan tâm. "Một trách nhiệm" không có nghĩa là "một method", mà là chỉ có một lý do khiến class phải sửa.
- Tên class chứa chữ Manager, Helper, Utils, Processor và dài hàng nghìn dòng.
- Một class vừa xử lý nghiệp vụ, vừa truy cập database, vừa gửi email, vừa ghi log.
- Khi mô tả class bạn phải dùng chữ "và" nhiều lần.
public class OrderService
{
public void PlaceOrder(Order order)
{
// 1. Kiểm tra dữ liệu
if (order.Items.Count == 0)
throw new InvalidOperationException("Đơn hàng trống");
// 2. Lưu database
using var conn = new SqlConnection("Server=...;Database=Shop");
conn.Open();
var cmd = new SqlCommand("INSERT INTO Orders ...", conn);
cmd.ExecuteNonQuery();
// 3. Gửi email xác nhận
var smtp = new SmtpClient("smtp.gmail.com");
smtp.Send("shop@pounitech.com", order.CustomerEmail,
"Xác nhận đơn hàng", $"Đơn #{order.Id} đã được tạo");
// 4. Ghi log
File.AppendAllText("log.txt", $"{DateTime.Now}: Order {order.Id}\n");
}
}
OrderService có ít nhất 4 lý do để thay đổi: đổi quy tắc kiểm tra, đổi database, đổi nhà cung cấp email, đổi cách ghi log. Sửa bất kỳ phần nào cũng có nguy cơ làm hỏng phần khác, và gần như không thể viết unit test.
public class OrderValidator
{
public void Validate(Order order)
{
if (order.Items.Count == 0)
throw new InvalidOperationException("Đơn hàng trống");
}
}
public class OrderRepository
{
public void Save(Order order) { /* chỉ lo lưu database */ }
}
public class OrderEmailSender
{
public void SendConfirmation(Order order) { /* chỉ lo gửi email */ }
}
// OrderService giờ chỉ "điều phối" quy trình đặt hàng
public class OrderService
{
private readonly OrderValidator _validator;
private readonly OrderRepository _repository;
private readonly OrderEmailSender _emailSender;
private readonly ILogger<OrderService> _logger;
public OrderService(OrderValidator validator, OrderRepository repository,
OrderEmailSender emailSender, ILogger<OrderService> logger)
{
_validator = validator;
_repository = repository;
_emailSender = emailSender;
_logger = logger;
}
public void PlaceOrder(Order order)
{
_validator.Validate(order);
_repository.Save(order);
_emailSender.SendConfirmation(order);
_logger.LogInformation("Order {OrderId} placed", order.Id);
}
}
Hỏi: "Ai sẽ yêu cầu sửa class này?" Nếu câu trả lời là nhiều bộ phận khác nhau (kế toán, marketing, DBA…), hãy tách class.
Nguyên tắc 2
O — Open/Closed Principle
"Software entities should be open for extension, but closed for modification."
Open/Closed
Đóng / Mở Thêm mới · không sửa cũKhi có yêu cầu mới, ta thêm code mới (class mới, implementation mới) thay vì sửa code cũ đã chạy ổn định. Công cụ chính trong C#: interface, abstract class, polymorphism.
- Chuỗi if/else hoặc switch dài theo "loại" (type, kind, method…).
- Mỗi lần thêm một loại mới phải mở lại cùng một file để sửa.
public class ShippingCalculator
{
public decimal Calculate(Order order, string method)
{
switch (method)
{
case "Standard": return 30_000m;
case "Express": return 50_000m + order.Weight * 5_000m;
case "Free": return 0m;
// Thêm "Hỏa tốc"? Lại phải mở file này ra sửa...
default: throw new ArgumentException("Không hỗ trợ");
}
}
}
public interface IShippingMethod
{
string Name { get; }
decimal Calculate(Order order);
}
public class StandardShipping : IShippingMethod
{
public string Name => "Standard";
public decimal Calculate(Order order) => 30_000m;
}
public class ExpressShipping : IShippingMethod
{
public string Name => "Express";
public decimal Calculate(Order order) => 50_000m + order.Weight * 5_000m;
}
// Yêu cầu mới: chỉ cần THÊM class, không đụng vào code cũ
public class SameDayShipping : IShippingMethod
{
public string Name => "SameDay";
public decimal Calculate(Order order) => 80_000m;
}
public class ShippingCalculator
{
private readonly Dictionary<string, IShippingMethod> _methods;
public ShippingCalculator(IEnumerable<IShippingMethod> methods)
=> _methods = methods.ToDictionary(m => m.Name);
public decimal Calculate(Order order, string method)
=> _methods.TryGetValue(method, out var m)
? m.Calculate(order)
: throw new ArgumentException($"Không hỗ trợ: {method}");
}
Với ASP.NET Core, chỉ cần đăng ký thêm một dòng services.AddScoped<IShippingMethod, SameDayShipping>() — ShippingCalculator tự nhận phương thức mới mà không phải sửa.
"Cắm thêm, đừng mổ ra." Giống ổ cắm điện: muốn thêm thiết bị thì cắm vào, không ai đi đục tường đi lại dây.
Nguyên tắc 3
L — Liskov Substitution Principle
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
Liskov Substitution
Thay thế Liskov Con thay được chaỞ bất kỳ đâu dùng class cha, ta thay bằng class con thì chương trình vẫn phải chạy đúng. Kế thừa phải dựa trên hành vi, không chỉ dựa trên quan hệ "là một" (is-a) ngoài đời thực.
- Class con override method rồi throw NotImplementedException / NotSupportedException.
- Code gọi phải kiểm tra if (x is ClassConCụThể) trước khi dùng.
- Class con làm yếu đi cam kết của cha (nhận ít input hơn, trả kết quả khác ý nghĩa).
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
// Hình vuông: đổi cạnh này thì cạnh kia đổi theo
public override int Width { set { base.Width = base.Height = value; } }
public override int Height { set { base.Width = base.Height = value; } }
}
void Resize(Rectangle r)
{
r.Width = 5;
r.Height = 4;
Console.WriteLine(r.Area); // Kỳ vọng 20
}
Resize(new Rectangle()); // 20 ✔
Resize(new Square()); // 16 ✘ — hành vi đã bị phá vỡ
Toán học nói "hình vuông là hình chữ nhật", nhưng trong code, Square không giữ được hành vi "đặt chiều rộng và chiều cao độc lập" của Rectangle. Do đó nó không nên kế thừa.
public interface IShape
{
int Area { get; }
}
public record Rectangle(int Width, int Height) : IShape
{
public int Area => Width * Height;
}
public record Square(int Side) : IShape
{
public int Area => Side * Side;
}
// Bất kỳ IShape nào cũng dùng được ở đây mà không bất ngờ
int TotalArea(IEnumerable<IShape> shapes) => shapes.Sum(s => s.Area);
public class FileStorage
{
public virtual void Write(string path, string content) { /* ... */ }
}
public class ReadOnlyStorage : FileStorage
{
public override void Write(string path, string content)
=> throw new NotSupportedException(); // ✘ con "từ chối" hành vi của cha
}
Cách sửa: tách thành IReadableStorage và IWritableStorage — điều này dẫn ta tới nguyên tắc tiếp theo.
"Nếu nó trông như con vịt, kêu như con vịt nhưng cần pin — thì abstraction của bạn sai rồi."
Nguyên tắc 4
I — Interface Segregation Principle
"Clients should not be forced to depend on interfaces they do not use."
Interface Segregation
Phân tách interface Nhỏ · đúng nhu cầuĐừng ép một class implement những method nó không cần. Hãy chia interface "béo" thành nhiều interface nhỏ theo vai trò; class nào cần vai trò nào thì implement vai trò đó.
- Implementation có nhiều method rỗng hoặc throw NotImplementedException.
- Interface có hàng chục method phục vụ nhiều nhóm client khác nhau.
- Sửa một method trong interface khiến hàng loạt class không liên quan phải build lại.
public interface IMachine
{
void Print(Document doc);
void Scan(Document doc);
void Fax(Document doc);
}
public class BasicPrinter : IMachine
{
public void Print(Document doc) { /* in */ }
public void Scan(Document doc) => throw new NotImplementedException();
public void Fax(Document doc) => throw new NotImplementedException();
}
public interface IPrinter { void Print(Document doc); }
public interface IScanner { void Scan(Document doc); }
public interface IFax { void Fax(Document doc); }
public class BasicPrinter : IPrinter
{
public void Print(Document doc) { /* in */ }
}
public class OfficeMachine : IPrinter, IScanner, IFax
{
public void Print(Document doc) { /* ... */ }
public void Scan(Document doc) { /* ... */ }
public void Fax(Document doc) { /* ... */ }
}
// Client chỉ phụ thuộc đúng thứ nó cần
public class ReportService
{
private readonly IPrinter _printer;
public ReportService(IPrinter printer) => _printer = printer;
}
Chính .NET cũng áp dụng ISP: IEnumerable<T> (chỉ duyệt), IReadOnlyCollection<T> (thêm Count), ICollection<T> (thêm/xóa), IList<T> (truy cập theo index). Method nào chỉ cần duyệt thì nhận IEnumerable<T> là đủ.
"Gọi món theo suất, đừng bắt khách mua cả buffet."
Nguyên tắc 5
D — Dependency Inversion Principle
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Dependency Inversion
Đảo ngược phụ thuộc Phụ thuộc abstractionModule cấp cao (nghiệp vụ) không nên biết chi tiết module cấp thấp (SQL Server, SMTP, API bên thứ ba). Cả hai cùng phụ thuộc vào một interface — và interface đó thuộc về phía nghiệp vụ.
- Từ khóa new tạo dependency cụ thể ngay bên trong class nghiệp vụ.
- Không thể unit test vì class luôn gọi database / API thật.
- Đổi từ SQL Server sang PostgreSQL, từ SMTP sang SendGrid… phải sửa code nghiệp vụ.
public class SmtpEmailSender
{
public void Send(string to, string subject, string body) { /* SMTP */ }
}
public class UserService
{
// Dính chặt vào SMTP, không thay thế, không mock được
private readonly SmtpEmailSender _email = new SmtpEmailSender();
public void Register(string email)
{
// ... tạo user
_email.Send(email, "Chào mừng", "Cảm ơn bạn đã đăng ký Pounitech!");
}
}
// Abstraction do tầng nghiệp vụ định nghĩa
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
// Chi tiết hạ tầng phụ thuộc NGƯỢC LÊN abstraction
public class SmtpEmailSender : IEmailSender
{
public Task SendAsync(string to, string subject, string body) { /* SMTP */ return Task.CompletedTask; }
}
public class SendGridEmailSender : IEmailSender
{
public Task SendAsync(string to, string subject, string body) { /* API */ return Task.CompletedTask; }
}
public class UserService
{
private readonly IEmailSender _email;
public UserService(IEmailSender email) => _email = email; // nhận qua constructor
public async Task RegisterAsync(string email)
{
// ... tạo user
await _email.SendAsync(email, "Chào mừng", "Cảm ơn bạn đã đăng ký Pounitech!");
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Đổi nhà cung cấp email chỉ bằng MỘT dòng, không đụng UserService
builder.Services.AddScoped<IEmailSender, SendGridEmailSender>();
builder.Services.AddScoped<UserService>();
var app = builder.Build();
public class FakeEmailSender : IEmailSender
{
public List<string> SentTo { get; } = new();
public Task SendAsync(string to, string subject, string body)
{
SentTo.Add(to);
return Task.CompletedTask;
}
}
[Fact]
public async Task Register_SendsWelcomeEmail()
{
var fake = new FakeEmailSender();
var service = new UserService(fake);
await service.RegisterAsync("an@example.com");
Assert.Contains("an@example.com", fake.SentTo);
}
"Đừng gọi chúng tôi, chúng tôi sẽ gọi bạn" (Hollywood Principle). Class không tự new thứ nó cần — nó khai báo nhu cầu qua constructor, phần còn lại để DI container lo.
Hay nhầm
Những cặp khái niệm dễ nhầm lẫn
Hiểu đúng ranh giới giúp bạn áp dụng SOLID chính xác hơn.
Đừng nhầm lẫn
Dependency Inversion là nguyên tắc thiết kế (phụ thuộc vào abstraction). Dependency Injection là kỹ thuật để hiện thực nguyên tắc đó (truyền dependency từ bên ngoài vào, thường qua constructor). Dùng DI container nhưng inject class cụ thể thì vẫn chưa đạt DIP.
SRP nhìn từ phía class cung cấp: class này có bao nhiêu lý do để thay đổi? ISP nhìn từ phía client sử dụng: client có bị ép phụ thuộc vào thứ nó không dùng không?
OCP không cấm sửa bug hay refactor. Nó khuyên rằng khi thêm tính năng, thiết kế nên cho phép mở rộng bằng code mới thay vì chỉnh sửa logic cũ đã ổn định.
"Hình vuông là hình chữ nhật" đúng trong toán học nhưng sai về hành vi trong code. LSP xét hành vi, không xét phân loại ngữ nghĩa.
Tra cứu nhanh
Thấy dấu hiệu này — nghĩ ngay tới nguyên tắc nào?
Dùng khi review code hoặc khi cảm thấy code "có mùi".
| Khi thấy dấu hiệu… | Nghĩ ngay tới |
|---|---|
| Class dài hàng nghìn dòng, tên kiểu XxxManager, làm đủ mọi việc | SRP |
| switch / if-else theo loại, thêm loại mới phải sửa file cũ | OCP |
| Class con throw NotSupportedException hoặc phải check is trước khi dùng | LSP |
| Implement interface mà nhiều method rỗng / không dùng | ISP |
| new SqlConnection / HttpClient / SmtpClient ngay trong class nghiệp vụ, không test được | DIP |
Thực hành
Áp dụng SOLID vào dự án thực tế
Đừng refactor toàn bộ dự án trong một đêm — hãy đi từng bước.
Bắt đầu từ nơi đau nhất
Chọn class bị sửa nhiều nhất, hay gây bug nhất (xem lịch sử Git). Đó là nơi SOLID mang lại giá trị lớn nhất.
Viết test trước khi refactor
Có test bảo vệ hành vi hiện tại thì mới yên tâm tách class. Nếu chưa test được, bước đầu tiên chính là áp dụng DIP để có thể inject fake.
Tách trách nhiệm (SRP)
Đưa phần truy cập dữ liệu, gửi thông báo, ghi log… ra các class riêng.
Rút interface tại các điểm hay thay đổi (OCP + DIP)
Chỉ tạo interface ở những chỗ thực sự có khả năng thay đổi: cổng thanh toán, phương thức giao hàng, nhà cung cấp email…
Rà lại kế thừa và interface (LSP + ISP)
Ưu tiên composition over inheritance; chia nhỏ interface khi thấy method rỗng.
SOLID áp dụng máy móc sẽ sinh ra hàng trăm interface chỉ có một implementation và những lớp trừu tượng không ai cần. Hãy nhớ nguyên tắc YAGNI (You Aren't Gonna Need It): một script nhỏ hay một prototype không cần đủ 5 nguyên tắc. Hãy áp dụng SOLID khi sự thay đổi thực sự xuất hiện — hoặc khi bạn biết chắc nó sẽ đến.
Tổng kết
Kết luận
Năm nguyên tắc, một mục tiêu: code dễ thay đổi.
- S — Mỗi class một lý do để thay đổi.
- O — Thêm tính năng bằng cách thêm code, không mổ code cũ.
- L — Class con phải giữ đúng lời hứa của class cha.
- I — Interface nhỏ, đúng nhu cầu của client.
- D — Phụ thuộc vào abstraction, nhận dependency từ bên ngoài.
Năm nguyên tắc này bổ trợ lẫn nhau: SRP và ISP giúp các thành phần nhỏ gọn, OCP và LSP giúp mở rộng an toàn, còn DIP là "keo dán" kết nối mọi thứ một cách lỏng lẻo. Khi nắm vững SOLID, bạn sẽ thấy các design pattern như Strategy, Factory, Decorator, Repository… thực chất chỉ là những cách áp dụng cụ thể của chúng.
Chúc bạn viết code ngày càng "sạch"! Nếu thấy bài viết hữu ích, đừng quên lưu lại và chia sẻ cho đồng đội nhé. 🚀
Bình luận 0
Chưa có bình luận nào. Hãy là người đầu tiên!