+1

Lập trình hướng đối tượng & SOLID: Hiểu sâu, Code sạch và Nghệ thuật đánh đổi

Sự khác biệt giữa một "thợ gõ code" và một kỹ sư phần mềm thực thụ không nằm ở việc ai thuộc nhiều cú pháp ngôn ngữ hơn, mà nằm ở cách họ thiết kế kiến trúc để dự án "sống khỏe" qua những lần yêu cầu nghiệp vụ thay đổi liên tục.

Bạn đã bao giờ rơi vào tình cảnh: chỉ sửa một tính năng nhỏ xíu ở module này, nhưng lại vô tình làm sập toàn bộ chức năng ở một module khác? Hoặc khi sếp yêu cầu thêm một loại thanh toán mới, bạn phải mở hàng loạt file cũ ra để nhồi nhét thêm các câu lệnh if-else chằng chịt? Đó là những "căn bệnh" điển hình của một hệ thống thiếu vắng kiến trúc.

Viết code cũng giống như xây dựng một tòa nhà chọc trời 🏗️. Lập trình Hướng đối tượng (OOP) cung cấp cho bạn những viên gạch 🧱 vững chắc, nhưng chính bộ nguyên tắc thiết kế SOLID mới là bản vẽ kiến trúc 📐 đảm bảo tòa nhà không sụp đổ khi cất nóc đến tầng 100.

Trong bài viết này, chúng ta sẽ không để lý thuyết nằm yên trên trang giấy. Chúng ta sẽ biến những khái niệm nền tảng thành "vũ khí" thực chiến bằng cách mang chúng đặt thẳng vào một dự án thực tế. Từ đó, chúng ta sẽ cùng nhau:

  • Mổ xẻ 4 trụ cột của OOP để đúc ra những viên gạch chuẩn mực nhất.

  • Giải mã 5 nguyên tắc thiết kế SOLID qua việc đối chiếu trực tiếp giữa các đoạn code "thảm họa" và cách kê đơn, nâng cấp chúng.

  • Và cuối cùng, chạm đến kỹ năng định đoạt đẳng cấp của một Senior: Nghệ thuật đánh đổi (Trade-offs) – biết khi nào nên tuân thủ thiết kế hoàn hảo, và khi nào sự đơn giản (KISS) mới là chìa khóa.

Phần 1: Xây nền móng với 4 trụ cột OOP

OOP (Object-Oriented Programming), hay Lập trình hướng đối tượng, là một mô hình lập trình dựa trên khái niệm "đối tượng" (objects). Thay vì tập trung vào các hành động và logic (như lập trình hướng thủ tục), OOP tập trung vào việc tổ chức dữ liệu và các thao tác trên dữ liệu đó thành các đơn vị độc lập.

Trước tiên, nền móng của thế giới OOP xoay quanh hai khái niệm: Class (Lớp)Object (Đối tượng).

  • Class 📐: Là một "bản thiết kế" hay khuôn mẫu. Ví dụ, bản vẽ kỹ thuật của một chiếc điện thoại. Nó định nghĩa điện thoại có những dữ liệu gì (màu sắc, dung lượng pin) và hành động gì (gọi điện, chụp ảnh).

  • Object 📱: Là thực thể có thật được tạo ra từ bản thiết kế đó. Chiếc điện thoại bạn đang cầm trên tay chính là một Object.

Để các viên gạch này tương tác với nhau một cách an toàn và trơn tru, chúng ta phải dựa vào 4 trụ cột cốt lõi:

1. Tính Đóng gói (Encapsulation) 📦 - Lớp vỏ an toàn

Tính Đóng gói có hai nhiệm vụ chính:

  • Gom nhóm dữ liệu (thuộc tính) và các hàm xử lý dữ liệu (phương thức) vào chung một Class.
  • Che giấu trạng thái chi tiết bên trong của đối tượng và chỉ cho phép bên ngoài tương tác thông qua các "cánh cửa" được kiểm soát.

Hãy tưởng tượng một chiếc máy pha cà phê tự động. Bạn chỉ cần tương tác với các nút bấm trên vỏ máy (giao diện công khai - public) để lấy cà phê. Bạn không được phép thọc tay vào hệ thống bánh răng hay nồi hơi bên trong (dữ liệu nội bộ - private) vì có thể làm hỏng máy hoặc gây nguy hiểm.

Để xem cách bạn áp dụng nguyên tắc che giấu này: Giả sử bạn đang viết code cho một Class TaiKhoanNganHang. Theo nguyên tắc Đóng gói, thuộc tính sodutien nên được giấu kín (private) hay mở công khai (public)? Và bạn sẽ cung cấp những phương thức (hành động) nào để hệ thống bên ngoài có thể tương tác với số tiền đó một cách an toàn?

⏩️Câu trả lời là đặt thuộc tính sodutien là private và tạo một hàm ruttien(sotien) kèm theo điều kiện kiểm tra chính là cách chúng ta bảo vệ dữ liệu khỏi những can thiệp sai lệch. Để hoàn thiện, chúng ta chỉ cần cung cấp thêm hàm naptien(sotien)xem_so_du() là hệ thống bên ngoài có thể tương tác đầy đủ và an toàn.

2. Tính Kế thừa (Inheritance) 🧬

Kế thừa cho phép một Class mới (Class con) "thừa hưởng" lại các thuộc tính và phương thức của một Class đã có (Class cha). Mục đích chính của đặc tính này là giúp tái sử dụng code và thiết lập một mối quan hệ phân cấp có ý nghĩa (quan hệ "IS-A" - "Là một").

Tiếp tục với hệ thống ngân hàng của chúng ta. Giả sử bạn đã xây dựng xong Class cha là TaiKhoanNganHang với các tính năng cơ bản như so_du_tien(), nap_tien(), và rut_tien(). Bây giờ, nghiệp vụ yêu cầu bạn tạo thêm một loại tài khoản chuyên biệt là TaiKhoanTietKiem (Savings Account).

Nếu chúng ta cho TaiKhoanTietKiem kế thừa từ TaiKhoanNganHang, theo bạn, Class con này có cần phải viết lại đoạn code cho hàm naptien() từ đầu không? Và bạn thử nghĩ xem, có một thuộc tính hoặc hành động "đặc trưng" nào đó chỉ tồn tại ở tài khoản tiết kiệm mà một tài khoản thông thường không có?

Đáp án ở đây là , TaiKhoanTietKiem hoàn toàn có thể sử dụng lại naptien()mà không cần viết lại dòng code nào, đồng thời được bổ sung thêm phương thức riêng như tinh_lai_suat(). Đó chính là sức mạnh của Kế thừa: tái sử dụng và mở rộng.

Bây giờ, chúng ta bước sang trụ cột thứ ba, một tính chất cực kỳ mạnh mẽ và có liên quan mật thiết đến Kế thừa.

