SOLID ra đời như thế nào?
Lập trình hướng đối tượng (Object-Oriented Programming – OOP) là một trong những mô hình lập trình được sử dụng phổ biến. OOP cung cấp các đặc tính giúp chúng ta mô hình hóa và tổ chức chương trình theo các đối tượng, bao gồm:
- Tính trừu tượng (Abstraction): Tập trung vào những đặc điểm và hành vi cần thiết của đối tượng, đồng thời che giấu những chi tiết không cần thiết.
- Tính đóng gói (Encapsulation): Đóng gói dữ liệu và các hành vi liên quan vào trong một đối tượng, đồng thời kiểm soát cách các thành phần bên ngoài truy cập và thay đổi trạng thái của đối tượng.
- Tính kế thừa (Inheritance): Cho phép một lớp kế thừa các thuộc tính và hành vi từ một lớp khác, từ đó có thể tái sử dụng hoặc mở rộng chúng.
- Tính đa hình (Polymorphism): Cho phép cùng một lời gọi hoặc giao diện có thể được thực hiện theo những cách khác nhau tùy thuộc vào đối tượng cụ thể.
Những đặc tính này giúp chúng ta xây dựng các chương trình có cấu trúc và giải quyết được nhiều vấn đề khác nhau. Tuy nhiên, biết và sử dụng các đặc tính của OOP không có nghĩa là chương trình đã được thiết kế tốt. Cách chúng ta phân chia trách nhiệm, xây dựng mối quan hệ giữa các lớp và quản lý sự phụ thuộc giữa các thành phần cũng ảnh hưởng rất lớn đến khả năng đọc, thay đổi và bảo trì của hệ thống.
Một trong những nhóm nguyên tắc giúp chúng ta thiết kế phần mềm hướng đối tượng tốt hơn là SOLID.
SOLID là gì?
SOLID là từ viết tắt của 5 nguyên tắc thiết kế phần mềm hướng đối tượng. Các nguyên tắc này giúp lập trình viên xây dựng code dễ hiểu, dễ thay đổi, mở rộng và bảo trì hơn.
Thuật ngữ SOLID được Robert C. Martin đặt ra để gọi chung cho 5 nguyên tắc thiết kế. Các nguyên tắc đó bao gồm:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Single Responsibility Principle (SRP)
![]()
Nội dung:
Một class nên có một trách nhiệm chính và một lý do để thay đổi.
Nguyên lý đầu tiên ứng với chữ S trong SOLID, nói rằng một class không nên đảm nhiệm quá nhiều nhóm trách nhiệm khác nhau. Khi một class có quá nhiều trách nhiệm, nó sẽ trở nên cồng kềnh, khó đọc, khó hiểu và khó bảo trì.
Trong quá trình phát triển phần mềm, requirement thường xuyên thay đổi và việc thêm hoặc sửa chức năng là điều bình thường. Nếu một class đảm nhiệm nhiều trách nhiệm, một thay đổi ở một chức năng có thể khiến class đó phải thay đổi dù các chức năng khác không liên quan. Điều này làm tăng sự phụ thuộc giữa các phần của chương trình và khiến việc bảo trì trở nên khó khăn hơn.
Vì vậy, SRP hướng chúng ta đến việc phân chia trách nhiệm của chương trình thành các class phù hợp, sao cho mỗi class tập trung vào một nhóm trách nhiệm có liên quan với nhau.
Ví dụ trái với SRP

Để tuân thủ SRP, chúng ta tách chức năng lưu vào cơ sở dữ liệu ra khỏi lớp Book:

Chúng ta có một lớp Book có nhiệm vụ quản lý thông tin về sách và một phương thức để in sách. Nếu chúng ta muốn thêm chức năng lưu thông tin sách vào cơ sở dữ liệu, chúng ta không nên thêm phương thức đó vào lớp Book. Thay vào đó, chúng ta nên tạo một lớp mới có trách nhiệm là lưu thông tin sách.
Open-Closed Principle (OCP)
![]()
Nội dung:
Một module hoặc class nên mở cho việc mở rộng nhưng đóng đối với việc sửa đổi.
Nguyên lý thứ 2 ứng với chữ O trong SOLID.
Theo nguyên lý này, khi muốn thêm chức năng mới cho chương trình, chúng ta nên thiết kế sao cho có thể mở rộng hành vi bằng cách thêm các thành phần mới, thay vì phải sửa đổi nhiều phần code đã ổn định. Việc mở rộng có thể được thực hiện thông qua các cơ chế như kế thừa, đa hình, interface hoặc composition.
Ví dụ, khi muốn thêm một phương thức thanh toán mới, không nên phải liên tục sửa các câu lệnh điều kiện trong class xử lý thanh toán. Thay vào đó, có thể tạo implementation mới tuân theo một interface hoặc abstraction có sẵn.
Trước khi áp dụng OCP:
class PaymentService
{
public void Pay(string paymentMethod, decimal amount)
{
if (paymentMethod == "Cash")
{
Console.WriteLine($"Thanh toán {amount} bằng tiền mặt");
}
else if (paymentMethod == "Card")
{
Console.WriteLine($"Thanh toán {amount} bằng thẻ");
}
}
}
Trong trường hợp này, nếu muốn thêm phương thức thanh toán mới như MoMo, chúng ta phải sửa PaymentService và thêm một câu lệnh điều kiện mới:
else if (paymentMethod == "MoMo")
{
Console.WriteLine($"Thanh toán {amount} bằng MoMo");
}
Điều này khiến class PaymentService liên tục phải thay đổi mỗi khi hệ thống hỗ trợ thêm một phương thức thanh toán.
Sau khi áp dụng OCP:
interface IPayment
{
void Pay(decimal amount);
}
class CashPayment : IPayment
{
public void Pay(decimal amount)
{
Console.WriteLine($"Thanh toán {amount} bằng tiền mặt");
}
}
class CardPayment : IPayment
{
public void Pay(decimal amount)
{
Console.WriteLine($"Thanh toán {amount} bằng thẻ");
}
}
class PaymentService
{
public void Process(IPayment payment, decimal amount)
{
payment.Pay(amount);
}
}
Khi muốn thêm phương thức thanh toán mới, chẳng hạn MoMo, chúng ta chỉ cần tạo implementation mới:
class MomoPayment : IPayment
{
public void Pay(decimal amount)
{
Console.WriteLine($"Thanh toán {amount} bằng MoMo");
}
}
Lúc này PaymentService không cần phải sửa đổi. Chúng ta chỉ cần thêm implementation mới của IPayment.
Điều này giúp hạn chế việc thay đổi trực tiếp code cũ và giảm nguy cơ làm ảnh hưởng đến những chức năng đã hoạt động ổn định. Tuy nhiên, OCP không có nghĩa là code cũ tuyệt đối không được sửa hoặc khi mở rộng thì không cần kiểm thử lại hệ thống.
Thông thường, việc mở rộng chức năng sẽ cần thêm code. Vì vậy, để thiết kế một module có thể dễ dàng mở rộng nhưng hạn chế việc sửa đổi code đã ổn định, chúng ta cần xác định những phần có khả năng thay đổi và tách chúng khỏi những phần ít thay đổi hơn thông qua các abstraction phù hợp.
Liskov Substitution Principle (LSP)
![]()
Nội dung:
Trong một chương trình, các object của class con có thể thay thế class cha mà không làm thay đổi tính đúng đắn của chương trình
Nguyên tắc thứ ba, Liskov Substitution Principle (LSP), nói rằng class con khi kế thừa class cha phải tuân thủ những hành vi và quy tắc mà class cha đã định nghĩa, để có thể được sử dụng thay thế cho class cha mà chương trình vẫn hoạt động đúng.
Ví dụ, nếu Bird đã định nghĩa phương thức Fly(), thì class con kế thừa Bird phải đảm bảo rằng việc gọi Fly() vẫn hoạt động đúng theo những gì chương trình mong đợi. Nếu một class con không thể thực hiện hành vi mà class cha đã cam kết, việc kế thừa đó có thể vi phạm LSP.
Ví dụ:
class Bird
{
public virtual void Fly()
{
Console.WriteLine("Bird is flying");
}
}
class Eagle : Bird
{
public override void Fly()
{
Console.WriteLine("Eagle is flying");
}
}
Bird bird = new Eagle();
bird.Fly();
Trong ví dụ trên, Eagle có thể thay thế Bird. Mặc dù biến bird có kiểu Bird, object thực tế là Eagle và chương trình vẫn hoạt động đúng.
Ngược lại, nếu một class con không thể thực hiện hành vi mà class cha đã cam kết:
class Penguin : Bird
{
public override void Fly()
{
throw new Exception("Penguin cannot fly");
}
}
Khi đó:
Bird bird = new Penguin();
bird.Fly();
chương trình sẽ phát sinh lỗi. Penguin không thể thay thế Bird trong ngữ cảnh mà Bird yêu cầu phải có khả năng Fly(). Đây là dấu hiệu cho thấy thiết kế có thể vi phạm LSP.
Có thể hiểu đơn giản rằng:
Class con phải có thể thay thế class cha mà không làm chương trình hoạt động sai.
LSP không có nghĩa là class con phải giống hoàn toàn class cha hoặc phải sử dụng mọi phương thức theo cùng một cách. Điều quan trọng là class con không được phá vỡ những hành vi mà code sử dụng class cha đang mong đợi.
Ví dụ Bird → Eagle/Penguin là một ví dụ kinh điển nhưng cũng cho thấy vấn đề trong thiết kế class cha. Nếu không phải mọi loại Bird đều có khả năng bay, thì việc đặt Fly() trong Bird có thể khiến một số class con không thể đáp ứng đúng hành vi mà class cha cam kết.
Qua ví dụ trên, vấn đề không nằm ở việc Penguin không có khả năng bay, mà nằm ở việc Bird đã đưa ra một hành vi mà mọi class con không thể đáp ứng. Trong trường hợp này, có thể tách khả năng bay thành một abstraction riêng để tránh ép những class không bay được phải triển khai hành vi này.
Interface Segregation Principle (ISP)
![]()
Nội dung:
Thay vì sử dụng một interface lớn chứa nhiều phương thức, chúng ta nên tách thành nhiều interface nhỏ, với mỗi interface tập trung vào một nhóm chức năng cụ thể.
Nguyên lý thứ tư, Interface Segregation Principle (ISP), nói rằng một class không nên bị buộc phải phụ thuộc vào những phương thức mà nó không sử dụng.
Nguyên lý này khá dễ hiểu. Hãy tưởng tượng chúng ta có một interface lớn chứa khoảng 100 phương thức. Khi một class implements interface này, class đó sẽ phải triển khai toàn bộ các phương thức, kể cả những phương thức mà nó không cần sử dụng. Điều này có thể dẫn đến code dư thừa và làm cho việc quản lý, thay đổi interface trở nên khó khăn hơn.
Thay vì tạo một interface lớn, chúng ta có thể tách nó thành nhiều interface nhỏ, trong đó mỗi interface chứa các phương thức có liên quan với nhau. Khi đó, class chỉ cần implements những interface chứa các chức năng mà nó thực sự cần.
Ví dụ, thay vì:
interface IWorker
{
void Work();
void Eat();
void Sleep();
}
Một class chỉ cần làm việc nhưng không cần các chức năng khác có thể bị buộc phải triển khai những phương thức không cần thiết.
Có thể tách thành:
interface IWorkable
{
void Work();
}
interface IEatable
{
void Eat();
}
interface ISleepable
{
void Sleep();
}
Khi đó, class chỉ cần triển khai những interface phù hợp với trách nhiệm của nó:
class Robot : IWorkable
{
public void Work()
{
Console.WriteLine("Robot is working");
}
}
class Human : IWorkable, IEatable, ISleepable
{
public void Work()
{
Console.WriteLine("Human is working");
}
public void Eat()
{
Console.WriteLine("Human is eating");
}
public void Sleep()
{
Console.WriteLine("Human is sleeping");
}
}
Như vậy, ISP giúp giảm sự phụ thuộc không cần thiết giữa class và interface, đồng thời giúp code dễ hiểu, dễ thay đổi và dễ quản lý hơn.
Dependency Inversion Principle (DIP)
![]()
Nội dung:
Nguyên lý thứ năm, Dependency Inversion Principle (DIP), gồm hai ý chính:
-
Các module cấp cao không nên phụ thuộc vào các module cấp thấp. Cả hai nên phụ thuộc vào abstraction.
-
Abstraction không nên phụ thuộc vào chi tiết, mà chi tiết nên phụ thuộc vào abstraction.
Có thể hiểu đơn giản rằng các class nên giao tiếp với nhau thông qua interface hoặc abstraction, thay vì phụ thuộc trực tiếp vào một implementation cụ thể.
Ví dụ, trong máy tính, bo mạch chủ có thể sử dụng nhiều loại ổ cứng khác nhau như SSD hoặc HDD. Nhà sản xuất bo mạch chủ không cần biết cụ thể người dùng sẽ sử dụng loại ổ cứng nào. Điều kiện là ổ cứng phải tuân theo chuẩn giao tiếp mà bo mạch chủ hỗ trợ, chẳng hạn SATA.
Trong ví dụ này, SATA có thể được xem như abstraction/interface, còn SSD và HDD là những implementation cụ thể. Bo mạch chủ phụ thuộc vào chuẩn giao tiếp thay vì phụ thuộc trực tiếp vào một loại ổ cứng cụ thể.
Trong lập trình cũng tương tự. Ví dụ, thay vì để một module cấp cao phụ thuộc trực tiếp vào MySQL, ta có thể tạo một abstraction:
interface IDataAccess
{
void Save();
void Get();
}
Sau đó tạo các implementation cụ thể:
class MySqlDataAccess : IDataAccess
{
public void Save()
{
Console.WriteLine("Save to MySQL");
}
public void Get()
{
Console.WriteLine("Get from MySQL");
}
}
class MongoDataAccess : IDataAccess
{
public void Save()
{
Console.WriteLine("Save to MongoDB");
}
public void Get()
{
Console.WriteLine("Get from MongoDB");
}
}
Module cấp cao chỉ cần phụ thuộc vào IDataAccess:
class UserService
{
private readonly IDataAccess dataAccess;
public UserService(IDataAccess dataAccess)
{
this.dataAccess = dataAccess;
}
public void SaveUser()
{
dataAccess.Save();
}
}
Khi đó, UserService không cần biết đang sử dụng MySQL hay MongoDB. Chỉ cần implementation cụ thể tuân theo IDataAccess là có thể sử dụng được.
Nhờ vậy, nếu muốn thay đổi từ MySQL sang MongoDB, module cấp cao không cần phải thay đổi. Đây chính là ý tưởng của Dependency Inversion: thay vì để module cấp cao phụ thuộc trực tiếp vào chi tiết triển khai, cả module cấp cao và module cấp thấp cùng phụ thuộc vào một abstraction.
Có thể hiểu đơn giản:
Module cấp cao không nên phụ thuộc trực tiếp vào implementation cụ thể. Nó nên phụ thuộc vào abstraction.
Kết luận:
SOLID không phải là những quy tắc bắt buộc phải áp dụng một cách máy móc, mà là những nguyên tắc giúp chúng ta thiết kế code dễ hiểu, dễ thay đổi và dễ mở rộng hơn.
Năm nguyên lý tập trung vào những vấn đề khác nhau trong thiết kế hướng đối tượng: SRP giúp phân tách trách nhiệm, OCP giúp hạn chế việc sửa code đã ổn định khi mở rộng, LSP đảm bảo class con có thể thay thế class cha, ISP giúp giảm sự phụ thuộc không cần thiết vào interface, và DIP giúp các module phụ thuộc vào abstraction thay vì implementation cụ thể.
Mục tiêu cuối cùng của SOLID là giảm sự phụ thuộc và hạn chế những thay đổi không cần thiết lan sang các phần khác của hệ thống. Tuy nhiên, việc áp dụng SOLID cần dựa vào tình huống thực tế, vì nếu áp dụng quá mức có thể khiến code trở nên phức tạp hơn thay vì đơn giản hơn.






Khoá học lập trình game con rắn cho trẻ em




