SOLID

SOLID

Một phần mềm tốt không chỉ được đánh giá bởi việc nó có thể chạy đúng chức năng, mà còn nằm ở kiến trúc, khả năng mở rộng, bảo trì và mức độ dễ thay đổi của hệ thống. Có thể hình dung kiến trúc phần mềm giống như móng của một ngôi nhà: nền móng càng vững thì công trình càng ổn định và có khả năng phát triển lâu dài. Ngược lại, một hệ thống được xây dựng trên nền tảng kiến trúc thiếu hợp lý sẽ nhanh chóng trở nên khó bảo trì khi yêu cầu thay đổi hoặc quy mô ứng dụng tăng lên. Trong lập trình hướng đối tượng (OOP), một trong những nền tảng quan trọng giúp chúng ta thiết kế hệ thống tốt hơn là SOLID – tập hợp 5 nguyên tắc thiết kế hướng đối tượng, giúp mã nguồn trở nên rõ ràng, dễ bảo trì, dễ mở rộng và có tính tái sử dụng cao.

SOLID – 5 nguyên tắc thiết kế giúp xây dựng phần mềm tốt hơn

Một phần mềm tốt không chỉ được đánh giá bởi việc nó có thể chạy đúng chức năng, mà còn nằm ở kiến trúc, khả năng mở rộng, bảo trì và mức độ dễ thay đổi của hệ thống.

Có thể hình dung kiến trúc phần mềm giống như móng của một ngôi nhà: nền móng càng vững thì công trình càng ổn định và có khả năng phát triển lâu dài. Ngược lại, một hệ thống được xây dựng trên nền tảng kiến trúc thiếu hợp lý sẽ nhanh chóng trở nên khó bảo trì khi yêu cầu thay đổi hoặc quy mô ứng dụng tăng lên.

Trong lập trình hướng đối tượng (OOP), một trong những nền tảng quan trọng giúp chúng ta thiết kế hệ thống tốt hơn là SOLID – tập hợp 5 nguyên tắc thiết kế hướng đối tượng, giúp mã nguồn trở nên rõ ràng, dễ bảo trì, dễ mở rộng và có tính tái sử dụng cao.

1. SOLID ra đời từ đâu?

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 nhiều đặc tính quan trọng giúp chúng ta mô hình hóa các đối tượng và bài toán trong thế giới thực, bao gồm:

  • Tính trừu tượng (Abstraction): Mô hình hóa các đối tượng bằng cách tập trung vào những đặc điểm và hành vi cần thiết.
  • Tính đóng gói (Encapsulation): Đóng gói dữ liệu và hành vi liên quan trong cùng 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 chúng.
  • Tính kế thừa (Inheritance): Cho phép một lớp kế thừa và mở rộng các thuộc tính, hành vi từ lớp khác.
  • Tính đa hình (Polymorphism): Cho phép cùng một hành động đượ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 lập trình viên xây dựng các chương trình có khả năng giải quyết nhiều bài toán khác nhau. Tuy nhiên, biết sử dụng OOP chưa đồng nghĩa với việc biết thiết kế một hệ thống OOP tốt.

Vấn đề quan trọng nằm ở cách chúng ta tổ chức và kết hợp các đặc tính của OOP để tạo ra một hệ thống có cấu trúc hợp lý, dễ thay đổi và ít phụ thuộc lẫn nhau.

Đây chính là một trong những mục tiêu mà SOLID hướng tới.

2. SOLID là gì?

SOLID là viết tắt của 5 nguyên tắc thiết kế hướng đối tượng, được đưa ra nhằm giúp lập trình viên xây dựng mã nguồn dễ đọc, dễ hiểu, dễ bảo trì và dễ mở rộng.

Năm nguyên tắc bao gồm:

  • S – Single Responsibility Principle (SRP): Nguyên tắc trách nhiệm đơn.
  • O – Open/Closed Principle (OCP): Nguyên tắc đóng/mở.
  • L – Liskov Substitution Principle (LSP): Nguyên tắc thay thế Liskov.
  • I – Interface Segregation Principle (ISP): Nguyên tắc phân tách interface.
  • D – Dependency Inversion Principle (DIP): Nguyên tắc đảo ngược sự phụ thuộc.

Hãy cùng tìm hiểu từng nguyên tắc thông qua các ví dụ cụ thể.