3. Tính Đa hình (Polymorphism) 🎭

"Đa hình" hiểu đơn giản là "nhiều hình thái". Nó cho phép các đối tượng thuộc các Class khác nhau phản hồi lại cùng một lời gọi hàm theo những cách khác nhau.

Lấy ví dụ thực tế: Cùng là hành động "Kêu", nhưng nếu bạn ra lệnh cho đối tượng Chó, nó sẽ "Gâu gâu"; nếu ra lệnh cho đối tượng Mèo, nó sẽ "Meo meo". Hệ thống chỉ cần phát ra một thông điệp "Kêu đi", còn thực hiện thế nào là việc của từng đối tượng.

Quay lại ứng dụng ngân hàng của chúng ta: Class cha TaiKhoanNganHang đã có sẵn hàm rut_tien(). Tuy nhiên, với TaiKhoanTietKiem, việc rút tiền khắt khe hơn. Ví dụ, nếu khách hàng rút trước kỳ hạn, họ sẽ bị mất toàn bộ tiền lãi dự kiến hoặc chịu phí phạt. Rõ ràng, logic tính toán bên trong đã khác biệt so với tài khoản thường.

Theo bạn, làm thế nào để TaiKhoanTietKiem vẫn giữ nguyên tên hàm là rut_tien() (để phần mềm giao dịch viên chỉ cần dùng 1 lệnh duy nhất cho mọi loại tài khoản), nhưng hệ thống lại biết tự động áp dụng quy tắc phạt tiền riêng biệt của tài khoản tiết kiệm thay vì cách rút thông thường?

Câu này thử thách hơn chút rồi đúng không. Nhưng không có gì băn khoăn cả, chúng ta hãy cùng mổ xẻ thử xem nhé. Đây chính là lúc khái niệm Ghi đè phương thức (Method Overriding) – một trong những cách phổ biến nhất để thể hiện Tính Đa hình – xuất hiện.

Cách giải quyết rất thông minh và gọn gàng: Trong Class con TaiKhoanTietKiem, bạn sẽ khai báo lại một hàm rut_tien() với tên gọi y hệt như Class cha TaiKhoanNganHang. Tuy nhiên, bên trong hàm của Class con, bạn sẽ viết đoạn code mới (bao gồm logic trừ tiền phạt).

Khi chương trình chạy, nếu bạn yêu cầu một "Tài khoản tiết kiệm" rút tiền, hệ thống sẽ tự động ưu tiên chạy hàm rut_tien() phiên bản có phạt của Class con, thay vì dùng phiên bản gốc của Class cha. Nhờ vậy, anh nhân viên ngân hàng chỉ cần gọi đúng một lệnh rut_tien() cho bất kỳ ai đến quầy, còn việc tính toán thế nào thì từng loại tài khoản sẽ tự biết cách "hành xử" cho đúng!

Bây giờ, chúng ta đi đến mảnh ghép cuối cùng trong 4 đặc tính của OOP.

4. Tính Trừu tượng (Abstraction) ☁️

Trừu tượng hiểu đơn giản là: Chỉ hiển thị những tính năng cần thiết ra bên ngoài và giấu đi những chi tiết phức tạp bên trong.

Hãy thử tưởng tượng bạn đang lái một chiếc ô tô 🚗. Để xe tiến lên, bạn thao tác đạp chân ga. Theo bạn, bạn chỉ cần biết "đạp ga là xe chạy", hay bạn bắt buộc phải hiểu chính xác cách động cơ đốt trong pha trộn xăng và không khí như thế nào ở dưới nắp capo? Việc "giấu đi" sự phức tạp của động cơ này mang lại lợi ích gì cho người lái xe (hay chính là những lập trình viên khác sẽ sử dụng code của bạn sau này)?

Đọc xong câu hỏi là đã có đáp án rồi đúng không, thực tế khi lái xe, bạn chỉ cần ạn chỉ cần biết cách dùng chân ga, vô lăng (giao diện sử dụng) mà không cần phải là một thợ máy thực thụ. Lợi ích khổng lồ ở đây là sự đơn giản hóa. Bạn có thể đổi từ chiếc xe chạy xăng sang một chiếc xe điện ⚡, động cơ bên trong thay đổi hoàn toàn, nhưng bạn vẫn lái được ngay vì "giao diện" (chân ga) vẫn được giữ nguyên

Trong lập trình cũng vậy, Tính Trừu tượng giúp bạn giấu đi hàng ngàn dòng code logic phức tạp bên trong và chỉ cung cấp ra ngoài những hàm đơn giản (như tien_len(), dung_lai()). Những lập trình viên khác trong team khi dùng lại code của bạn sẽ không bị "ngợp", và họ cũng không thể vô tình can thiệp làm hỏng hệ thống động cơ phức tạp bên trong.

Không biết có bạn nào giống mình, thắc mắc là về mặt vật lý, chúng ta có toàn quyền truy cập vào mã nguồn (source code), bạn hoàn toàn có thể mở file ra, xóa đổi hay sửa bất cứ dòng code nào bạn muốn. Không có "phép thuật" nào khóa tay bạn lại cả.

Vậy tại sao chúng ta lại nói Tính Trừu tượng (và Đóng gói) giúp tránh "vô tình can thiệp làm hỏng hệ thống"? Ở đây, sự bảo vệ không phải là khóa cửa không cho vào, mà là tạo ra các dải phân cách và biển báo an toàn trong kiến trúc phần mềm vì 2 lý do cực kỳ thực tế sau:

  • Ngăn chặn lỗi "vô ý" trong làm việc nhóm: Giả sử dự án của bạn có 10 lập trình viên. Bạn là chuyên gia viết Class DongCo, một đồng nghiệp khác viết Class BanhXe. Nếu bạn giấu kín các hàm xử lý bu-gi, xăng gió và chỉ để lộ ra hàm dap_ga(), đồng nghiệp của bạn khi ghép nối code sẽ chỉ được phép gọi DongCo.dap_ga(). Nếu bạn không giấu chúng đi, một ngày đẹp trời, đồng nghiệp đó có thể gọi nhầm hoặc thay đổi trực tiếp một biến số nội bộ của động cơ từ bên ngoài, gây ra lỗi hệ thống dây chuyền mà mất hàng tuần mới tìm ra được nguyên nhân.

  • Cam kết về "Giao diện sử dụng" (Contract): Quay lại ví dụ chiếc ô tô, bạn hoàn toàn có quyền mở nắp capo ra và cắt nối lại dây điện (sửa code tự do). Nhưng nhà sản xuất bọc kín động cơ lại và đưa cho bạn cái vô lăng để bạn không phải làm việc đó mỗi khi muốn xe chạy. Nó tạo ra một "hợp đồng": Miễn là bạn dùng đúng cái vô lăng, tôi đảm bảo xe rẽ đúng hướng, còn bên trong bánh răng khớp với nhau thế nào, tôi có thể tự do nâng cấp bản 2.0, 3.0 ở các bản cập nhật sau mà không sợ làm hỏng thói quen lái xe của bạn.

