Nguyên Lý SOLID

Ha Ngoc HieuHa Ngoc Hieu
24 min read

Việc nắm vững SOLID không chỉ giúp bạn viết code tốt hơn mà còn là một bước đệm quan trọng để trở thành một lập trình viên giỏi, có khả năng xây dựng các hệ thống phần mềm linh hoạt, dễ bảo trì và mở rộng.

I. Giới thiệu về SOLID

A. SOLID là gì?

SOLID là một từ viết tắt được tạo ra bởi Michael Feathers, dựa trên năm nguyên tắc thiết kế hướng đối tượng do Robert C. Martin giới thiệu và phổ biến vào đầu những năm 2000. SOLID không phải là một ngôn ngữ lập trình hay một framework cụ thể, mà là một tập hợp các nguyên tắc hoặc hướng dẫn giúp chúng ta thiết kế phần mềm một cách hiệu quả hơn.

Năm chữ cái trong SOLID đại diện cho năm nguyên tắc sau :

  • S - Single Responsibility Principle (Nguyên tắc Trách nhiệm Đơn lẻ)

  • O - Open/Closed Principle (Nguyên tắc Đóng/Mở)

  • L - Liskov Substitution Principle (Nguyên tắc Thay thế Liskov)

  • I - Interface Segregation Principle (Nguyên tắc Phân tách Interface)

  • D - Dependency Inversion Principle (Nguyên tắc Đảo ngược Phụ thuộc)

B. Tại sao SOLID quan trọng?

Trong quá trình phát triển phần mềm, mục tiêu không chỉ đơn giản là "làm cho nó chạy được". Khi dự án phát triển, code có thể trở nên phức tạp, khó hiểu, khó sửa lỗi và khó thêm tính năng mới. Đây là lúc các nguyên tắc SOLID phát huy tác dụng.

Việc áp dụng SOLID giúp chúng ta :

  • Tăng khả năng bảo trì (Maintainability): Các đoạn code dễ hiểu dễ đọc giúp việc xác định và sửa đổi code để sửa lỗi hoặc thay đổi yêu cầu trở nên dễ dàng hơn, ít gây ảnh hưởng đến các phần khác. Đồng thời các đoạn code dễ hiểu dễ đọc cũng giúp các thành viên trong nhóm dễ dàng hợp tác với nhau hơn.

  • Tăng khả năng mở rộng (Extensibility): Dễ dàng thêm các tính năng mới mà không cần sửa đổi quá nhiều code hiện có.

  • Tăng tính linh hoạt (Flexibility): Hệ thống dễ dàng thích ứng với những thay đổi.

  • Tăng khả năng tái sử dụng (Reusability): Các thành phần được thiết kế tốt có thể được sử dụng lại trong các dự án khác.

  • Tăng khả năng kiểm thử (Testability): Code được chia nhỏ và ít phụ thuộc lẫn nhau sẽ dễ dàng viết Unit Test hơn.

Về cơ bản, SOLID là kim chỉ nam giúp bạn vận dụng hiệu quả các khái niệm cốt lõi của Lập trình Hướng đối tượng (OOP) như Tính trừu tượng (Abstraction), Tính bao đóng (Encapsulation), Tính kế thừa (Inheritance), và Tính đa hình (Polymorphism) để xây dựng phần mềm chất lượng cao.

II. Phân tích Nguyên tắc SOLID

Bây giờ, chúng ta sẽ đi sâu vào từng nguyên tắc một.

S - Single Responsibility Principle (Nguyên tắc Trách nhiệm Đơn lẻ)

Nguyên tắc này phát biểu rằng:

Một lớp (class) chỉ nên có một và chỉ một lý do để thay đổi.