3. Single Responsibility Principle – SRP

Nội dung

Mỗi lớp chỉ nên có một trách nhiệm duy nhất.

SRP là nguyên tắc đầu tiên trong SOLID. Một class không nên đảm nhận quá nhiều chức năng khác nhau. Khi một class có quá nhiều trách nhiệm, nó sẽ nhanh chóng trở nên cồng kềnh, khó đọc và khó bảo trì.

Trong thực tế phát triển phần mềm, yêu cầu thường xuyên thay đổi: khách hàng có thể bổ sung chức năng, điều chỉnh nghiệp vụ hoặc thay đổi cách hệ thống vận hành. Vì vậy, việc tổ chức code rõ ràng và phân tách trách nhiệm hợp lý là yếu tố rất quan trọng.

Ví dụ

Giả sử một công ty phần mềm có ba vị trí:

  • Developer – phát triển phần mềm.
  • Tester – kiểm thử phần mềm.
  • Salesman – kinh doanh phần mềm.

Một thiết kế ban đầu có thể là:

class Employee
{
    string position;

    function developSoftware(){};
    function testSoftware(){};
    function saleSoftware(){};
}

Thiết kế này có vấn đề vì Employee đang đảm nhận quá nhiều trách nhiệm.

Developer không cần testSoftware() hay saleSoftware(). Tester cũng không cần developSoftware(). Nếu hệ thống tiếp tục bổ sung thêm các vị trí như HR, Project Manager, Accountant..., class Employee sẽ ngày càng phình to.

Không những vậy, các method không liên quan còn có thể bị sử dụng nhầm, làm tăng nguy cơ phát sinh lỗi.

Áp dụng SRP

Thay vì đặt tất cả chức năng vào một class, chúng ta có thể tạo một lớp trừu tượng Employee với hành vi chung là working():

abstract class Employee
{
    public abstract function working();
}

Sau đó tạo các lớp cụ thể:

class Developer extends Employee
{
    public function working()
    {
        // Develop software
    }
}

class Tester extends Employee
{
    public function working()
    {
        // Test software
    }
}

class Salesman extends Employee
{
    public function working()
    {
        // Sell software
    }
}

Mỗi class chỉ tập trung vào một trách nhiệm cụ thể, giúp code dễ đọc, dễ kiểm thử và dễ mở rộng hơn.

4. Open/Closed Principle – OCP

Nội dung

Một class nên mở cho việc mở rộng nhưng đóng đối với việc sửa đổi.

OCP là nguyên tắc thứ hai trong SOLID.

Khi cần bổ sung chức năng, thay vì liên tục sửa đổi code hiện có, chúng ta nên thiết kế hệ thống sao cho có thể mở rộng bằng cách thêm thành phần mới mà hạn chế ảnh hưởng đến những thành phần đã ổn định.

Điều này đặc biệt hữu ích trong những hệ thống thường xuyên phải bổ sung tính năng.

Ví dụ

Giả sử chúng ta xây dựng một class chịu trách nhiệm kết nối cơ sở dữ liệu:

class ConnectionManager
{
    public function doConnection(Object $connection)
    {
        if ($connection instanceof SqlServer) {
            // Connect with SqlServer
        } elseif ($connection instanceof MySql) {
            // Connect with MySql
        }
    }
}

Ban đầu hệ thống chỉ hỗ trợ SQL Server và MySQL.

Sau đó, yêu cầu mới xuất hiện: hệ thống phải hỗ trợ thêm Oracle và các hệ quản trị cơ sở dữ liệu khác.

Nếu tiếp tục sử dụng cách thiết kế trên, chúng ta sẽ phải liên tục bổ sung thêm các nhánh else if:

if (...)
{
    ...
}
elseif (...)
{
    ...
}
elseif (...)
{
    ...
}

Khi số lượng loại database tăng lên, class ConnectionManager sẽ ngày càng phức tạp và khó bảo trì.

Giải pháp

Chúng ta có thể tách phần thay đổi ra khỏi phần ổn định bằng cách sử dụng abstraction.

Tạo một class cơ sở Connection:

abstract class Connection
{
    public abstract function doConnect();
}

Các loại database cụ thể sẽ kế thừa class này:

class SqlServer extends Connection
{
    public function doConnect()
    {
        // Connect with SqlServer
    }
}