Tóm lại, Trừu tượng không cấm bạn sửa code, nó tạo ra một "bức tường ranh giới" rõ ràng. Bất cứ ai muốn vượt qua bức tường đó để sửa phần lõi thì họ đang làm việc đó một cách cố ý và phải chịu trách nhiệm, chứ không thể viện cớ là "tôi vô tình gọi nhầm" được nữa.

Không phải đặc tính nào cả, chỉ là ví dụ cụ thể

Lý thuyết ngon nghẻ rồi, việc tiếp theo là ánh xạ chúng việc ánh xạ lý thuyết vào code thực tế trong Java, điều này sẽ giúp bạn nhìn rõ cách ngôn ngữ này cung cấp các công cụ để thực thi OOP. Chúng ta sẽ điểm qua các kỹ thuật phổ biến nhất cho từng đặc tính.

1. Đóng gói (Encapsulation) 📦

Trong Java, Đóng gói được thể hiện qua các từ khóa phạm vi truy cập (Access Modifiers) như private, protected, public.

  • Giấu dữ liệu (Private Fields & Public Methods): Dùng private cho thuộc tính và cung cấp getter/setter.

  • Giấu logic phức tạp (Private Helper Methods): Đẩy các đoạn code tính toán phức tạp vào các hàm private để class bên ngoài không gọi nhầm.

public class TaiKhoan {
    // 1. Giấu dữ liệu
    private double soDu; 

    public void napTien(double tien) {
        // 2. Ẩn logic kiểm tra phức tạp vào hàm private
        if (kiemTraTienHopLe(tien)) { 
            soDu += tien;
        }
    }

    private boolean kiemTraTienHopLe(double tien) {
        // Logic phức tạp: kiểm tra tiền giả, kiểm tra hạn mức...
        return tien > 0; 
    }
}

2. Kế thừa (Inheritance) 🧬

Java sử dụng từ khóa extends cho class và super để tương tác với class cha. Lưu ý: Java chỉ cho phép đơn kế thừa (một class chỉ có 1 class cha trực tiếp).

Kế thừa thuộc tính/phương thức: Class con tự động có các thành phần public và protected của cha.

Tái sử dụng constructor: Dùng super().

class NhanVien {
    protected String ten; // protected cho phép class con truy cập trực tiếp
    
    public NhanVien(String ten) { 
        this.ten = ten; 
    }
}

// 1. Dùng extends để kế thừa
class QuanLy extends NhanVien { 
    private int soNhanVienDuoiQuyen;

    public QuanLy(String ten, int soNhanVien) {
        // 2. Dùng super để tái sử dụng logic khởi tạo của cha
        super(ten); 
        this.soNhanVienDuoiQuyen = soNhanVien;
    }
}

3. Đa hình (Polymorphism) 🎭

Java có 3 cách chính để thể hiện Đa hình:

  • Nạp chồng phương thức (Overloading - Compile-time): Cùng tên hàm, nhưng khác số lượng hoặc kiểu tham số.

  • Ghi đè phương thức (Overriding - Runtime): Viết lại hàm của class cha trong class con (thường dùng @Override).

  • Ép kiểu ngược lên (Upcasting): Dùng kiểu dữ liệu của class cha để chứa object của class con.

class MayTinh {
    // 1. Overloading: Cùng tên hàm "cong", khác kiểu tham số
    public int cong(int a, int b) { return a + b; }
    public double cong(double a, double b) { return a + b; }
}

class DongVat { 
    public void keu() { System.out.println("..."); } 
}

class Cho extends DongVat {
    // 2. Overriding: Viết lại cách kêu của loài Chó
    @Override 
    public void keu() { System.out.println("Gâu gâu!"); }
    
    // Hàm đặc thù của riêng loài chó
    public void giuNha() { System.out.println("Đang canh trộm..."); }
}

public class Main {
    public static void main(String[] args) {
        // 3. Upcasting: Biến kiểu DongVat, nhưng chứa object Cho
        DongVat pet = new Cho(); 
        pet.keu(); // Sẽ in ra "Gâu gâu!" nhờ tính Đa hình
    }
}

Bạn hãy nhìn lại phần code của Đa hình với ví dụ Upcasting ở trên:

DongVat pet = new Cho();

Theo bạn, nếu ngay bên dưới dòng lệnh đó, chúng ta gọi pet.giuNha(); (một hàm chỉ tồn tại trong class Cho), trình biên dịch của Java có báo lỗi không, hay nó vẫn chạy bình thường?

Trông đoạn code đó ổn mà đúng không, tại sao lại lỗi được. Nếu bạn cũng đang thắc mắc như vậy thì nó cũng hoàn toàn hợp lý thôi! Trông thì rõ ràng chúng ta vừa tạo ra một con chó (new Cho()), tại sao nó lại không biết giữ nhà đúng không? Rất nhiều người khi mới học OOP cũng rơi vào cái bẫy này!

Chìa khóa ở đây là sự khác biệt giữa "Cái nhãn dán bên ngoài" (Kiểu tham chiếu - Reference type) và "Đồ vật thực sự bên trong" (Kiểu đối tượng - Object type).

Hãy hình dung thế này:

  1. Bạn có một cái thùng các-tông, bên ngoài dán nhãn là "Động Vật" (DongVat pet).
  2. Bạn bỏ vào trong cái thùng đó một "Con Chó" (= new Cho();).

Khi bạn viết code, Trình biên dịch (Compiler) của Java làm việc như một anh bảo vệ nguyên tắc. Anh ta chỉ nhìn vào cái nhãn dán bên ngoài thùng chứ không mở thùng ra xem.

  • Khi bạn gọi pet.keu();, anh bảo vệ dò trong danh sách của "Động Vật" thấy có hành động "kêu" ➡️ Cho phép đi qua.

  • Khi bạn gọi pet.giuNha();, anh bảo vệ dò danh sách "Động Vật" và nói: "Này, theo lý thuyết thì Động Vật nói chung làm gì biết giữ nhà? Lỗi rồi" 🚫 Và thế là code của bạn sẽ bị báo lỗi đỏ chót (Compile-time error), dù bạn biết tỏng bên trong là con chó.

Để khắc phục lỗi này và gọi được hàm giuNha(), chúng ta phải dùng một kỹ thuật gọi là Ép kiểu xuống (Downcasting). Tức là bạn tự viết một "giấy cam kết" với trình biên dịch: "Tôi đảm bảo cái thùng Động Vật này đang chứa con Chó, hãy coi nó là Chó đi"

Cú pháp trong Java sẽ trông như thế này:

((Cho) pet).giuNha();