Hay nói cách khác một lớp chỉ nên đảm nhiệm một trách nhiệm (responsibility) hoặc một công việc (job) duy nhất. Nếu một lớp làm quá nhiều việc, nó sẽ trở nên cồng kềnh, phức tạp và khó quản lý.

  • Lợi ích chính:

    • Dễ Đọc & Dễ Hiểu: Nhìn vào tên class là biết ngay nó làm gì, không cần phải đọc toàn bộ code bên trong để đoán.

    • Dễ bảo trì, Dễ mở rộng: Khi cần thay đổi một chức năng, một logic tính toán, bạn chỉ cần sửa đổi class chịu trách nhiệm cho chức năng đó, giảm nguy cơ ảnh hưởng đến các phần khác.

    • Dễ Tái Sử Dụng & Kiểm Thử (Test): Các lớp nhỏ, tập trung dễ viết Unit Test hơn.

    • Tăng khả năng tái sử dụng: Lớp có trách nhiệm cụ thể dễ được dùng lại ở nhiều nơi.

    • Giảm sự phụ thuộc (Lower coupling): Lớp ít chức năng hơn sẽ ít phụ thuộc vào các lớp khác hơn.

    • Giảm xung đột khi làm việc nhóm: Các nhóm khác nhau ít có khả năng phải sửa cùng một lớp vì những lý do khác nhau.

  • Ví dụ minh họa (Đời thực):

    • Thợ xây nhà: Thay vì một người thợ làm tất cả mọi việc (xây, điện, nước, sơn), chúng ta thuê các thợ chuyên biệt (thợ nề, thợ điện, thợ nước, thợ sơn). Khi cần sửa đường ống nước, bạn chỉ cần gọi thợ nước mà không ảnh hưởng đến công việc của thợ điện hay thợ sơn.
  • Ví dụ minh họa (Code):

    • Lớp User tạo bài viết và ghi log lỗi:

      • Vi phạm SRP: Một lớp User có phương thức CreatePost vừa ghi bài viết vào CSDL, vừa ghi log lỗi vào file. Lớp này có 2 lý do để thay đổi: logic tạo post thay đổi, hoặc cách ghi log thay đổi.

      • Tuân thủ SRP: Tách nhiệm vụ ghi log ra lớp ErrorLogger riêng. Lớp Post (hoặc User) chỉ gọi errorLogger.log() khi có lỗi. Bây giờ Post chỉ lo việc tạo post, ErrorLogger chỉ lo việc ghi log.

    • Lớp AreaCalculator tính toán và định dạng output:

      • Vi phạm SRP: Lớp AreaCalculator vừa tính tổng diện tích các hình, vừa có phương thức output() để định dạng kết quả thành HTML. Nếu muốn output JSON thì phải sửa lớp này.

      • Tuân thủ SRP: Lớp AreaCalculator chỉ tính tổng diện tích (sum()). Tạo lớp SumCalculatorOutputter riêng để xử lý việc định dạng output (HTML, JSON,...).

  • SRP là một trong những nguyên tắc cơ bản và quan trọng nhất. Nó đặt nền móng cho các nguyên tắc khác. Khi các lớp được thiết kế với trách nhiệm đơn lẻ, chúng có xu hướng gắn kết nội tại cao hơn (high cohesion) - tức là các thành phần bên trong lớp liên quan chặt chẽ đến nhau để thực hiện một mục tiêu duy nhất. Đồng thời, sự phụ thuộc giữa các lớp khác nhau (coupling) cũng giảm đi. High cohesion và low coupling là những đặc tính quan trọng của một thiết kế phần mềm tốt, dễ bảo trì và phát triển. Việc xác định "một trách nhiệm" đôi khi không dễ dàng và phụ thuộc vào ngữ cảnh, nhưng mục tiêu là giảm thiểu lý do khiến một lớp phải thay đổi.

O - Open/Closed Principle (Nguyên tắc Đóng/Mở)

Nguyên tắc này nói rằng:

Các thực thể phần mềm (lớp, module, hàm,...) nên "mở" cho việc mở rộng (open for extension) nhưng "đóng" cho việc sửa đổi (closed for modification)

Điều này có nghĩa là bạn có thể thêm chức năng mới vào hệ thống mà không cần phải thay đổi mã nguồn hiện có của các lớp đã ổn định.

Lợi ích chính:

  • Giảm rủi ro và Tăng tính ổn định: Hạn chế sửa đổi code cũ giúp giảm nguy cơ gây ra lỗi không mong muốn trong các chức năng đã hoạt động ổn định.

  • Dễ mở rộng và bảo trì: Thêm chức năng mới một cách linh hoạt mà không làm ảnh hưởng đến các phần khác. Việc bảo trì tập trung vào các phần mở rộng mới, ít can thiệp vào code cũ.

Ví dụ minh họa (Đời thực):

  • Xe hơi và động cơ: Bạn muốn nâng cấp xe từ động cơ xăng sang động cơ điện. Nếu thiết kế xe tốt (tuân thủ OCP), bạn chỉ cần thay thế module động cơ mà không cần phải thiết kế lại toàn bộ khung gầm hay hệ thống lái.

  • Điện thoại thông minh và App Store: Điện thoại có các chức năng cơ bản. App Store cho phép "mở rộng" chức năng bằng cách cài thêm ứng dụng mà không cần phải "sửa đổi" hệ điều hành gốc cho từng ứng dụng mới.