class MySql extends Connection
{
    public function doConnect()
    {
        // Connect with MySql
    }
}

ConnectionManager chỉ cần làm việc với abstraction:

class ConnectionManager
{
    public function doConnection(Connection $connection)
    {
        $connection->doConnect();
    }
}

Khi cần hỗ trợ một database mới, chúng ta chỉ cần tạo thêm một class kế thừa Connection, thay vì sửa đổi ConnectionManager.

Đây chính là tinh thần của Open/Closed Principle: mở để mở rộng, đóng để sửa đổi.

5. Liskov Substitution Principle – LSP

Nội dung

Đối tượng của lớp con phải có thể thay thế đối tượng của lớp cha mà không làm thay đổi tính đúng đắn của chương trình.

LSP là nguyên tắc thứ ba trong SOLID.

Nói cách khác, khi một class con kế thừa từ class cha, class con phải tuân thủ các hành vi và kỳ vọng mà class cha đã đặt ra.

Ví dụ

Quay lại ví dụ Employee.

Giả sử công ty có quy định nhân viên chính thức phải điểm danh mỗi buổi sáng. Khi đó, chúng ta thêm method:

checkAttendance()

vào Employee.

Tuy nhiên, công ty cũng thuê nhân viên lao công theo thời vụ. Nhân viên này không có ID nhân viên chính thức nên không thuộc nhóm được phép điểm danh.

Nếu chúng ta tạo:

class CleanerStaff extends Employee

thì CleanerStaff sẽ tự động có checkAttendance().

Đây là vấn đề về thiết kế: CleanerStaff không thực sự thỏa mãn đầy đủ các hành vi mà Employee yêu cầu.

Khi đó, việc kế thừa CleanerStaff từ Employee có thể dẫn đến thiết kế vi phạm LSP.

Cách giải quyết

Một giải pháp là tách checkAttendance() thành một interface riêng.

Chỉ những loại nhân viên thực sự có khả năng điểm danh mới implement interface này.

Nhờ đó, chúng ta tránh việc ép một class phải kế thừa những hành vi mà nó không phù hợp.

6. Interface Segregation Principle – ISP

Nội dung

Thay vì sử dụng một interface lớn, nên tách thành nhiều interface nhỏ, mỗi interface phục vụ một mục đích cụ thể.

ISP là nguyên tắc thứ tư trong SOLID.

Hãy tưởng tượng chúng ta có một interface chứa tới 100 method. Mọi class implement interface này đều phải triển khai toàn bộ 100 method, ngay cả khi chúng chỉ thực sự cần một vài method trong số đó.

Điều này tạo ra code dư thừa và làm tăng mức độ phụ thuộc không cần thiết.

Ví dụ

Giả sử chúng ta có interface:

interface Animal
{
    void eat();
    void run();
    void fly();
}

Nếu cả DogSnake đều implement interface này, chúng ta sẽ gặp vấn đề:

  • Dog có thể eat()run(), nhưng không thể fly().
  • Snake có thể eat() và một số loài có thể di chuyển bằng cách bò, nhưng không phù hợp với việc bắt buộc phải run() hoặc fly().

Thay vì tạo một interface quá lớn, chúng ta có thể chia nhỏ:

interface Animal
{
    void eat();
}

interface RunnableAnimal extends Animal
{
    void run();
}

interface FlyableAnimal extends Animal
{
    void fly();
}

Mỗi class chỉ cần implement những interface chứa các hành vi thực sự cần thiết.

Kết quả là code trở nên rõ ràng hơn, giảm phụ thuộc và dễ quản lý hơn.

7. Dependency Inversion Principle – DIP

Nội dung

DIP gồm hai nguyên tắc chính:

  1. Các module cấp cao không nên phụ thuộc trực tiếp vào các module cấp thấp. Cả hai nên phụ thuộc vào abstraction.
  2. Abstraction không nên phụ thuộc vào implementation; implementation nên phụ thuộc vào abstraction.

Có thể hiểu đơn giản rằng các thành phần trong hệ thống nên giao tiếp với nhau thông qua abstraction/interface, thay vì phụ thuộc trực tiếp vào một implementation cụ thể.

Tại sao cần Dependency Inversion?

Các abstraction thường đại diện cho những đặc tính ổn định và ít thay đổi hơn. Trong khi đó, implementation cụ thể có thể thay đổi tùy theo nhu cầu của hệ thống.