Chúng ta ép kiểu pet về lại thành Cho, lúc này trình biên dịch mới cho phép gọi hàm giuNha().

Nhưng khoan đã, có một rủi ro cực lớn ở đây 🤔 Nếu trước đó chúng ta gán DongVat pet = new Meo(); (bỏ con Mèo vào thùng Động Vật), nhưng ở dưới chúng ta lại "cam kết" và ép kiểu nó thành Cho như lệnh ở trên:((Cho) pet).giuNha();.

Theo bạn, chương trình lúc này sẽ phản ứng thế nào khi chúng ta ép một con Mèo phải đi làm công việc giữ nhà của một con Chó?

⏩️Lỗi là cái chắc chắn rồi có đúng không. Lý do bởi vì dù ở bước gõ code, bạn đã dùng kỹ thuật "ép kiểu" để lách qua anh bảo vệ (Trình biên dịch), nhưng khi chương trình thực sự chạy (Runtime), máy ảo Java (JVM) sẽ "mở thùng ra", thấy con Mèo thay vì con Chó và lập tức ném thẳng vào mặt chúng ta một lỗi có tên là ClassCastException (Lỗi sai kiểu), làm sập chương trình ngay lập tức.

Đó là lý do vì sao Tính Đa hình cần được sử dụng một cách tinh tế.

4. Trừu tượng (Abstraction) ☁️

Java cung cấp 2 công cụ để thực hiện Trừu tượng:

  • Abstract Class: Lớp trừu tượng. Dùng khi bạn muốn định nghĩa một bộ khung, trong đó có một số hàm bắt buộc class con phải tự viết (abstract methods), và một số hàm dùng chung đã có sẵn code (concrete methods).

  • Interface: Giao diện. Bản hợp đồng 100% trừu tượng (trước Java 8), nơi chỉ liệt kê tên các hàm mà không có code thực thi. Một class có thể implements nhiều interface cùng lúc.

// 1. Interface: Chỉ định nghĩa "Làm gì", không định nghĩa "Làm như thế nào"
interface TheThanhToan {
    void quetThe(); 
}

// 2. Abstract Class: Kết hợp giữa trừu tượng và cụ thể
abstract class Xe {
    // Hàm trừu tượng: Bắt buộc các loại xe tự định nghĩa cách khởi động
    abstract void khoiDong(); 
    
    // Hàm cụ thể: Mọi loại xe đều dùng chung cách bật đèn này
    public void batDen() { 
        System.out.println("Đèn xe đã sáng"); 
    } 
}

class XeMay extends Xe implements TheThanhToan {
    @Override
    void khoiDong() { System.out.println("Đạp nổ máy"); }

    @Override
    public void quetThe() { System.out.println("Quẹt thẻ trả phí giữ xe"); }
}

💡 Điểm nhấn thực chiến: Ưu tiên Lắp ráp hơn Kế thừa (Composition over Inheritance) 🧩

Trước khi khép lại 4 trụ cột của OOP, có một "cú lừa" mà rất nhiều lập trình viên mắc phải khi mới ứng dụng OOP vào dự án thực tế: Lạm dụng tính Kế thừa.

Kế thừa rất tốt để tái sử dụng code, nhưng nó tạo ra một mối quan hệ cực kỳ chặt chẽ là "IS-A" (Là một). Ví dụ: Bạn có class Cho kế thừa từ DongVat. Mọi thứ rất ổn cho đến khi khách hàng yêu cầu làm thêm một con... ChoRobot (Chó máy). Nếu bạn cho ChoRobot kế thừa từ Cho, nó sẽ bị ép mang theo những hàm như tieuHoaThucAn() hay hoHap(), trong khi robot thì chỉ cần cắm sạc điện!

Lúc này, nguyên tắc "Ưu tiên Lắp ráp hơn Kế thừa" lên ngôi. Thay vì ép buộc vào một hệ phả hệ cha-con cứng nhắc, chúng ta sử dụng mối quan hệ "HAS-A" (Có một). Tức là thay vì dùng từ khóa extends, bạn chỉ cần khai báo một Object của Class khác như một thuộc tính bên trong Class của bạn.

Hãy xem cách chúng ta chế tạo ChoRobot bằng cách "lắp ráp" thay vì kế thừa:

// 1. Chế tạo các "mảnh ghép" (Module) hoàn toàn độc lập
class ModulePhatAm {
    public void taoAmThanh(String amThanh) {
        System.out.println("Phát ra loa: " + amThanh);
    }
}

class ModuleDiChuyen {
    public void chayBangBanhXich() {
        System.out.println("Đang di chuyển tằng tằng bằng bánh xích...");
    }
}

// 2. "Lắp ráp" chúng vào thành phẩm ChoRobot
class ChoRobot {
    // KHÔNG dùng "extends Cho". 
    // Thay vào đó, ChoRobot "CÓ MỘT" cái loa và "CÓ MỘT" động cơ di chuyển
    private ModulePhatAm loa;
    private ModuleDiChuyen dongCo;

    // Khi khởi tạo ChoRobot, ta nhét các module này vào bên trong nó
    public ChoRobot() {
        this.loa = new ModulePhatAm();
        this.dongCo = new ModuleDiChuyen();
    }

    // Khi có lệnh báo động, ChoRobot sẽ nhờ (delegate) các module bên trong hoạt động
    public void baoDong() {
        dongCo.chayBangBanhXich();
        loa.taoAmThanh("Gâu gâu tít tít!");
    }
}

Sự khác biệt lớn nhất ở đây là: ChoRobot không bị ép phải mang theo bộ máy tiêu hóa dạ dày hay phổi của sinh vật sống (như khi nó extends Cho). Khi gọi hàm baoDong(), thực chất ChoRobot đang "nhờ" (delegate) cái loa và cái động cơ bên trong nó làm việc.

Nếu sau này khách hàng yêu cầu làm MeoRobot, bạn chỉ cần tạo class MeoRobot, bê nguyên cái ModuleDiChuyen ở trên lắp vào, và truyền lệnh tạo âm thanh "Meo meo tít tít" là xong! Tháo lắp dễ dàng như chơi Lego vậy.

Phần 2: Nâng tầm kiến trúc với 5 nguyên tắc SOLID

Vậy là chúng ta đã xây xong móng nhà vững chắc với 4 đặc tính OOP (Đóng gói, Kế thừa, Đa hình, Trừu tượng). Giờ là lúc tìm hiểu cách tổ chức các Class sao cho dự án không biến thành một mớ bòng bong khi nó lớn lên.

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

Nguyên tắc này phát biểu rất ngắn gọn: Một Class chỉ nên giữ một trách nhiệm duy nhất (tức là chỉ nên có một lý do để thay đổi).

Hãy quay lại Class TaiKhoanNganHang của chúng ta. Giả sử hiện tại, Class này đang làm 3 việc cùng lúc:

  1. Tính toán số dư, nạp/rút tiền.

  2. Kết nối với máy in để in biên lai giao dịch.

  3. Kết nối với nhà mạng để gửi SMS thông báo biến động số dư.