Ví dụ minh họa (Code):

  • Tính diện tích các hình dạng:

    • Vi phạm OCP: Lớp AreaCalculator dùng if/else để kiểm tra loại hình (vuông, tròn) và tính diện tích. Khi muốn thêm hình tam giác, phải sửa đổi hàm sum() bằng cách thêm else if.

    • Tuân thủ OCP: Tạo interface ShapeInterface với phương thức area(). Các lớp Square, Circle, Triangle implement interface này. Lớp AreaCalculator chỉ làm việc với ShapeInterface. Khi cần thêm hình mới, chỉ cần tạo lớp mới implement ShapeInterface, không cần sửa AreaCalculator.

  • Xử lý bài viết theo loại (thường, #tag, @mention):

    • Vi phạm OCP: Dùng if/else trong phương thức CreatePost để xử lý các loại post khác nhau. Thêm loại mới phải sửa hàm này.

    • Tuân thủ OCP: Dùng kế thừa. Lớp cha Post xử lý trường hợp mặc định. Các lớp con TagPost, MentionPost override phương thức CreatePost để xử lý logic riêng.

  • Quản lý kết nối DB (SQL Server, MySQL, Oracle):

    • Vi phạm OCP: Dùng if/else trong ConnectionManager để kiểm tra loại kết nối.

    • Tuân thủ OCP: Dùng lớp trừu tượng Connection với phương thức doConnect(). Các lớp SqlServer, MySql, Oracle kế thừa và triển khai doConnect(). ConnectionManager làm việc với Connection.

  • Để đạt được OCP, tức là vừa "đóng" vừa "mở", chúng ta cần dựa vào các cơ chế của OOP, đặc biệt là Abstraction (Trừu tượng hóa)Polymorphism (Tính đa hình).

    1. Đầu tiên, chúng ta xác định những hành vi có thể thay đổi hoặc mở rộng trong tương lai (ví dụ: cách kết nối DB, cách thanh toán…).

    2. Chúng ta tạo ra một Abstraction (thường là một Interface hoặc một Abstract Class) để định nghĩa một "hợp đồng" chung cho các hành vi đó. Ví dụ: ShapeInterface, Connection, PaymentProcessor.

    3. Các module hiện có (ví dụ: AreaCalculator, ConnectionManager, Order, HashGenerator) sẽ được thiết kế để phụ thuộc vào Abstraction này, thay vì phụ thuộc vào các lớp cụ thể. Đây là phần "đóng" - logic cốt lõi này không cần thay đổi.

    4. Khi cần thêm một hành vi mới (ví dụ: thêm hình tam giác, thêm kết nối Oracle, thêm thanh toán ví điện tử), chúng ta tạo ra một lớp mới implement hoặc kế thừa từ Abstraction đã định nghĩa. Đây là phần "mở".

    5. Nhờ Polymorphism, các module hiện có có thể làm việc với các đối tượng của lớp mới này thông qua Abstraction chung mà không cần biết về kiểu cụ thể của chúng. Ví dụ, AreaCalculator có thể gọi phương thức area() trên một đối tượng Triangle giống như cách nó gọi trên Square hay Circle, miễn là chúng đều là ShapeInterface. Do đó, Abstraction và Polymorphism là chìa khóa để hiện thực hóa nguyên tắc OCP một cách hiệu quả, giúp hệ thống linh hoạt và dễ dàng thích ứng với sự thay đổi.

L - Liskov Substitution Principle (Nguyên tắc Thay thế Liskov)

Nguyên tắc này, được đặt theo tên của Barbara Liskov, phát biểu rằng:

Các đối tượng của lớp con phải có khả năng thay thế hoàn toàn cho các đối tượng của lớp cha mà không làm thay đổi tính đúng đắn hoặc hành vi mong đợi của chương trình

Nói đơn giản, nếu chương trình của bạn đang sử dụng một đối tượng thuộc lớp cha, bạn phải có thể thay thế nó bằng một đối tượng thuộc bất kỳ lớp con nào của nó mà chương trình vẫn chạy đúng như trước.

Lợi ích chính:

  • Đảm bảo tính đúng đắn: Giúp hệ thống hoạt động ổn định và đáng tin cậy khi sử dụng tính kế thừa và đa hình.

  • Tăng tính linh hoạt, tái sử dụng: Cho phép sử dụng các lớp con một cách an toàn ở những nơi mong đợi lớp cha, tận dụng lợi ích của đa hình.

  • Dễ bảo trì: Giảm thiểu các lỗi tiềm ẩn và hành vi không mong muốn phát sinh từ việc sử dụng kế thừa không đúng cách.

Ví dụ minh họa (Đời thực):

  • Chim biết bay và Chim cánh cụt: Nếu chương trình của bạn quản lý một đàn chim (Bird) và mong đợi tất cả chúng đều có thể thực hiện hành động fly(), thì việc thêm một con chim cánh cụt (Penguin) vào đàn sẽ gây ra vấn đề, vì chim cánh cụt không bay được. Penguin không thể thay thế hoàn toàn cho Bird trong ngữ cảnh này.

  • Ví dụ minh họa (Code):

    • Rectangle và Square (Ví dụ kinh điển):

      • Vấn đề: Về mặt toán học, hình vuông là một hình chữ nhật. Do đó, ta có thể cho lớp Square kế thừa lớp Rectangle. Lớp RectanglesetWidth()setHeight() hoạt động độc lập. Để duy trì tính chất hình vuông, lớp Square phải override các phương thức này sao cho khi set một cạnh thì cạnh kia cũng thay đổi theo.

      • Vi phạm LSP: Giả sử có một hàm TestRectangleArea(Rectangle r) nhận vào một Rectangle, đặt r.setWidth(5), rồi r.setHeight(10), và kiểm tra xem diện tích có phải là 50 không. Nếu bạn truyền vào một Rectangle, nó hoạt động đúng. Nhưng nếu bạn truyền vào một Square, sau khi gọi setHeight(10), chiều rộng cũng bị đổi thành 10, và diện tích trở thành 100, làm hàm TestRectangleArea chạy sai. Square đã thay đổi hành vi mong đợi của Rectangle, do đó không thể thay thế hoàn toàn.

      • Cách sửa: Nhận ra rằng mối quan hệ "is-a" về mặt cấu trúc không đồng nghĩa với "is-a" về mặt hành vi. Không nên cho Square kế thừa Rectangle. Thay vào đó, có thể tạo một interface Shape với phương thức area(), và cả Rectangle lẫn Square đều implement Shape.

    • AreaCalculatorVolumeCalculator: Nếu VolumeCalculator kế thừa AreaCalculator nhưng phương thức sum() của nó trả về một kiểu dữ liệu khác (ví dụ: mảng chứa tổng diện tích và tổng thể tích) so với sum() của lớp cha (chỉ trả về tổng diện tích dạng số), thì nó vi phạm LSP. Một client mong đợi kết quả dạng số từ AreaCalculator.sum() sẽ bị lỗi nếu nhận được mảng từ VolumeCalculator.sum().

    • Cầu thủ và Thủ môn: Nếu lớp cha Player có cả phương thức kickBallWithFeet()holdBallWithHands(). Lớp con Defender kế thừa từ Player sẽ vi phạm LSP, vì hậu vệ không được phép dùng tay giữ bóng trong trận đấu (trừ tình huống ném biên). Lớp Goalkeeper thì có thể giữ bóng bằng tay. Việc Defender kế thừa một hành vi (holdBallWithHands()) mà nó không được phép thực hiện đã vi phạm nguyên tắc. Cách sửa: Tách thành lớp Player (chỉ đá bóng), lớp HandsUsingPlayer kế thừa Player (thêm khả năng dùng tay). Defender kế thừa Player, Goalkeeper kế thừa HandsUsingPlayer.

  • Điểm mấu chốt của LSP nằm ở khái niệm "Behavioral Subtyping" (Kiểu con về hành vi), chứ không chỉ đơn thuần là "Structural Subtyping" (Kiểu con về cấu trúc) mà kế thừa thường thể hiện.

    1. Khi nói Square "is-a" Rectangle, chúng ta thường nghĩ về cấu trúc: hình vuông cũng có chiều rộng và chiều cao. Đây là kiểu con về cấu trúc.

    2. Tuy nhiên, LSP yêu cầu lớp con phải hành xử theo đúng "hợp đồng" (contract) mà lớp cha đã định nghĩa. Hợp đồng này bao gồm các điều kiện tiên quyết (preconditions), điều kiện hậu quả (postconditions), và các bất biến (invariants) của lớp cha.

    3. Trong ví dụ Rectangle/Square, "hợp đồng" ngầm của Rectangle là chiều rộng và chiều cao có thể được thay đổi độc lập. Lớp Square, bằng việc ép buộc width = height, đã vi phạm hợp đồng này (cụ thể là vi phạm postcondition của setWidthsetHeight).

    4. Do đó, khi quyết định sử dụng kế thừa, lập trình viên phải tự hỏi: "Lớp con có duy trì được tất cả các quy tắc và hành vi mà lớp cha hứa hẹn không?". Nếu câu trả lời là không, thì kế thừa có thể không phải là giải pháp phù hợp, và việc vi phạm LSP có thể dẫn đến những lỗi khó lường khi sử dụng đa hình.

I - Interface Segregation Principle (Nguyên tắc Phân tách Interface)

Nguyên tắc này khuyên rằng:

Client (lớp sử dụng interface) không nên bị buộc phải phụ thuộc vào những phương thức (trong interface) mà nó không sử dụng.

  • Thay vì tạo ra các interface lớn chứa nhiều phương thức, hãy chia nhỏ chúng thành nhiều interface cụ thể hơn, mỗi interface tập trung vào một nhóm chức năng liên quan.

  • Lợi ích chính:

    • Giảm sự phụ thuộc không cần thiết (Reduced coupling): Client chỉ phụ thuộc vào những gì nó thực sự cần, không bị ràng buộc bởi các phương thức thừa.

    • Tăng tính gắn kết (Higher cohesion) của interface: Mỗi interface nhỏ sẽ tập trung vào một mục đích, một vai trò rõ ràng hơn.

    • Dễ hiểu, bảo trì, tái sử dụng: Interface nhỏ gọn dễ quản lý, sửa đổi và sử dụng lại hơn.

    • Linh hoạt hơn: Việc thay đổi một interface nhỏ ít có khả năng ảnh hưởng đến các client không sử dụng interface đó.

  • Ví dụ minh họa (Đời thực):

    • Điều khiển từ xa đa năng vs. Điều khiển chuyên dụng: Một chiếc điều khiển TV chỉ cần các nút bật/tắt, kênh, âm lượng. Một chiếc điều khiển máy lạnh chỉ cần nút bật/tắt, nhiệt độ, chế độ gió. Việc gộp tất cả vào một điều khiển đa năng (fat interface) sẽ khiến người dùng TV phải mang theo các nút của máy lạnh và ngược lại. Tách ra thành các điều khiển chuyên dụng (specific interfaces) sẽ tốt hơn.

    • Công ty yêu cầu nhân viên đa năng: Bắt một nhân viên xử lý đơn hàng phải biết cả quản lý tài chính, nhân sự, phát triển sản phẩm là không hiệu quả và vi phạm ISP ở cấp độ công việc.

  • Ví dụ minh họa (Code):

    • Interface IPost có cả CreatePostReadPost: Nếu một client chỉ cần đọc post, nó vẫn bị phụ thuộc vào CreatePost. Nên tách thành IPostCreateIPostRead.

    • Interface Developer có cả codePHP(), codeRuby(), codeIOS(): Một PHP Developer không cần biết cách code Ruby hay iOS. Nên tách thành các interface riêng: PHPDeveloper, RubyDeveloper, IOSDeveloper.

    • Interface ShapeInterface có cả area()volume(): Hình 2D như Square không có thể tích. Nên tách thành ShapeInterface (chỉ area()) và ThreeDimensionalShapeInterface (chỉ volume()). Square chỉ implement ShapeInterface, Cuboid implement cả hai.

    • Interface Workerwork(), eat(), rest(): Robot chỉ work(). Nên tách thành Workable, Eatable, Restable. Robot chỉ implement Workable.

    • Interface IPayment có cả phương thức cho Bank và EWallet: Lớp BankPayment không cần LinkToBank hay TopUp. Lớp EWalletPayment không cần AddBankInfo. Nên tách thành IPayment (chung), IBankPayment (kế thừa IPayment), IEWalletPayment (kế thừa IPayment).

  • Giải thích sâu hơn: ISP thay đổi cách chúng ta tư duy về việc thiết kế interface. Thay vì nghĩ "Lớp này có thể làm được những gì?" (góc nhìn của nhà cung cấp - provider perspective) và đưa tất cả vào một interface, ISP khuyến khích chúng ta nghĩ "Client này thực sự cần những gì?" (góc nhìn của người sử dụng - client perspective).

    1. Khi thiết kế một interface, hãy xem xét các client tiềm năng sẽ sử dụng nó.

    2. Nếu các client khác nhau chỉ cần một tập hợp con các phương thức trong interface đó, đó là dấu hiệu cho thấy interface có thể quá lớn và cần được chia nhỏ.

    3. Việc tạo ra các interface nhỏ hơn, tập trung vào một vai trò (role interface) hoặc một nhóm chức năng cụ thể, giúp đảm bảo rằng các client chỉ phụ thuộc vào những gì chúng thực sự cần. Ví dụ, thay vì một interface IUserService lớn, có thể có IUserAuthenticator, IUserProfileManager, IUserNotifier.

    4. Cách tiếp cận tập trung vào client này giúp giảm thiểu sự phụ thuộc không cần thiết, tăng tính linh hoạt và phù hợp với mục tiêu chung của SOLID là giảm coupling.

D - Dependency Inversion Principle (Nguyên tắc Đảo ngược Phụ thuộc)

Nguyên tắc này được phát biểu rằng:

Các module/lớp cấp cao không nên phụ thuộc trực tiếp vào các module/lớp cấp thấp. Thay vào đó, cả hai nên phụ thuộc vào Abstraction (thường là interface hoặc abstract class).

module/lớp cấp cao (chứa logic nghiệp vụ chính, điều phối hoạt động)

module/lớp cấp thấp (thực hiện các chi tiết kỹ thuật như ghi file, kết nối database, gọi API)

Nói cách khác: Abstraction không nên phụ thuộc vào chi tiết cụ thể (implementation), mà chi tiết cụ thể nên phụ thuộc vào Abstraction.

  • Lợi ích chính:

    • Giảm sự phụ thuộc (Decoupling): Tách rời các module cấp cao khỏi các chi tiết triển khai của module cấp thấp, giúp chúng thay đổi độc lập.

    • Tăng tính linh hoạt, dễ thay thế: Dễ dàng thay đổi implementation cấp thấp (ví dụ: đổi từ ghi log ra file sang ghi log vào DB, đổi từ MySQL sang PostgreSQL) mà không cần sửa đổi module cấp cao.

    • Tăng khả năng tái sử dụng: Module cấp cao có thể làm việc với nhiều implementation cấp thấp khác nhau thông qua abstraction.

    • Tăng khả năng kiểm thử: Dễ dàng tạo mock hoặc stub cho các dependency cấp thấp khi kiểm thử module cấp cao một cách độc lập.

  • Ví dụ minh họa (Đời thực):

    • Ổ cắm điện và thiết bị: Ổ cắm (module cấp cao) không quan tâm bạn cắm vào đó là quạt, đèn, hay sạc điện thoại (module cấp thấp). Nó chỉ quan tâm đến phích cắm (abstraction) có đúng chuẩn hay không. Các thiết bị (module cấp thấp) phải thiết kế phích cắm của mình để phù hợp với chuẩn ổ cắm (phụ thuộc vào abstraction).

    • Thanh toán bằng thẻ tín dụng: Nhân viên thu ngân (cấp cao) sử dụng máy POS (Point of Sale) để đọc thẻ. Máy POS không phụ thuộc vào thẻ Visa hay Mastercard (cấp thấp). Nó phụ thuộc vào chuẩn thẻ từ hoặc chip (abstraction). Các loại thẻ (cấp thấp) phải tuân theo chuẩn đó.

  • Ví dụ minh họa (Code):

    • Lớp PostErrorLogger:

      • Vi phạm DIP: Lớp Post (cấp cao) tạo trực tiếp new ErrorLogger() (cấp thấp).

      • Tuân thủ DIP: Tạo interface ILogger (abstraction). Post phụ thuộc vào ILogger. Lớp FileLoggerDatabaseLogger (cấp thấp) implement ILogger. Một instance của logger cụ thể được "inject" vào Post.

    • PasswordReminderMySQLConnection:

      • Vi phạm DIP: PasswordReminder (cấp cao) phụ thuộc trực tiếp vào MySQLConnection (cấp thấp).

      • Tuân thủ DIP: Tạo interface DBConnectionInterface (abstraction). PasswordReminder phụ thuộc vào DBConnectionInterface. MySQLConnection (cấp thấp) implement DBConnectionInterface. Instance MySQLConnection được inject vào PasswordReminder.

    • Windows98MachineStandardKeyboard/Monitor:

      • Vi phạm DIP: Windows98Machine (cấp cao) tạo new StandardKeyboard()new Monitor() (cấp thấp).

      • Tuân thủ DIP: Tạo interface KeyboardMonitor (abstractions). Windows98Machine phụ thuộc vào các interface này. StandardKeyboardMonitor (cấp thấp) implement các interface. Instance cụ thể được inject vào Windows98Machine.

    • OrderProcessorEmailNotifier:

      • Vi phạm DIP: OrderProcessor (cấp cao) tạo new EmailNotifier() (cấp thấp).

      • Tuân thủ DIP: Tạo interface Notifier (abstraction). OrderProcessor phụ thuộc vào Notifier. EmailNotifierSMSNotifier (cấp thấp) implement Notifier. Instance notifier cụ thể được inject vào OrderProcessor.

    • InvoiceService và các loại Invoice:

      • Vi phạm DIP: InvoiceService (cấp cao) sử dụng switch để tạo và làm việc trực tiếp với CompanyInvoicePersonalInvoice (cấp thấp).

      • Tuân thủ DIP: Tạo interface IInvoice (abstraction). InvoiceService phụ thuộc vào IInvoice. CompanyInvoicePersonalInvoice (cấp thấp) implement IInvoice. Instance invoice cụ thể được tạo bởi một Factory (hoặc container DI) và inject vào InvoiceService (hoặc factory được inject).

  • Giải thích về Dependency Injection (DI):

    • DI là gì: Đây là một kỹ thuật hoặc design pattern rất phổ biến được dùng để hiện thực hóa nguyên tắc Đảo ngược Phụ thuộc (DIP). Ý tưởng cốt lõi là thay vì một đối tượng tự tạo ra các đối tượng phụ thuộc (dependencies) mà nó cần, các dependency này sẽ được cung cấp (inject - tiêm) vào đối tượng đó từ một nguồn bên ngoài.

    • Tại sao lại dùng DI? Nó giúp tách biệt mối quan tâm (separation of concerns): lớp sử dụng dependency không cần bận tâm đến việc dependency đó được tạo ra như thế nào hay implementation cụ thể của nó là gì, miễn là nó tuân thủ abstraction (interface).

    • Các hình thức DI:

      • Constructor Injection: Truyền dependency vào qua constructor. Đây là cách phổ biến và được khuyến khích nhất vì nó đảm bảo đối tượng luôn có đủ dependency cần thiết ngay khi được tạo ra.

      • Setter Injection: Truyền dependency qua các phương thức set...(). Linh hoạt hơn nhưng không đảm bảo dependency luôn tồn tại.

      • Interface Injection: Lớp implement một interface yêu cầu client phải cung cấp dependency thông qua một phương thức của interface đó. Ít phổ biến hơn.

    • Vai trò của DI: DI là công cụ giúp chúng ta áp dụng DIP một cách hiệu quả, làm cho code trở nên "lỏng lẻo" (loosely coupled), dễ cấu hình, dễ thay đổi và đặc biệt là dễ kiểm thử.

  • Giải thích sâu hơn: Bản chất của DIP là sự đảo ngược luồng kiểm soát liên quan đến việc tạo và quản lý các dependency.

    1. Trong thiết kế truyền thống không có DIP, module cấp cao thường kiểm soát việc tạo ra hoặc lựa chọn module cấp thấp mà nó cần. Ví dụ: OrderProcessor quyết định dùng EmailNotifier bằng cách gọi new EmailNotifier(). Quyền kiểm soát nằm ở module cấp cao.

    2. DIP nói rằng module cấp cao không nên làm điều này. Nó chỉ nên biết về abstraction (Notifier).

    3. Điều này có nghĩa là quyền quyết định sẽ sử dụng implementation cụ thể nào (EmailNotifier hay SMSNotifier) phải được chuyển ra bên ngoài module cấp cao.

    4. Dependency Injection chính là cơ chế thực hiện việc "chuyển quyền kiểm soát" này. Một thành phần khác (ví dụ: code khởi tạo ứng dụng, một IoC container - Inversion of Control container) sẽ chịu trách nhiệm tạo instance cấp thấp (EmailNotifier) và "inject" nó vào module cấp cao (OrderProcessor) thông qua abstraction (Notifier).

    5. Như vậy, DIP thực sự "đảo ngược" quyền kiểm soát việc tạo và liên kết dependency, chuyển nó từ module cấp cao sang một cơ chế bên ngoài. Sự đảo ngược này là chìa khóa để giảm thiểu sự phụ thuộc và tăng tính linh hoạt của hệ thống.

III. SOLID Hoạt động Cùng Nhau Như Thế Nào?

Các nguyên tắc SOLID không phải là những thực thể hoàn toàn độc lập. Chúng phối hợp và bổ sung cho nhau để đạt được mục tiêu chung là tạo ra một thiết kế phần mềm tốt hơn.

  • Sự tương hỗ:

    • SRP giúp tạo ra các lớp nhỏ, tập trung. Điều này làm cho việc áp dụng OCP trở nên dễ dàng hơn, vì việc mở rộng một lớp nhỏ thường đơn giản hơn là sửa đổi một lớp lớn, phức tạp.

    • OCP thường dựa vào Abstraction và Polymorphism để cho phép mở rộng mà không cần sửa đổi. LSP là nguyên tắc đảm bảo rằng Polymorphism hoạt động đúng như mong đợi, tức là các lớp con có thể thực sự thay thế lớp cha một cách an toàn.

    • ISP khuyến khích việc tạo ra các Abstraction (interfaces) nhỏ và cụ thể. Điều này hỗ trợ OCP (vì dễ dàng mở rộng thông qua các interface chuyên biệt) và DIP (vì các module sẽ phụ thuộc vào các interface gọn gàng, đúng mục đích).

    • DIP đóng vai trò như chất keo, liên kết các module lại với nhau thông qua Abstraction. Nó cho phép các module cấp cao (thường được thiết kế theo SRP và OCP) tương tác một cách linh hoạt với các module cấp thấp (có thể thay thế được nhờ LSP) thông qua các Abstraction được thiết kế tốt (nhờ ISP).

  • Mục tiêu chung: Nhìn một cách tổng thể, SOLID là một hệ thống các nguyên tắc cùng hướng tới mục tiêu giảm sự phụ thuộc (low coupling)tăng tính gắn kết nội tại (high cohesion).

    • SRP tăng cohesion.

    • OCP, LSP, ISP, và đặc biệt là DIP, đều góp phần giảm coupling.

    • Khi hệ thống có low coupling và high cohesion, nó sẽ trở nên dễ hiểu, dễ bảo trì, dễ mở rộng và ít lỗi hơn.

  • Lợi ích tổng hợp: Khi áp dụng đồng bộ cả năm nguyên tắc SOLID, bạn sẽ thu được những lợi ích to lớn cho dự án phần mềm của mình: code trở nên dễ đọc, dễ hiểu, dễ bảo trì, dễ mở rộng, dễ kiểm thử, linh hoạt hơn, khả năng tái sử dụng cao hơn và giảm thiểu các lỗi do sự phụ thuộc phức tạp gây ra.

Bảng Tóm tắt 5 Nguyên tắc SOLID

Để giúp các bạn dễ dàng ghi nhớ, dưới đây là bảng tóm tắt các ý chính của từng nguyên tắc:

Nguyên tắcViết tắtÝ tưởng cốt lõi (Đơn giản)Lợi ích chínhTừ khóa ví dụ đời thực
Single Responsibility PrincipleSMỗi lớp chỉ làm một việc duy nhất.Dễ hiểu, dễ bảo trì, dễ test, giảm phụ thuộc.Thợ chuyên môn, Dao chuyên dụng
Open/Closed PrincipleOMở cho mở rộng, Đóng cho sửa đổi.Dễ thêm tính năng mới, ổn định, ít lỗi.App Store, Thay động cơ xe
Liskov Substitution PrincipleLLớp con thay thế được lớp cha (đúng).Đảm bảo kế thừa đúng, hệ thống ổn định, linh hoạt.Vịt thường vs Vịt pin, Hình chữ nhật vs Hình vuông
Interface Segregation PrincipleIDùng nhiều interface nhỏ, không dùng interface lớn.Giảm phụ thuộc thừa, linh hoạt, dễ dùng.Điều khiển riêng, Menu riêng
Dependency Inversion PrincipleDPhụ thuộc vào trừu tượng, không phải cụ thể.Giảm phụ thuộc mạnh, linh hoạt, dễ test, dễ đổi.Ổ cắm điện, Cổng USB

IV. Kết luận

Như vậy, chúng ta đã cùng nhau đi qua 5 nguyên tắc nền tảng của SOLID. Đây là những kim chỉ nam vô cùng quý giá giúp các lập trình viên xây dựng nên những phần mềm hướng đối tượng không chỉ hoạt động tốt ở hiện tại mà còn có thể dễ dàng bảo trì, thích ứng và phát triển trong tương lai.

Việc áp dụng SOLID giúp mã nguồn của bạn trở nên sạch sẽ, rõ ràng, module hóa tốt hơn, giảm thiểu sự phụ thuộc phức tạp, từ đó nâng cao hiệu quả làm việc cá nhân cũng như cộng tác trong nhóm.

Tuy nhiên, việc hiểu và áp dụng thành thạo SOLID đòi hỏi thời gian, sự kiên nhẫn và thực hành liên tục. Đừng ngần ngại bắt đầu áp dụng chúng vào các bài tập nhỏ, các dự án cá nhân của bạn. Ban đầu có thể bạn sẽ thấy hơi phức tạp hoặc tốn công hơn một chút, nhưng lợi ích lâu dài mà SOLID mang lại là vô cùng lớn. Mặc dù có những tranh luận về việc áp dụng cứng nhắc hoặc các trường hợp ngoại lệ , nhưng đối với người mới bắt đầu, việc nắm vững và cố gắng tuân thủ các nguyên tắc này chắc chắn sẽ giúp bạn xây dựng một nền tảng kỹ thuật vững chắc.

Chúc các bạn thành công trên con đường trở thành những kỹ sư phần mềm xuất sắc!

0
Subscribe to my newsletter

Read articles from Ha Ngoc Hieu directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ha Ngoc Hieu
Ha Ngoc Hieu