Khi module cấp cao phụ thuộc trực tiếp vào implementation, chỉ cần implementation thay đổi, module cấp cao cũng có thể phải thay đổi theo.

Ngược lại, nếu cả hai cùng phụ thuộc vào một abstraction, implementation có thể được thay thế linh hoạt hơn.

Ví dụ thực tế

Hãy hình dung về ổ cứng máy tính.

Mainboard không cần biết bạn sử dụng:

  • SSD;
  • HDD;
  • hay một loại ổ cứng cụ thể nào khác.

Điều quan trọng là thiết bị phải tuân thủ chuẩn giao tiếp mà mainboard hỗ trợ, chẳng hạn SATA.

Trong ví dụ này:

  • SATA đóng vai trò tương tự abstraction/interface.
  • SSD hoặc HDD là các implementation 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, chúng ta có thể tạo abstraction:

interface DataAccess
{
    public function save();
    public function get();
}

Sau đó các implementation cụ thể có thể triển khai interface này:

class MySqlDataAccess implements DataAccess
{
    public function save()
    {
        // Save data to MySQL
    }

    public function get()
    {
        // Get data from MySQL
    }
}

Nếu sau này cần chuyển sang MongoDB hoặc một hệ quản trị cơ sở dữ liệu khác, chúng ta có thể tạo implementation mới mà không cần thay đổi quá nhiều logic ở tầng sử dụng abstraction.

Đây là sức mạnh quan trọng của Dependency Inversion: giảm sự phụ thuộc trực tiếp giữa các module và giúp hệ thống linh hoạt hơn trước những thay đổi.

8. Tổng kết

SOLID là tập hợp 5 nguyên tắc quan trọng trong thiết kế phần mềm hướng đối tượng. Mục tiêu không phải là biến mọi đoạn code thành một hệ thống phức tạp với thật nhiều class và interface, mà là giúp chúng ta tổ chức các thành phần trong hệ thống một cách hợp lý.

Năm nguyên tắc có thể được tóm tắt như sau:

Nguyên tắcÝ nghĩa
S – Single ResponsibilityMột class nên tập trung vào một trách nhiệm
O – Open/ClosedMở cho mở rộng, hạn chế sửa đổi code đã ổn định
L – Liskov SubstitutionClass con phải có thể thay thế class cha mà không phá vỡ chương trình
I – Interface SegregationƯu tiên nhiều interface nhỏ thay vì một interface quá lớn
D – Dependency InversionPhụ thuộc vào abstraction thay vì implementation cụ thể

SOLID mang lại những lợi ích gì?

1. Code rõ ràng và dễ hiểu

SOLID khuyến khích chúng ta phân chia trách nhiệm một cách hợp lý, từ đó giúp code dễ đọc và dễ hiểu hơn, đặc biệt trong môi trường làm việc nhóm.

2. Dễ thay đổi và mở rộng

Khi các module được thiết kế độc lập và giảm sự phụ thuộc lẫn nhau, việc bổ sung hoặc thay đổi yêu cầu sẽ ít gây ảnh hưởng dây chuyền đến toàn bộ hệ thống.

3. Tăng khả năng tái sử dụng

Các class và module có trách nhiệm rõ ràng, phụ thuộc vào abstraction và được thiết kế linh hoạt sẽ có nhiều cơ hội được tái sử dụng trong những phần khác của hệ thống.

4. Dễ bảo trì và kiểm thử

Một hệ thống được thiết kế theo các nguyên tắc SOLID thường có các thành phần nhỏ, ít phụ thuộc và có trách nhiệm rõ ràng. Điều này giúp việc kiểm thử, phát hiện lỗi và bảo trì trở nên thuận lợi hơn.

SOLID không phải là một công thức bắt buộc phải áp dụng trong mọi tình huống. Giá trị thực sự của SOLID nằm ở việc giúp lập trình viên hình thành tư duy thiết kế: biết cách phân chia trách nhiệm, kiểm soát sự phụ thuộc và xây dựng hệ thống có khả năng thích ứng với thay đổi.

Khi được áp dụng đúng mức và đúng hoàn cảnh, SOLID sẽ trở thành một nền tảng quan trọng giúp chúng ta xây dựng những hệ thống phần mềm dễ hiểu, dễ bảo trì, dễ mở rộng và có tuổi thọ lâu dài.