Ngày mai, ngân hàng quyết định đổi nhà cung cấp dịch vụ SMS, hoặc máy in bị đổi sang loại mới, chúng ta lại phải mở Class TaiKhoanNganHang ra để sửa code.

Theo bạn, việc nhồi nhét quá nhiều công việc thuộc các lĩnh vực khác nhau vào chung một Class cốt lõi như vậy sẽ tiềm ẩn những rủi ro gì khi chúng ta cần bảo trì hoặc nâng cấp phần mềm?

Nếu bạn đã trả lời được thì bạn đã bắt trúng "căn bệnh" nguy hiểm nhất của việc nhồi nhét code. Sự phụ thuộc chéo khiến một thay đổi nhỏ ở tính năng in ấn cũng có thể làm sập luôn chức năng tính tiền cốt lõi do vô tình gõ nhầm một dấu phẩy.

Để giải quyết, theo đúng chữ S, chúng ta tách hệ thống thành 3 Class hoàn toàn độc lập: TaiKhoanNganHang (chỉ lo số dư), MayInBienLai (chỉ lo in), và DichVuSMS (chỉ lo gửi tin). Lỗi ở khâu nào, ta chỉ mở đúng file của khâu đó ra sửa. Cực kỳ an toàn và dễ kiểm soát.

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

Nguyên tắc này phát biểu: Mở rộng thì thoải mái, nhưng đừng có dại mà sửa đổi code cũ.

Nhắc lại bài toán lúc nãy: Ứng dụng mua sắm của bạn có chức năng thanh toán. Nếu mỗi lần sếp yêu cầu tích hợp thêm một hình thức mới (Tiền mặt, Thẻ tín dụng, Momo, ZaloPay...), bạn lại mở Class ThanhToanDonHang ra và nối thêm một đoạn if-else vào, thì chúng ta lại quay về vết xe đổ: dễ làm hỏng code cũ đang chạy ngon lành. Chúng ta đang vi phạm quy tắc "Đóng để sửa đổi".

Chúng ta cần thiết kế sao cho hệ thống "Đóng" (không cần sửa code cũ) nhưng vẫn "Mở" (dễ dàng cắm thêm các loại ví điện tử mới vào).

Dựa vào các đặc tính OOP mà chúng ta vừa đi qua (gợi ý: hãy nghĩ đến công cụ Interface của Tính Trừu tượng và cách gọi hàm của Tính Đa hình), bạn thử hình dung xem chúng ta nên tạo ra một "bản hợp đồng" thanh toán như thế nào? Và khi có một ví Momo xuất hiện, class Momo sẽ phải làm gì với bản hợp đồng đó để hệ thống tự động hiểu?

Để thỏa mãn nguyên tắc Đóng/Mở (O), 'bản hợp đồng' lý tưởng nhất chính là một Interface. Thay vì code cứng từng loại ví, ta tạo ra một Interface ThanhToan chung. Khi đó, các class như MoMo hay ZaloPay sẽ implement Interface này và tự định nghĩa cách trừ tiền của riêng mình. Hệ thống lõi giờ đây hoàn toàn 'Đóng' (không cần sửa code cũ), nhưng vẫn 'Mở' (sẵn sàng đón nhận thêm VNPay chỉ bằng cách tạo class mới)

Đến đây, nguyên tắc O đã hoàn thành xuất sắc nhiệm vụ của mình. Nhưng khoan vội mừng Việc ép mọi phương thức thanh toán mới tuân theo chung một Interface đôi khi sẽ đẩy lập trình viên vào thế 'gượng ép' – bắt một đối tượng phải làm những việc trái với bản chất của nó (chẳng hạn như ép phương thức 'Tiền Mặt' phải thực thi lệnh quet_ma_QR() hay kiem_tra_so_du_ngan_hang()). Hệ quả là hệ thống sụp đổ ngay khi đang chạy (Runtime Error). Kẻ đứng sau ngăn chặn thảm họa này chính là chữ cái tiếp theo trong bộ quy tắc

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

Nguyên tắc này phát biểu: Các đối tượng của class con phải có thể thay thế được cho đối tượng của class cha (hoặc Interface) mà không làm hỏng tính đúng đắn của chương trình

Nói cách khác, nếu class con đã ký "hợp đồng" (implement Interface) thì nó phải thực hiện được đầy đủ và đúng bản chất các điều khoản trong hợp đồng đó.

Hãy thử đưa thiết kế của bạn vào thực tế. Interface yêu cầu bắt buộc phải có hàm thanh_toan()lich_su_thanh_toan()

Bây giờ cửa hàng yêu cầu thêm hình thức ThanhToanTienMat (Cash). Khi trả tiền mặt cho shipper, chúng ta không có kết nối với hệ thống ngân hàng hay API nào để truy xuất dữ liệu lịch sử số hóa như MoMo.

Theo bạn, nếu class ThanhToanTienMat bắt buộc phải implement Interface trên, bạn sẽ viết code gì bên trong hàm lich_su_thanh_toan() của class này, và điều đó có thể gây ra rủi ro sập hệ thống như thế nào khi phần mềm kế toán tự động chạy vòng lặp để rút trích lịch sử của hàng ngàn đơn hàng?

Nếu các bạn chưa có câu trả lời thì không có gì phải băn khoăn cả, tình huống "gượng ép" này cực kỳ phổ biến trong thực tế. Chúng ta cùng mổ xẻ nhé 🛠️

Khi class ThanhToanTienMat bị ép phải thực thi hàmlich_su_thanh_toan() từ Interface, lập trình viên thường gượng ép viết đoạn code bên trong theo 2 hướng:

  1. Trả về giá trị null hoặc danh sách rỗng (vì tiền mặt đưa tay qua tay thì làm gì có lịch sử API).

  2. Chủ động ném ra một báo lỗi (Exception) kiểu như: "Tính năng này không hỗ trợ cho giao dịch tiền mặt".

Bây giờ, hãy tưởng tượng phần mềm kế toán cuối ngày sẽ chạy một vòng lặp qua danh sách 1000 đơn hàng (gồm cả MoMo, ZaloPay và Tiền mặt). Nhờ Tính Đa hình, vòng lặp tự tin gọi chung một lệnh don_hang.lich_su_thanh_toan() cho tất cả.

Chuyện gì sẽ xảy ra khi vòng lặp chạm đến đơn hàng "Tiền mặt"?

  • Nếu nhận về null, hệ thống kế toán tiếp tục cố gắng đọc dữ liệu bên trong và lập tức văng lỗi NullPointerException (lỗi "tử thần" hay làm sập các ứng dụng Java).

  • Nếu class ném ra một Exception, vòng lặp cũng bị bẻ gãy, phần mềm dừng đột ngột và báo cáo không thể in ra.

Đây chính là sự vi phạm nguyên tắc L - Liskov Substitution (LSP) ⚠️. Class con (ThanhToanTienMat) đã không thể thay thế một cách an toàn cho Interface gốc. Nó "ký hợp đồng" hứa cung cấp lịch sử, hệ thống tin tưởng điều đó, nhưng khi chạy thực tế thì nó lại phản hồi một cách bất thường và làm sập toàn bộ quy trình.

Để hệ thống không sập, chúng ta không được phép tạo ra một "bản hợp đồng" ép buộc class con nhận những phương thức mà nó không có khả năng thực hiện.

Thay vì bắt mọi loại thanh toán dùng chung một Interface khổng lồ chứa tất cả các tính năng, bạn nghĩ sao về việc "chẻ" nó ra? Bạn sẽ tách các hàm như thanh_toan()lich_su_thanh_toan() thành các Interface nhỏ gọn và độc lập với nhau như thế nào?

Đó cũng chính là nội dung của chữ cái tiếp theo ngay sau đây.

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

Nguyên tắc này khuyên rằng: Không nên ép buộc một class phải implement những phương thức mà nó không sử dụng.

Bằng cách tạo ra Interface ThanhToan (chỉ có hàm thanh toán) và Interface LichSu (chỉ có hàm lịch sử), bạn đã giúp hệ thống linh hoạt và an toàn hơn rất nhiều:

  • ThanhToanTienMat chỉ cần implement Interface ThanhToan.

  • MoMo hay ZaloPay có thể implement cả hai Interface trên. Nhờ vậy, không class nào bị ép làm những việc nằm ngoài khả năng của chúng

Bây giờ, chúng ta sẽ đến với mảnh ghép cuối cùng của bức tranh SOLID:

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

Nguyên tắc này chỉ ra rằng:

  1. Các class cấp cao (chứa logic nghiệp vụ chính) không nên phụ thuộc trực tiếp vào các class cấp thấp (chứa chi tiết thực thi). Cả hai nên phụ thuộc vào Trừu tượng (Interface/Abstract Class).

  2. Trừu tượng không nên phụ thuộc vào chi tiết, mà chi tiết phải phụ thuộc vào Trừu tượng.

Hãy đưa lý thuyết này vào ví dụ thực tế. Giả sử bạn có class CuaHang (class cấp cao) làm nhiệm vụ thu tiền. Nếu bên trong class này, bạn khai báo cứng một loại ví điện tử như sau:

class CuaHang {
    // Cửa hàng đang phụ thuộc trực tiếp vào một class chi tiết (cấp thấp)
    private ThanhToanMoMo hinhThucThuTien = new ThanhToanMoMo(); 

    public void thuTienKhachHang() {
        hinhThucThuTien.thanhToan();
    }
}

Với cách viết này, CuaHang đang bị "trói chặt" vào ThanhToanMoMo. Nếu một khách hàng bước vào và muốn trả bằng Tiền mặt, hệ thống sẽ không xử lý được trừ khi bạn mở class CuaHang ra để sửa code.

Thay vì phụ thuộc vào một class chi tiết như ThanhToanMoMo, chúng ta cần làm cho CuaHang phụ thuộc vào một Trừu tượng. Dựa vào chính Interface ThanhToan mà bạn vừa thiết kế, bạn sẽ sửa lại cách khai báo biến hinhThucThuTien bên trong class CuaHang như thế nào để cửa hàng này có thể linh hoạt chấp nhận bất kỳ hình thức thanh toán nào (Tiền mặt, MoMo, ZaloPay...)?

Nếu bạn chưa có câu trả lời, thì Chữ D này thường là nguyên tắc "khó nhằn" nhất khi mới học, chúng ta cùng "mổ xẻ" nó ngay trên code cho dễ hình dung nhé. 🛠️

Vấn đề cốt lõi của đoạn code cũ nằm ở từ khóa new:

private ThanhToanMoMo hinhThucThuTien = new ThanhToanMoMo();

Khi class CuaHang tự tay new (tạo ra) một đối tượng ThanhToanMoMo, nó đã tự "trói chết" mình vào cái ví MoMo đó. Cửa hàng đang đóng vai trò "kẻ quyết định" chi tiết.

Để Đảo ngược phụ thuộc, chúng ta làm 2 việc:

  • Đổi kiểu dữ liệu: Khai báo biến bằng Interface ThanhToan thay vì class cụ thể.

  • Không tự tạo đối tượng (Không dùng new): Cửa hàng sẽ không tự tạo hình thức thanh toán nữa, mà bắt buộc một bộ phận khác từ bên ngoài phải "bơm" (truyền) hình thức thanh toán đó vào cho nó. Kỹ thuật này gọi là Dependency Injection (Tiêm phụ thuộc).

Đoạn code chuẩn SOLID sẽ trông như thế này:

class CuaHang {
    // 1. Phụ thuộc vào Trừu tượng (Interface), không quan tâm chi tiết bên dưới là ví gì
    private ThanhToan hinhThucThuTien; 

    // 2. Nhận hình thức thanh toán từ bên ngoài truyền vào qua Constructor
    public CuaHang(ThanhToan hinhThuc) {
        this.hinhThucThuTien = hinhThuc;
    }

    public void thuTienKhachHang() {
        // Cứ gọi hàm thanh toán, tự hệ thống đa hình sẽ biết phải trừ tiền ở đâu
        hinhThucThuTien.thanhToan();
    }
}

Sự kỳ diệu diễn ra khi phần mềm hoạt động:

Lúc này, class CuaHang hoàn toàn trong sạch và linh hoạt. Ở hàm chạy chính (hàm main), tùy vào khách hàng chọn gì, chúng ta chỉ cần truyền cái đó vào Cửa hàng:

  • Khách dùng Momo:

    CuaHang cuaHang = new CuaHang(new ThanhToanMoMo());

  • Khách dùng Tiền mặt:

    CuaHang cuaHang = new CuaHang(new ThanhToanTienMat());

Cửa hàng nhận cái gì thì sẽ thu tiền bằng cái đó mà bạn không cần phải sửa lại dù chỉ một dấu phẩy bên trong class CuaHang.

Phần 3: Bác sĩ phần mềm và Nghệ thuật đánh đổi (Trade-offs)

Các bạn đã kiên nhẫn cùng mình đi qua trọn vẹn 4 đặc tính của OOP và cả 5 nguyên tắc trong SOLID. Nó thực sự là một lượng kiến thức rất lớn và mang tính tuy duy nền tảng cao. Để kiểm tra lại độ "thấm" của những kiến thức, chúng ta hãy cùng nhau làm một bài tập nhỏ: Dưới đây là một đoạn code đang bị thiết kế rất tệ, và chúng ta sẽ cùng nhau đóng vai trò "Bác sĩ phần mềm" để bắt bệnh xem nó đang vi phạm nguyên tắc nào trong SOLID nhé.

1. Bác sĩ phần mềm

Bác sĩ phần mềm mặc áo blouse trắng vào nhé, chúng ta có một "bệnh nhân" đang chờ. 🩺

Đây là hệ thống tính lương của một công ty khởi nghiệp viết bằng Java:

class NhanVien {
    public String ten;
    public String loaiNhanVien; // Nhận các giá trị: "DEV", "QA", "MANAGER"

    public NhanVien(String ten, String loaiNhanVien) {
        this.ten = ten;
        this.loaiNhanVien = loaiNhanVien;
    }
}

class BoPhanKeToan {
    public double tinhLuong(NhanVien nv) {
        if (nv.loaiNhanVien.equals("DEV")) {
            return 20000000; // Lương cơ bản + thưởng code
        } else if (nv.loaiNhanVien.equals("QA")) {
            return 15000000; // Lương cơ bản + thưởng tìm bug
        } else if (nv.loaiNhanVien.equals("MANAGER")) {
            return 30000000; // Lương cơ bản + thưởng KPI + phụ cấp
        } else {
            return 0;
        }
    }
}

Tình trạng bệnh lý: Code hiện tại đang chạy rất tốt. Tuy nhiên, tháng sau công ty mở rộng quy mô và sẽ tuyển thêm 2 vị trí mới là "DESIGNER""MARKETING", mỗi vị trí lại có công thức cộng tiền thưởng, tiền hoa hồng hoàn toàn khác nhau.

Câu hỏi chẩn đoán dành cho Bác sĩ: Nhìn vào class BoPhanKeToan và hàm tinhLuong(), theo bạn đoạn code này đang vi phạm nguyên tắc nào trong 5 chữ cái của SOLID? Chuyện gì sẽ xảy ra với file code này khi công ty tuyển thêm 2 vị trí mới vào tháng sau?

Nếu bạn trả lời là chữ O thì bạn đúng là bác sĩ giỏi đấy. Đoạn code trên đang vi phạm nghiêm trọng chữ O - Open/Closed Principle. Căn bệnh điển hình nhất của việc vi phạm nguyên tắc này chính là lạm dụng các chuỗi if-else hoặc switch-case để kiểm tra loại (type) của đối tượng. Với cách thiết kế trên, mỗi khi có thêm vị trí mới, ta bắt buộc phải "mổ" class BoPhanKeToan ra để sửa, nguy cơ gây lỗi cho các logic tính lương cũ là rất cao. Giờ thì kê đơn cho bệnh nhân nào

Kê đơn 1: Dùng Kế thừa (Inheritance)

Với cách này, class NhanVien sẽ trở thành một lớp trừu tượng (Abstract Class).

Chỉnh sửa nhỏ cho bạn: Khi đã tách thành các class con (Dev, QA), chúng ta không cần giữ lại biến String loaiNhanVien nữa. Chính cái tên Class đã nói lên loại nhân viên rồi.

abstract class NhanVien {
    public String ten;
    public NhanVien(String ten) { this.ten = ten; }
    
    // Hàm trừu tượng, bắt buộc class con phải tự tính
    public abstract double tinhLuong(); 
}

class Dev extends NhanVien {
    public Dev(String ten) { super(ten); }
    @Override
    public double tinhLuong() { return 20000000; }
}

// Bên trong Bộ phận Kế toán giờ cực kỳ gọn gàng (Tuân thủ Đóng/Mở):
class BoPhanKeToan {
    public double tinhLuong(NhanVien nv) {
        return nv.tinhLuong(); // Tự động gọi đúng hàm của Dev, QA hay Manager
    }
}

Kê đơn 2: Dùng Interface (Composition - Mẫu thiết kế Strategy)

Đây chính là ý tưởng thứ 2 của bạn (tạo một Interface chứa hành vi tính lương). Ý tưởng này mang dáng dấp của một Design Pattern rất nổi tiếng là Strategy Pattern.

Thay vì để nhân viên tự tính lương (nghe hơi sai sai về mặt thực tế đúng không?), ta cung cấp cho họ một "Chính sách lương" (Interface).

interface ChinhSachLuong {
    double tinhToan();
}

class LuongDev implements ChinhSachLuong {
    public double tinhToan() { return 20000000; }
}

class NhanVien {
    public String ten;
    private ChinhSachLuong chinhSach; // Nhận Interface từ bên ngoài

    public NhanVien(String ten, ChinhSachLuong chinhSach) {
        this.ten = ten;
        this.chinhSach = chinhSach;
    }

    public double nhanLuong() {
        return chinhSach.tinhToan();
    }
}

Cả hai cách của bạn đều giải quyết triệt để sự vi phạm nguyên tắc O. Tuy nhiên, trong thực tế phát triển phần mềm, yêu cầu luôn thay đổi liên tục.

Giả sử hôm nay bạn nhân viên tên "Tèo" đang là Dev, nhưng tháng sau Tèo được thăng chức lên làm Manager. Theo bạn, giữa Cách 1 (Kế thừa) và Cách 2 (Dùng Interface), cách nào sẽ giúp chúng ta dễ dàng "nâng cấp" chức vụ cho Tèo trên hệ thống mà không cần phải xóa đối tượng cũ đi tạo lại từ đầu? Mổ xẻ thử xem nào🛠️

Cách 1: Kế thừa (Inheritance) - Sự trói buộc vĩnh viễn ⛓️

  • Thiết kế: Bạn tạo class Dev extends NhanVien và class Manager extends NhanVien.

  • Tạo đối tượng: NhanVien teo = new Dev("Tèo");

  • Khuyết điểm: Trong thế giới OOP, bản chất của teo đã bị "đúc khuôn" vĩnh viễn là một Dev ngay từ lúc dùng từ khóa new. Khi sếp yêu cầu thăng chức, hệ thống không có cách nào biến object Dev thành object Manager được. Cách duy nhất là... "đuổi việc" Tèo (xóa object cũ đi) và tạo một object Tèo hoàn toàn mới: NhanVien teo_moi = new Manager("Tèo");.

  • Hậu quả: Toàn bộ dữ liệu gắn liền với Tèo cũ (mã nhân viên, số ngày nghỉ phép, lịch sử công tác...) sẽ bị "bay màu" hoặc tốn rất nhiều công sức để copy sang object mới.

Cách 2: Dùng Interface (Composition) - Lắp ráp linh hoạt 🧩

  • Thiết kế: Chúng ta tạo một Interface ChinhSachLuong. Các class như LuongDev, LuongManager sẽ là những "linh kiện" rời rạc tuân theo hợp đồng này.

  • Class NhanVien không kế thừa ai cả. Nó chỉ sở hữu một thuộc tính là ChinhSachLuong (giấu kín bằng private) và mở ra một cánh cửa Setter.

  • Phép màu thăng chức: Object teo vẫn đứng yên đó, mọi dữ liệu cá nhân được bảo toàn. Chúng ta chỉ đơn giản là gọi hàm Setter để "rút" linh kiện cũ ra và "cắm" linh kiện mới vào.

// Tháng 1: Tèo vào làm Dev
NhanVien teo = new NhanVien("Tèo", new LuongDev());

// Tháng 2: Tèo được thăng chức lên Manager
// Chỉ việc thay "ruột" công thức tính lương, giữ nguyên thân xác nhân viên
teo.setChinhSachLuong(new LuongManager());

Kết luận: Bài toán này chính là minh chứng sống động nhất cho câu thần chú của các Senior mà chúng ta đã đề cập phía trên: "Hãy ưu tiên sử dụng Composition (Lắp ráp) thay vì Inheritance (Kế thừa)", và nó cũng là cốt lõi của Mẫu thiết kế Strategy Pattern giúp thỏa mãn tuyệt đối nguyên tắc Đóng/Mở (O).

2. Nghệ thuật đánh đổi (Trade-offs)

Thuộc lòng 5 nguyên tắc SOLID sẽ đưa bạn từ mức Junior lên Mid-level. Nhưng để bước chân vào lãnh địa của một Senior thực thụ, bạn phải biết cách... 'chối bỏ' chúng đúng lúc. Một hệ thống hoàn hảo về mặt kiến trúc đôi khi lại chính là kẻ thù của tốc độ và tính thực dụng. Khi nào nên theo đuổi sự hoàn hảo của SOLID, và khi nào nên nhắm mắt viết những dòng code 'mì ăn liền'? Hãy cùng bước vào phần quan trọng nhất của bài viết: Bác sĩ phần mềm và Nghệ thuật đánh đổi (Trade-offs).

Như mình đã nhắc ở đầu bài học, SOLID là "kim chỉ nam", không phải đạo luật. Việc cố gắng nhồi nhét cả 5 nguyên tắc vào mọi dòng code sẽ dẫn đến một vấn đề gọi là Over-engineering (Thiết kế thừa).

Hãy hình dung việc viết code giống như xây dựng:

  • 🐕 Xây chuồng heo: Bạn chỉ cần vài miếng gỗ, cái búa và đinh. Nếu bạn vẽ bản thiết kế 3D, thuê chuyên gia kết cấu và đổ móng bê tông (tức là áp dụng SOLID triệt để), đó là sự lãng phí khủng khiếp về thời gian và công sức.

  • 🏢 Xây tòa nhà 100 tầng: Nếu bạn không có bản thiết kế kỹ lưỡng, không chia nhỏ từng module (hệ thống điện, nước, thông gió) hoàn toàn độc lập với nhau, tòa nhà chắc chắn sẽ sập.

S (Đơn trách nhiệm) và O (Đóng/Mở): Đây là cốt lõi của code sạch. Luôn nên được ưu tiên để code dễ đọc và dễ bảo trì, dù dự án lớn hay nhỏ.

L (Thay thế Liskov): Bắt buộc tuân thủ nếu bạn đã quyết định dùng Kế thừa (Inheritance). Nếu vi phạm, phần mềm sẽ sập ngay lúc chạy (như lỗi ép Mèo đi giữ nhà của Chó mà ta đã phân tích).

I (Phân tách Interface) và D (Đảo ngược phụ thuộc): Đây là 2 nguyên tắc dễ gây ra Over-engineering nhất. Việc tuân thủ chúng đòi hỏi bạn phải tạo ra thêm rất nhiều Interface và Class phụ trợ. Với các module quá đơn giản, việc áp dụng I và D khiến số lượng file tăng vọt, làm luồng code bị phân mảnh và khó đọc hơn cả việc viết trực tiếp.

Bây giờ, hãy thử đóng vai một Tech Lead (Trưởng nhóm kỹ thuật) nhé:

Giả sử sếp yêu cầu team bạn viết một đoạn script nhỏ để đọc dữ liệu từ một file Excel cũ, tính tổng doanh thu và in ra màn hình. Sếp nhấn mạnh: "Cái này chỉ dùng để đối soát nhanh 1 lần cho cuộc họp chiều nay rồi bỏ, không bao giờ dùng lại".

Một bạn Junior trong team nghe vậy liền hào hứng đề xuất: "Em sẽ tạo 3 Interface DocDuLieu, TinhToan, HienThi và dùng Dependency Injection để các class không phụ thuộc vào nhau, chuẩn SOLID luôn anh nhé"

Dựa trên nguyên lý "đánh đổi" vừa học, bạn sẽ khuyên bạn Junior này như thế nào?

Trong tình huống này, việc đẻ ra 3 Interface và dùng Dependency Injection cho một đoạn script dùng một lần chính là Over-engineering (Thiết kế thừa).

Thay vì SOLID, lúc này Bác sĩ/Tech Lead sẽ khuyên bạn Junior áp dụng 2 nguyên tắc thực tế khác:

  • KISS (Keep It Simple, Stupid) 🎯: Cố gắng giữ mọi thứ đơn giản nhất có thể. Với script dùng 1 lần, đôi khi chỉ cần viết thẳng tất cả các bước từ trên xuống dưới trong một hàm main là đủ. Làm xong trong 10 phút và đi họp.

  • YAGNI (You Aren't Gonna Need It) 🚫: Đừng code những tính năng mà bạn "nghĩ rằng tương lai sẽ cần đến". Sếp đã bảo dùng 1 lần rồi bỏ, thì không cần thiết kế kiến trúc để "mở rộng cho tương lai".

Kết bài

Học OOP và SOLID không phải là việc học thuộc cú pháp, mà là hành trình nâng cấp tư duy thiết kế hệ thống.

OOP trao cho bạn những viên gạch vững chắc, còn SOLID cung cấp bản vẽ kiến trúc để đảm bảo tòa nhà dự án không sụp đổ khi phình to. Nhưng cảnh giới cao nhất của một kỹ sư phần mềm lại nằm ở Nghệ thuật đánh đổi — biết khi nào cần "đổ móng bê tông" hoàn hảo cho hệ thống lõi, và khi nào chỉ cần "dựng lều gỗ" đơn giản (KISS/YAGNI) cho tính năng dùng một lần.

Kiến trúc phần mềm không có đúng sai tuyệt đối, chỉ có sự phù hợp. Hãy code bằng tư duy, đừng code bằng thói quen!

Và bây giờ, hãy nhìn lại dự án của bạn: Chữ cái nào trong bộ nguyên tắc SOLID đang bị "ngó lơ" nhiều nhất và khiến code base trở thành mớ bòng bong? Bạn sẽ dùng "đơn thuốc" nào để bắt đầu dọn dẹp nó vào ngày mai?


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.