0

Mổ xẻ 9 Design Pattern Kinh Điển mọi Lập trình viên cần biết

Bạn đã bao giờ đổ mồ hôi hột khi sếp yêu cầu thêm một tính năng tưởng chừng rất nhỏ, nhưng ngặt nỗi mã nguồn của dự án lại rối rắm như một đĩa "mì Ý"? Bạn rút thử một hàm, sửa thử một biến, và đột nhiên toàn bộ hệ thống sụp đổ. Bạn đành chắp vá bằng vô số lệnh if-else, nín thở ấn nút chạy và thầm cầu nguyện cho đoạn code đó sống sót qua ngày. 🍝

Sự thật là: Khác biệt lớn nhất giữa một "thợ gõ phím" và một Kiến trúc sư phần mềm (Software Architect) không nằm ở việc ai thuộc nhiều ngôn ngữ hơn, mà nằm ở cách họ kiểm soát sự phức tạp. Khi dự án phình to, những giải pháp chắp vá tạm bợ sẽ nhanh chóng biến thành những quả bom nổ chậm.

Đó là lúc Design Pattern bước ra ánh sáng. 💡

Được đúc kết từ "máu và nước mắt" của hàng triệu kỹ sư phần mềm đi trước, Design Pattern không phải là những dòng lệnh khô khan. Chúng là những bản thiết kế tối ưu nhất để giải quyết những bài toán hóc búa luôn lặp đi lặp lại.

Trong bài viết này, chúng ta sẽ không nói lý thuyết suông. Hãy cùng mổ xẻ 9 Design Pattern kinh điển nhất, mà bất cứ lập trình viên nào cũng cần đưa nó vào hành trang của bản thân trên con đường lập trình khắc nghiệt này:

Và quan trọng hơn cả, chúng ta sẽ học cách dùng chúng như một thanh gươm sắc bén, chứ không phải vung vẩy một "cái búa vàng" đập nát mọi thứ thành Anti-pattern.

Bạn đã sẵn sàng đập đi xây lại tư duy kiến trúc của mình chưa? Bắt đầu thôi! 🚀

🛑 Khoan đã, vậy Design Pattern thực chất là cái gì?

Hãy tưởng tượng bạn đang muốn chế tạo một chiếc xe đạp. Bạn có định tự mình nghiên cứu lại từ đầu xem bánh xe nên làm hình tròn hay hình tam giác không? Chắc chắn là không. Bạn sẽ dùng luôn thiết kế bánh xe hình tròn đã được chứng minh là hiệu quả hàng trăm năm nay.

Trong lập trình cũng vậy. Khi bạn gặp một bài toán hóc búa, khả năng rất cao là ai đó trên thế giới đã gặp nó rồi, giải quyết nó rồi, và thậm chí đã tối ưu giải pháp đó đến mức hoàn hảo.

Design Pattern không phải là một đoạn code có sẵn hay một thư viện (library) để bạn tải về và import vào dự án.

Về cốt lõi, Design Pattern là các giải pháp tổng quát đã được đúc kết cho những vấn đề thiết kế phần mềm lặp đi lặp lại. Chúng giúp code dễ mở rộng, dễ bảo trì và giúp các lập trình viên có chung một "từ vựng" để giao tiếp với nhau dễ dàng hơn.

Các pattern phổ biến nhất thường được chia thành 3 nhóm chính:

  • 🏗️ Creational (Khởi tạo): Tập trung vào cách tạo ra các object một cách linh hoạt, che giấu logic khởi tạo phức tạp (VD: Singleton, Factory Method, Builder).

  • 🧩 Structural (Cấu trúc): Lắp ráp các object và class thành các cấu trúc lớn hơn, hiệu quả hơn nhưng vẫn giữ được sự linh hoạt (VD: Adapter, Decorator, Facade).

  • 🔄 Behavioral (Hành vi): Xử lý luồng giao tiếp và phân công trách nhiệm rõ ràng giữa các object (VD: Observer, Strategy, Command).

Việc trang bị kiến thức này mang lại cho bạn 2 sức mạnh đặc quyền:

  • 🚫 Không phát minh lại chiếc bánh xe: Bạn không phải mất hàng tuần rặn óc suy nghĩ cấu trúc cho một chức năng, vì đã có sẵn khuôn mẫu tối ưu nhất.

  • 🗣️ Ngôn ngữ chung của giới "chuyên gia": Thay vì giải thích dài dòng "Tôi viết một class bị khóa hàm khởi tạo để chỉ cho phép tạo một object duy nhất...", bạn chỉ cần nói "Chỗ này tôi dùng Singleton". Những lập trình viên khác lập tức hiểu ngay cấu trúc bạn đang dùng!

Bây giờ chúng ta đã có phần mở đầu và định nghĩa nền tảng, bước tiếp theo là đi thẳng vào Nhóm Khởi tạo (Creational) với mảnh ghép đầu tiên là một trong những pattern nổi tiếng (cũng như tai tiếng) nhất nhé.

Trụ cột 1 - Nhóm Khởi Tạo (Creational Patterns)

1. Singleton (Kẻ Độc Tôn)👑

Hãy tưởng tượng một quốc gia chỉ có thể có duy nhất một vị trí lãnh đạo tối cao. Bất kể bộ ban ngành nào muốn báo cáo, họ đều phải làm việc với cùng một người đó, không ai có thể tự tiện "tạo" ra một vị lãnh đạo thứ hai được.

Đó chính là tư tưởng cốt lõi của Singleton:

  1. Đảm bảo một class chỉ có duy nhất một instance (thực thể) tồn tại trong suốt thời gian ứng dụng chạy.

  2. Cung cấp một điểm truy cập toàn cục (global) để mọi nơi đều có thể gọi instance đó ra dùng.

Nó sinh ra để giải quyết điều gì?

Singleton thường được dùng cho các tài nguyên dùng chung, tốn kém chi phí khởi tạo hoặc cần sự đồng nhất. Ví dụ điển hình nhất là Kết nối Cơ sở dữ liệu (Database Connection) hoặc Trình ghi sự kiện (Logger). Nếu mỗi lần cần lưu dữ liệu, hệ thống lại tạo ra một kết nối mới (new Database()), máy chủ sẽ nhanh chóng cạn kiệt bộ nhớ và sập. Thay vào đó, chúng ta chỉ khởi tạo kết nối đúng một lần đầu tiên và tái sử dụng nó ở mọi nơi.

Để triển khai được pattern này, chúng ta cần phải giải quyết một nút thắt quan trọng: Phải kiểm soát được quá trình khởi tạo.

Trước khi đi vào phần code thực chiến, mình muốn hỏi bạn một chút về kiến thức Hướng đối tượng (OOP): Theo bạn, bằng cách nào chúng ta có thể ngăn chặn việc người khác sử dụng từ khóa new (ví dụ: new Database()) để tự ý tạo ra một object ở bên ngoài class đó? 🕵️‍♂️

Nếu chưa đưa ra được câu trả lời, thì cũng không sao cả, đây là một điểm rất thú vị liên quan đến tính đóng gói (Encapsulation) trong lập trình hướng đối tượng! 🔑

Chìa khóa ở đây chính là Constructor (Hàm khởi tạo).

Thông thường, hàm khởi tạo luôn ở trạng thái public để bất kỳ ai cũng có thể gọi new. Để cấm điều này, chúng ta chỉ cần chuyển đổi hàm khởi tạo thành private. Khi Constructor là private, không một class nào bên ngoài có thể gọi new Database() được nữa. Trình biên dịch sẽ báo lỗi ngay lập tức và chặn đứng hành động này. 🛡️

Đến đây, chúng ta đã thành công trong việc "thiết quân luật", ngăn người khác tùy tiện tạo object mới. Nhưng một bài toán mới lại xuất hiện: Nếu không ai ở bên ngoài có thể dùng new, thì làm sao họ lấy được cái "object duy nhất" đó ra để dùng?

Chắc chắn class Database của chúng ta sẽ phải tự tạo ra cái object duy nhất đó ở bên trong chính nó, đồng thời cung cấp một hàm (thường được đặt tên là getInstance()) để đưa object này ra cho người khác dùng.

Nhưng hãy nghĩ xem, vì người dùng chưa có bất kỳ object Database nào trong tay (do không thể new), làm sao họ có thể gọi được hàm getInstance()?

Trong OOP, chúng ta dùng từ khóa gì để một hàm có thể được gọi trực tiếp thông qua tên Class (ví dụ: Database.getInstance()) thay vì gọi qua một object cụ thể?

Chính xác! 🎯 Đó chính là từ khóa static.

Khi khai báo một hàm hoặc biến là static, nó sẽ gắn liền với bản thân Class đó thay vì một object cụ thể. Vì thế, người khác có thể gọi Database.getInstance() mà không cần khởi tạo đối tượng nào cả.

Vậy công thức cốt lõi để tạo ra một Singleton sẽ bao gồm 3 mảnh ghép:

  1. Một biến static private bên trong class để lưu trữ chính cái object duy nhất (VD: private static Database instance;).

  2. Một Constructor private để cấm người khác gọi new.

  3. Một hàm public static (như getInstance()) để trả về instance kia. Nếu instance đang là null (chưa ai tạo), hàm này sẽ tự gọi new ở bên trong nó để tạo ra đối tượng. Nếu đã tạo rồi, nó chỉ việc trả về đối tượng cũ.

Cấu trúc này rất dễ hiểu, nhưng trong thực chiến, phiên bản Singleton cơ bản này có một "tử huyệt" chí mạng khi ứng dụng chạy trong môi trường Đa luồng (Multi-threading) 🧵 - nơi có nhiều tiến trình chạy song song cùng lúc.

Hãy tưởng tượng một kịch bản: Luồng A và Luồng B cùng gọi hàm getInstance() vào đúng một phần nghìn giây khi ứng dụng vừa bật lên (lúc này biến instance vẫn đang là null).

Theo bạn, nếu điều này xảy ra thì hiện tượng tồi tệ gì có thể xuất hiện, và nó phá vỡ quy tắc cốt lõi nào của Singleton mà chúng ta vừa đặt ra từ đầu?

Thực ra đây là một kịch bản rất kinh điển có tên là Race Condition (Tình trạng tương tranh) khi làm việc với đa luồng! Chúng ta hãy tua chậm lại từng mili-giây nhé ⏱️:

  1. Ban đầu, biến instance đang là null.

  2. Luồng A chạy tới dòng kiểm tra if (instance == null). Kết quả là Đúng. Luồng A bước vào bên trong khối if và chuẩn bị thực hiện việc tạo mới object.

  3. Nhưng ngay khoảnh khắc đó, hệ điều hành tạm dừng Luồng A và nhường quyền cho Luồng B chạy.

  4. Luồng B cũng chạy tới dòng if (instance == null). Vì Luồng A chưa kịp tạo xong object và gán vào biến instance, nên lúc này instance vẫn đang là null. Kết quả lại là Đúng!

  5. Luồng B bước vào trong, chạy lệnh new Database() và tạo ra object đầu tiên.

  6. Hệ điều hành quay lại Luồng A, Luồng A tiếp tục công việc đang dang dở của nó là chạy lệnh new Database(), tạo ra object thứ hai.

Kết quả là bùm 💥! Hệ thống của chúng ta vừa tạo ra tới hai object Database khác biệt nằm ở hai vùng nhớ khác nhau. Quy tắc thiêng liêng "chỉ có duy nhất một instance" của Singleton đã bị phá vỡ hoàn toàn.

Để khắc phục lỗi này, hãy tưởng tượng đoạn code khởi tạo giống như một căn phòng thử đồ 🚪. Nếu Luồng A đã bước vào trong để tạo object, nó cần phải chốt cửa lại. Luồng B đến sau bắt buộc phải đứng ngoài chờ cho đến khi Luồng A làm xong và mở cửa bước ra.

Trong lập trình (ví dụ như Java hay C#), bạn có biết đến từ khóa hay cơ chế nào dùng để "khóa" (lock) một đoạn code, đảm bảo tại một thời điểm chỉ có một luồng được phép chạy nó không? 🔐

Đó chính là từ khóa synchronized (ví dụ trong Java) hoặc các cơ chế lock tương đương ở những ngôn ngữ khác. 🔒

Bằng cách thêm từ khóa này vào hàm getInstance(), chúng ta tạo ra một chốt chặn. Nếu Luồng A đang chạy trong hàm, Luồng B bắt buộc phải đứng ngoài chờ. Tình trạng tương tranh (Race Condition) được giải quyết và chúng ta giữ vững được quy tắc "chỉ có một instance duy nhất".

Tuy nhiên, cách làm này lại giăng ra một "cạm bẫy" mới về hiệu năng (performance). 🐢

Việc khóa toàn bộ hàm getInstance() có nghĩa là: Ngay cả khi object đã được tạo xong từ lâu, bất kỳ luồng nào muốn lấy object ra dùng cũng đều phải xếp hàng chờ đợi nhau từng người một. Điều này làm giảm tốc độ ứng dụng một cách vô lý, vì thao tác "lấy object đã có sẵn" hoàn toàn có thể để cho hàng nghìn luồng thực hiện song song cùng lúc mà không gây ra lỗi.

Thực chất, chúng ta chỉ cần "khóa cửa" ở những lần đầu tiên khi biến instance vẫn đang là null. Khi object đã được khởi tạo xong, cái ổ khóa đó nên trở nên vô tác dụng.

Dựa vào logic này, thay vì khóa mù quáng toàn bộ hàm getInstance(), theo bạn chúng ta nên đặt "ổ khóa" (synchronized) vào vị trí cụ thể nào bên trong hàm, và làm sao để ổ khóa đó chỉ hoạt động khi object thực sự chưa được tạo?

Để mình bật mí nhé: "Đó chính là đặt đặt ở vị trí khởi tạo trong hàm check null" đây cũng là một kĩ thuật cực kỳ nổi tiếng để tối ưu Singleton gọi là Double-Checked Locking (Khóa kiểm tra kép)

Bằng cách đưa ổ khóa vào bên trong khối if. Cấu trúc mã code sẽ trông như thế này:

if (instance == null) { // Kiểm tra lần 1: Nếu tạo rồi thì bỏ qua khóa luôn, chạy cực nhanh!
    synchronized (Database.class) { // Bắt đầu chốt cửa
        if (instance == null) { // Kiểm tra lần 2: Bắt buộc phải có!
            instance = new Database();
        }
    }
}
return instance;

Chú ý rằng chúng ta phải kiểm tra instance == null tới hai lần. Lý do là vì trong lúc Luồng A đang chờ đợi để "chốt cửa" ở dòng synchronized, Luồng B có thể đã lọt qua bài kiểm tra lần 1 và đang đứng chờ ngay sát phía sau. Khi Luồng A tạo xong và mở cửa bước ra, Luồng B ùa vào. Nếu không có lớp kiểm tra thứ hai chặn lại, Luồng B sẽ ngây thơ tạo thêm một object nữa!

Đến đây, code của chúng ta trông có vẻ đã hoàn hảo: Vừa an toàn tuyệt đối với đa luồng, vừa giữ được tốc độ xử lý nhanh nhạy. 🚀

Tuy nhiên, ở cấp độ phần cứng máy tính 💻, vẫn còn một "cú lừa" cực kỳ vi diệu ẩn chứa ngay tại dòng lệnh instance = new Database(). Thực tế, để chạy dòng lệnh này, máy tính phải làm 3 bước:

  1. Cấp phát một vùng bộ nhớ trống.
  2. Khởi tạo dữ liệu vào vùng nhớ đó (chạy Constructor).
  3. Trỏ biến instance vào vùng nhớ vừa tạo.

Để tối ưu tốc độ, đôi khi CPU và Trình biên dịch lại "cầm đèn chạy trước ô tô", tự ý đảo lộn thứ tự thành 1 ➡️ 3 ➡️ 2. Tức là nó gán instance vào vùng nhớ trước khi kịp khởi tạo dữ liệu! Nếu ngay khoảnh khắc đó, một luồng khác chạy đến kiểm tra lần 1, nó thấy instance không còn null nữa nên hí hửng lấy ra dùng. Kết quả là chương trình báo lỗi văng tung tóe vì đang cố dùng một object rỗng tuếch. 💥

Để trị tận gốc "căn bệnh" CPU tự ý đảo lộn thứ tự lệnh này, trong các ngôn ngữ như Java hoặc C#, chúng ta phải gắn thêm một từ khóa đặc biệt vào trước vị trí khai báo biến private static ___ Database instance;.

Bạn có biết từ khóa đó là gì không? (Gợi ý: Nó bắt đầu bằng chữ "v" và đảm bảo biến luôn được đọc/ghi trực tiếp từ bộ nhớ chính).

Đó chính là từ khóa volatile 👏

Khi chúng ta khai báo private static volatile Database instance;, chúng ta đang ra lệnh cho trình biên dịch và CPU rằng: "Cấm tuyệt đối việc xáo trộn thứ tự lệnh liên quan đến biến này, và mọi thao tác đọc/ghi phải được làm việc trực tiếp với bộ nhớ RAM chính (Main Memory) chứ không phải bộ nhớ đệm (Cache) của từng lõi CPU".

Vậy là sau bao nhiêu lớp bảo vệ, chúng ta đã đúc kết được phiên bản Singleton "chuẩn không cần chỉnh" trong môi trường đa luồng:

public class Database {
    // 1. Dùng volatile để ngăn CPU đảo lệnh và đồng bộ bộ nhớ
    private static volatile Database instance;

    // 2. Hàm khởi tạo private để chặn từ khóa 'new' từ bên ngoài
    private Database() {
        // Khởi tạo kết nối...
    }

    // 3. Hàm lấy instance với kỹ thuật Double-Checked Locking
    public static Database getInstance() {
        if (instance == null) { // Kiểm tra lần 1
            synchronized (Database.class) { // Khóa luồng
                if (instance == null) { // Kiểm tra lần 2
                    instance = new Database(); // Khởi tạo an toàn
                }
            }
        }
        return instance;
    }
}

(Mẹo thực chiến: Tùy ngôn ngữ sẽ có cách viết ngắn gọn hơn. Ví dụ trong Java, người ta thường dùng Enum để tạo Singleton cực kỳ an toàn và ngắn gọn, hoặc trong C# có class Lazy<T>. Nhưng hiểu được cấu trúc cốt lõi ở trên mới giúp bạn thực sự làm chủ được nó).

⚠️ Cạm bẫy thực chiến: Tại sao Singleton bị ghét?

Dù rất phổ biến, Singleton ngày nay thường bị nhiều Senior Developer gọi là một Anti-pattern (mẫu thiết kế tồi nên tránh). Tại sao vậy?

  1. Nó là một biến toàn cục (Global Variable) trá hình: Bất kỳ class nào cũng có thể gọi Database.getInstance() và thay đổi trạng thái của nó. Khi có lỗi (bug) xảy ra, bạn sẽ rất khó truy vết xem class nào đã làm hỏng dữ liệu.

  2. Khó viết Unit Test: Khi bạn muốn test một class có dùng Database.getInstance(), nó bị dính chặt (tight coupling) vào database thật. Bạn rất khó để tạo ra một database "giả" (mock) để chạy test tự động.

2. Factory Pattern (Nhà Máy Lắp Ráp) 🏭

Chúng ta vừa giải quyết xong việc "chỉ tạo 1 object duy nhất". Giờ hãy đến với một bài toán khác của nhóm Khởi tạo: Factory.

Bài toán: Hãy tưởng tượng bạn đang viết một ứng dụng giao hàng. Ban đầu, công ty chỉ giao bằng đường bộ, nên trong code của bạn, ở đâu cần giao hàng bạn cũng gọi new Truck() (Xe tải). 🚚

Nửa năm sau, công ty làm ăn phát đạt và mở rộng giao hàng bằng đường thủy. Bạn phải thêm class Ship (Tàu thủy) 🚢. Lúc này, bạn phải đi tìm lại toàn bộ 100 chỗ trong code cũ đang gọi new Truck() để sửa lại thành các câu lệnh if... else... (nếu đường bộ thì new Truck, nếu đường thủy thì new Ship). Thật là một cơn ác mộng bảo trì!

Theo bạn, thay vì để cho cái class xử lý nghiệp vụ (class đi giao hàng) tự mình dùng từ khóa new để tạo ra Truck hay Ship, chúng ta nên đẩy trách nhiệm "tạo object" này cho ai/cái gì để code dễ mở rộng hơn sau này? 🤔

Để trả lời được câu hỏi này, tôi muốn các bạn hãy nghĩ đến thực tế nhé: Một công ty vận chuyển (Logistics) đâu có tự tay đi hàn khung, lắp ráp từng chiếc xe tải hay đóng từng chiếc tàu thủy đúng không? Họ giao việc tạo ra phương tiện đó cho ai? Chính là các Nhà máy (Factory).

Trong code, tư tưởng của Factory Pattern cũng y hệt như vậy! Thay vì để class giao hàng tự tay gọi new Truck() hay new Ship(), chúng ta sẽ ủy quyền (đẩy trách nhiệm) việc khởi tạo này cho một class hoặc một hàm chuyên biệt — gọi là Factory.

Nhiệm vụ duy nhất của Factory là: "Bạn đưa cho tôi yêu cầu (ví dụ: 'đường bộ' hay 'đường thủy'), tôi sẽ sản xuất và trả về cho bạn phương tiện tương ứng".

Tại sao cách này lại giải quyết được bài toán mở rộng?

  • Tách biệt mối quan tâm (Separation of Concerns): Class giao hàng giờ đây chỉ tập trung vào nghiệp vụ "đi giao hàng". Nó không cần biết làm thế nào để chế tạo ra một chiếc xe tải hay chiếc tàu nữa.

  • Dễ dàng mở rộng: Nửa năm sau, nếu công ty muốn giao hàng bằng máy bay (Airplane), bạn không cần đụng chạm hay sửa đổi hàng trăm dòng code cũ trong class giao hàng. Bạn chỉ cần vào class Factory, thêm một dòng điều kiện để "sản xuất" máy bay là xong! Code cũ hoàn toàn không bị ảnh hưởng. Điều này giúp hệ thống đạt được trạng thái Loose Coupling (Khớp nối lỏng lẻo).

Nhưng khoan đã, để hệ thống có thể hoạt động mượt mà, chúng ta cần giải quyết một vấn đề nhỏ về mặt kiểu dữ liệu (data type).

Nếu Factory lúc thì trả về object kiểu Truck, lúc thì trả về object kiểu Ship, thì class giao hàng phải dùng kiểu dữ liệu gì để hứng lấy cái kết quả trả về đó, sao cho nó có thể gọi được chung một hàm (ví dụ hàm deliver()) bất kể đó là xe hay tàu?

Trong Lập trình hướng đối tượng (OOP), chúng ta dùng khái niệm/cấu trúc gì để ép các class khác nhau (Truck, Ship) phải tuân thủ chung một bộ quy tắc hành vi? Bạn có nhớ không?

Nếu chưa nhớ ra thì cũng không sao cả, đây là một điểm nghẽn rất kinh điển khi mới học về Design Pattern!

Để giải quyết vấn đề này, chúng ta sẽ nhờ đến một "tuyệt chiêu" mang tính nền tảng của Lập trình hướng đối tượng (OOP): Interface (Giao diện) hoặc Abstract Class (Lớp trừu tượng).

Hãy tưởng tượng Interface giống như một Bản hợp đồng 📜.

  1. Tạo bản hợp đồng: Chúng ta tạo ra một Interface tên là Transport (Phương tiện). Trong hợp đồng này ghi rõ một điều khoản bắt buộc: Bất kỳ ai ký hợp đồng này đều phải biết làm một hành động là deliver() (giao hàng).

  2. Ký hợp đồng: Khi chúng ta tạo class TruckShip, chúng ta bắt chúng phải implements (thực thi/ký) cái hợp đồng Transport kia. Do đó, cả hai class này đều bắt buộc phải viết code cho hàm deliver().

Lúc này, phép màu của Tính đa hình (Polymorphism) sẽ xuất hiện trong class Factory của chúng ta. Thay vì Factory trả về cụ thể một cái Truck hay Ship, nó sẽ khai báo kiểu dữ liệu trả về là cái "Bản hợp đồng" Transport!

Cấu trúc code thực tế sẽ trông mượt mà như thế này:

// 1. Bản hợp đồng chung
public interface Transport {
    void deliver();
}

// 2. Các class cụ thể ký hợp đồng
public class Truck implements Transport {
    public void deliver() { 
        System.out.println("Giao hàng bằng xe tải trên đường bộ 🚛"); 
    }
}

public class Ship implements Transport {
    public void deliver() { 
        System.out.println("Giao hàng bằng tàu thủy trên biển 🚢"); 
    }
}

// 3. Nhà máy (Factory) chỉ trả về Bản hợp đồng
public class TransportFactory {
    // Lưu ý kiểu trả về ở đây là 'Transport'
    public Transport createTransport(String type) {
        if (type.equals("đường bộ")) {
            return new Truck();
        } else if (type.equals("đường thủy")) {
            return new Ship();
        }
        return null;
    }
}

Và ở class xử lý nghiệp vụ (class đi giao hàng), người lập trình viên không cần quan tâm hôm nay phương tiện là gì nữa. Họ chỉ cần viết một đoạn code duy nhất:

TransportFactory factory = new TransportFactory();
Transport myTransport = factory.createTransport("đường bộ"); // Chỉ cần truyền chữ

// Dù myTransport đang chứa xe hay tàu, hàm deliver() này luôn chạy đúng!
myTransport.deliver();

Tổng kết lại về Factory Pattern:

  • Sinh ra để: Tách biệt logic tạo object phức tạp ra khỏi logic sử dụng object. Giúp ẩn giấu đi các từ khóa new.

  • Khi nào nên dùng: Khi bạn có một nhóm các class có cùng chung một đặc điểm (cùng chung Interface) và bạn muốn tạo ra chúng dựa trên một điều kiện đầu vào nào đó ở thời gian chạy (runtime).

⚠️ Cạm bẫy thực chiến (Mặt trái của Factory)

Bây giờ, hãy nhìn lại class TransportFactory của chúng ta. Code hiện tại đang rất ổn, nhưng hãy tưởng tượng 5 năm sau, công ty của bạn trở thành tập đoàn toàn cầu. Bạn có tới 50 loại phương tiện khác nhau (máy bay, tàu hỏa, xe máy, xe đạp, drone, v.v.).

Lúc này, cái hàm createTransport() của chúng ta sẽ phình to ra thành một chuỗi if... else if... dài tới 50 dòng. Mỗi lần công ty mua thêm một loại xe mới, bạn lại phải mò vào đúng cái class Factory này, gõ thêm một dòng else if nữa.

Theo bạn, việc một hàm chứa một đống if... else... khổng lồ và liên tục bị lôi ra sửa đổi mỗi khi có tính năng mới thì có nguy cơ gây ra rủi ro gì cho hệ thống?

🎯 Đó chính là hiệu năng, việc máy tính phải kiểm tra tuần tự hàng chục lệnh if là một sự lãng phí.

Nhưng trong kỹ thuật phần mềm, có một "bóng ma" còn đáng sợ hơn cả sự chậm chạp: Sự mong manh của hệ thống khi bảo trì.

Hãy tưởng tượng file TransportFactory của bạn đang được 10 lập trình viên cùng làm việc. Mỗi khi có một loại xe mới, ai cũng phải nhảy vào mở đúng cái hàm createTransport() đó ra để sửa. Chỉ cần một người lỡ tay xóa nhầm một dấu } hoặc gõ sai tên biến, toàn bộ hệ thống tạo phương tiện của công ty sẽ sụp đổ.

Cấu trúc if-else khổng lồ này đã vi phạm một nguyên lý tối thượng trong thiết kế phần mềm: Nguyên lý Đóng/Mở (Open/Closed Principle - chữ O trong bộ nguyên lý SOLID) Nguyên lý này phát biểu rằng: "Một class nên được MỞ để mở rộng tính năng mới, nhưng ĐÓNG với việc sửa đổi code cũ".

Để chữa dứt điểm căn bệnh "viêm màng if-else" này, chúng ta sẽ nâng cấp từ Simple Factory (phiên bản ta vừa làm) lên Factory Method thực thụ.

Thay vì dồn mọi quyền lực vào một "Siêu nhà máy" khổng lồ chuyên sản xuất đủ loại phương tiện thập cẩm, chúng ta sẽ chia nhỏ nó ra thành các nhà máy chuyên biệt. Ví dụ: một TruckFactory chỉ chuyên tạo Xe tải, một ShipFactory chỉ chuyên tạo Tàu thủy.

Theo bạn, nếu chúng ta cấu trúc hệ thống theo hướng "mỗi loại phương tiện có một nhà máy riêng" như vậy, thì khi công ty muốn đưa Máy bay vào hoạt động, chúng ta sẽ giải quyết như thế nào để tuân thủ đúng Nguyên lý Đóng/Mở (tức là có thêm tính năng mới mà không phải sửa dù chỉ một dòng code của các nhà máy cũ)?

Chìa khóa ở đây là tiếp tục sử dụng "Bản hợp đồng" (Interface), nhưng lần này không chỉ dành cho phương tiện, mà là hợp đồng dành cho chính các Nhà máy! 🏭

Thay vì có một class TransportFactory khổng lồ chứa hàng tá lệnh if-else, chúng ta sẽ tạo ra một Interface tên là Factory với một điều khoản duy nhất: ai ký hợp đồng này đều phải có khả năng createTransport()

Sau đó, chúng ta xây dựng các nhà máy chuyên biệt:

  • TruckFactory (chỉ chứa logic để tạo ra Truck).
  • ShipFactory (chỉ chứa logic để tạo ra Ship).

Bây giờ, giải quyết bài toán thêm Máy bay (Airplane) ✈️. Chúng ta chỉ cần làm 2 việc rất đơn giản:

  • Tạo class Airplane.
  • Tạo class AirplaneFactory chuyên sản xuất máy bay.

Bạn thấy điểm kỳ diệu ở đây chứ? Chúng ta chỉ tạo thêm file code mới (Mở để mở rộng tính năng - Open) mà không hề đụng chạm hay sửa đổi bất kỳ một dòng code nào trong các nhà máy cũ (Đóng với việc sửa đổi - Closed). Hệ thống lúc này cực kỳ an toàn, không có chuyện sửa lỗi mới mà làm hỏng tính năng cũ.

Đó chính là hình thái hoàn chỉnh của Factory Pattern.

Dưới đây là cách chúng ta thiết kế hệ thống sao cho tuân thủ tuyệt đối Nguyên lý Đóng/Mở (Open/Closed Principle):

  1. Các Bản hợp đồng (Interface): Chúng ta cần 2 bản hợp đồng, một cho Sản phẩm (Phương tiện) và một cho Nhà máy.

    // Hợp đồng cho Phương tiện
    public interface Transport {
        void deliver();
    }
    
    // Hợp đồng cho Nhà máy
    public interface TransportFactory {
        Transport createTransport(); // Chỉ trả về 'Transport' chung chung
    }
    
  2. Triển khai các class Cũ (Đã có từ đầu):

    // --- Nhóm Đường bộ ---
    public class Truck implements Transport {
        public void deliver() { System.out.println("Giao bằng Xe tải 🚛"); }
    }
    
    public class TruckFactory implements TransportFactory {
        public Transport createTransport() { return new Truck(); }
    }
    
    // --- Nhóm Đường thủy ---
    public class Ship implements Transport {
        public void deliver() { System.out.println("Giao bằng Tàu thủy 🚢"); }
    }
    
    public class ShipFactory implements TransportFactory {
        public Transport createTransport() { return new Ship(); }
    }
    
  3. Sự kỳ diệu khi Mở rộng (Thêm Máy bay):

    // --- Nhóm Đường hàng không (MỚI) ---
    public class Airplane implements Transport {
        public void deliver() { System.out.println("Giao bằng Máy bay ✈️"); }
    }
    
    public class AirplaneFactory implements TransportFactory {
        public Transport createTransport() { return new Airplane(); } // Trả về Máy bay
    }
    
  4. Cách sử dụng ở hàm Main (Client Code):

    public class LogisticsApp {
        public static void main(String[] args) {
            // Giả sử logic nghiệp vụ quyết định dùng đường hàng không
            TransportFactory factory = new AirplaneFactory(); 
    
            // Từ đây về sau, code nghiệp vụ không cần biết phương tiện là gì
            Transport myTransport = factory.createTransport();
            myTransport.deliver(); // Sẽ in ra: Giao bằng Máy bay ✈️
        }
    }
    

Bạn thấy đấy, không còn một bóng dáng của câu lệnh if... else... nào! Tính năng mới được lắp ghép vào hệ thống cũ một cách mượt mà như những khối Lego.

3. Builder (Người Thợ Xây) 🏗️

Pattern này thuộc nhóm Khởi tạo (Creational), cùng họ hàng với Singleton và Factory.

Hãy tưởng tượng bạn đang lắp ráp một dàn máy tính bàn (PC) 💻. Một chiếc máy tính có rất nhiều linh kiện: CPU, RAM, Ổ cứng, Card màn hình (VGA), Tản nhiệt, Nguồn, Vỏ case...

Nếu chúng ta tạo đối tượng theo cách thông thường bằng hàm khởi tạo (Constructor), code sẽ trông như thế này:

// Khởi tạo một PC siêu khủng để chơi game
Computer pcGamer = new Computer("Core i9", "32GB", "1TB SSD", "RTX 4090", 
"Tản nước", "1000W", "Vỏ RGB");

Nhưng nếu khách hàng chỉ muốn mua một chiếc PC văn phòng cơ bản, không cần Card màn hình rời và Tản nhiệt nước thì sao? Bạn sẽ phải truyền giá trị null vào những chỗ không cần thiết:

Computer pcVanPhong = new Computer("Core i3", "8GB", "256GB SSD", null, 
null, "500W", "Vỏ đen");

Việc nhồi nhét quá nhiều tham số (đặc biệt là các tham số null hoặc cùng kiểu String) vào chung một hàm khởi tạo khiến code cực kỳ rối mắt và dễ nhầm lẫn. Rất dễ xảy ra tình trạng truyền nhầm "8GB" vào vị trí của Ổ cứng thay vì RAM. Trong lập trình, người ta gọi đây là "căn bệnh" Telescoping Constructor.

Để giải quyết, Builder Pattern chia quá trình tạo một đối tượng phức tạp thành các bước nhỏ độc lập. Nó giống như việc bạn ra lệnh cho một người thợ lắp ráp: "Lắp cho tôi CPU này, sau đó lắp cho tôi RAM này, mấy cái khác bỏ qua, cuối cùng hãy chốt lại thành máy hoàn chỉnh cho tôi".

Dựa vào ý tưởng "lắp ráp từng bước" đó, thay vì ép người dùng gọi hàm new Computer(...) với một hàng dài tham số cùng lúc, theo bạn chúng ta nên cung cấp những hàm (methods) dạng như thế nào để người dùng có thể tự do chọn thêm từng linh kiện mà họ muốn?

Để trả lời câu hỏi này, tôi với bạn sẽ cùng đi vào phân tích sâu hơn nhé, có thể thấy thiết kế của Builder ban đầu nghe có vẻ hơi ngược đời, nhưng khi "mổ xẻ" ra bạn sẽ thấy nó cực kỳ thông minh.

Để giải quyết bài toán nhồi nhét quá nhiều tham số, Builder Pattern sử dụng một kỹ thuật gọi là Chuỗi phương thức (Method Chaining).

Thay vì bắt bạn đưa hết vật liệu vào cùng một lúc, nó tách ra thành các hàm rời rạc cho từng linh kiện. Điểm "ăn tiền" nhất của cấu trúc này là: Sau khi thực hiện xong một hàm (ví dụ: lắp CPU), nó sẽ trả về chính bản thân người thợ xây đó (return this) để bạn có thể ra lệnh tiếp.

Dưới đây là cấu trúc chi tiết khi mổ xẻ class ComputerBuilder:

public class ComputerBuilder {
    // 1. Lưu trữ tạm thời các linh kiện
    private String cpu;
    private String ram;
    private String vga; // Có thể có hoặc không

    // 2. Các hàm lắp ráp từng món (Lưu ý: kiểu trả về là ComputerBuilder)
    public ComputerBuilder lapCPU(String cpu) {
        this.cpu = cpu;
        return this; // Bí quyết là ở đây! Trả về chính người thợ xây.
    }

    public ComputerBuilder lapRAM(String ram) {
        this.ram = ram;
        return this;
    }

    public ComputerBuilder lapVGA(String vga) {
        this.vga = vga;
        return this;
    }

    // 3. Hàm CHỐT ĐƠN: Lấy các vật liệu tạm thời để lắp thành Máy tính thật
    public Computer build() {
        return new Computer(cpu, ram, vga); 
    }
}

Nhờ thiết kế return this này, người lập trình có thể nối các câu lệnh lại với nhau một cách cực kỳ thanh lịch và rõ ràng. Giả sử một khách hàng muốn mua PC Văn phòng (chỉ cần CPU và RAM, không cần Card màn hình VGA):

Computer pcVanPhong = new ComputerBuilder()
                        .lapCPU("Core i3")
                        .lapRAM("8GB")
                        .build(); // Gọi hàm cuối cùng để chốt đơn

Nhìn vào đoạn code lắp ráp pcVanPhong ở trên, bạn có thấy chúng ta đã giải quyết được "căn bệnh" phải truyền một đống tham số null (cho VGA, Tản nước...) như cách dùng hàm khởi tạo (Constructor) ban đầu không? Theo bạn, đặc điểm nào của class Builder đã giúp chúng ta loại bỏ được việc phải viết những chữ null đó? 🤔

Điểm mấu chốt giúp chúng ta không phải viết bất kỳ chữ null nào chính là sự lựa chọn chủ động (Optionality) kết hợp với các hàm riêng lẻ.

Khi dùng hàm khởi tạo thông thường new Computer("Core i3", "8GB", null, null, ...), bạn bắt buộc phải điền đủ số lượng tham số theo đúng thứ tự, dù bạn không cần đến chúng.

Nhưng với Builder, bạn giống như đang đi ăn buffet vậy:

  1. Bạn cầm một cái đĩa trống (new ComputerBuilder()).
  2. Bạn thích ăn gì thì gắp nấy (chỉ gọi hàm lapCPU và lapRAM).
  3. Những món bạn không gắp (như lapVGA), hệ thống sẽ tự động bỏ qua (ẩn giá trị mặc định ở bên trong). Người lập trình (chính là bạn) không cần phải tự tay điền chữ null vào code nữa.

Nhờ vậy, code vừa ngắn gọn, vừa đọc dễ hiểu như ngôn ngữ tự nhiên!

Tuy nhiên, kiểm soát việc khởi tạo đối tượng mới chỉ là một nửa chặng đường. Trong thực tế, các hệ thống hiếm khi được xây mới hoàn toàn từ đầu. Thách thức thực sự ập đến khi bạn phải bắt các đoạn code cũ, thư viện của bên thứ ba và các module hoàn toàn khác biệt 'nói chuyện' được với nhau. Hãy cùng bước sang trụ cột thứ hai: Nhóm Cấu trúc (Structural Patterns) 🧱 – nơi cung cấp cho bạn những thủ thuật lắp ghép để giải quyết triệt để sự lệch pha này.

Trụ cột 2 - Nhóm Cấu trúc (Structural Patterns)

4. Adapter (Cục Chuyển Đổi) 🔌

Hãy tưởng tượng bạn mang laptop từ Việt Nam sang Châu Âu du lịch. Phích cắm laptop của bạn là loại 2 chấu dẹt, nhưng ổ điện trên tường ở khách sạn lại là loại 3 lỗ tròn. Bạn không thể cắm trực tiếp được, và bạn cũng không thể đập tường ra xây lại ổ điện, hay cắt đứt phích cắm của laptop để nối dây trần.

Giải pháp duy nhất là ra siêu thị mua một Cục chuyển đổi (Adapter). Cục này một đầu sẽ cắm vào tường (3 lỗ tròn), đầu kia sẽ cung cấp lỗ 2 chấu dẹt cho laptop của bạn cắm vào.

Trong lập trình y hệt như vậy! Khi bạn có 2 class không tương thích với nhau (do dùng 2 thư viện khác nhau, hoặc code cũ và code mới), bạn sẽ tạo ra một class Adapter đứng ở giữa để làm "phiên dịch viên".

Bài toán:

Hệ thống cũ của bạn quy định mọi cổng thanh toán đều phải ký một bản hợp đồng:

public interface CongThanhToanCu {
    void thanhToanTrucTuyen(int soTien);
}

Bây giờ, công ty mua một thư viện mới cực xịn là StripeAPI của nước ngoài. Khổ nỗi, class này người ta viết sẵn hàm tên là processPayment(int amount), không hề khớp với hệ thống của bạn. Bạn không thể sửa code của StripeAPI vì nó là thư viện tải về.

Theo bạn, để tạo ra một cục chuyển đổi tên là StripeAdapter, thì cái Adapter này sẽ phải ký hợp đồng (implements) với hệ thống nào (để hệ thống cũ nhận diện được nó), và bên trong ruột của nó sẽ phải chứa/gọi đến ai (để thực hiện việc thanh toán thực sự)?

Nếu bạn chưa có câu trả lời, thì cũng hoàn toàn là điều bình thường thôi, phần này hơi lắt léo một chút! Hãy quay lại hình ảnh cục chuyển đổi phích cắm để mổ xẻ nhé. 🔌

Để cục chuyển đổi (Adapter) hoạt động, nó phải làm tròn hai vai trò:

  1. Với cái ổ điện trên tường (Hệ thống cũ): Nó phải có hình dáng của chấu 3 lỗ tròn để cắm vừa vào tường. Suy ra trong code, StripeAdapter phải ký hợp đồng (implements) CongThanhToanCu. Nhờ vậy, hệ thống cũ nhìn vào nó và bảo: "À, anh bạn này đúng chuẩn hệ thống của mình rồi, cho qua!".

  2. Với cái laptop (Thư viện xịn): Nó phải nối điện vào chấu 2 dẹt của laptop. Suy ra bên trong ruột của StripeAdapter, nó phải chứa một đối tượng StripeAPI và gọi hàm processPayment() của thư viện này.

Hãy xem những dòng code thực tế dưới đây, bạn sẽ thấy nó đóng vai "phiên dịch viên" mượt mà thế nào:

// 1. Hệ thống cũ (Ổ điện trên tường)
public interface CongThanhToanCu {
    void thanhToanTrucTuyen(int soTien);
}

// 2. Thư viện mới tải về (Cái laptop chấu 2 dẹt - KHÔNG THỂ SỬA CODE NÀY)
public class StripeAPI {
    public void processPayment(int amount) {
        System.out.println("Thanh toán " + amount + " USD qua Stripe cực xịn!");
    }
}

// 3. CỤC CHUYỂN ĐỔI (Adapter) 🔌
public class StripeAdapter implements CongThanhToanCu {
    // Ruột của nó chứa thư viện mới
    private StripeAPI stripe;

    public StripeAdapter(StripeAPI stripe) {
        this.stripe = stripe;
    }

    // Nó tuân thủ hợp đồng cũ, nên hệ thống cũ gọi hàm này
    @Override
    public void thanhToanTrucTuyen(int soTien) {
        // Đứng ở giữa làm phiên dịch: Biến tiền tệ, đổi tham số...
        System.out.println("Adapter đang phiên dịch dữ liệu...");
        
        // Gọi hàm của thư viện mới để làm việc thật sự!
        stripe.processPayment(soTien); 
    }
}

Và đây là cách hệ thống cũ của bạn sử dụng nó mà không hề biết mình đang dùng công nghệ mới:

public class HeThongBanHang {
    public static void main(String[] args) {
        // Có một đối tượng Stripe xịn
        StripeAPI stripeXin = new StripeAPI();
        
        // Bọc nó vào cục chuyển đổi
        CongThanhToanCu thanhToan = new StripeAdapter(stripeXin);
        
        // Hệ thống cũ vẫn gọi hàm cũ, nhưng tiền được xử lý bằng Stripe!
        thanhToan.thanhToanTrucTuyen(100); 
    }
}

💡 Cốt lõi của Adapter: Nó cho phép các class có Interface (Giao diện) không tương thích có thể làm việc cùng nhau. Cực kỳ hữu dụng khi bạn làm việc với mã nguồn cũ (Legacy Code) hoặc tích hợp các thư viện bên thứ ba (Third-party libraries) mà bạn không có quyền sửa code của họ.

5. Decorator Pattern (Trang trí / Đắp lớp) ☕

Cùng chung nhóm Cấu trúc với Adapter, nhưng Decorator lại giải quyết một bài toán hoàn toàn khác: Đắp thêm tính năng mới cho một object đang chạy mà không cần sửa class gốc của nó.

Hãy tưởng tượng bạn đang viết phần mềm tính tiền cho một quán cà phê. Bạn có class gốc là CaPheDen (giá 10k).

Khách hàng đến và yêu cầu:

  1. Thêm Sữa (+5k)
  2. Thêm Đường (+2k)
  3. Thêm Trân châu (+10k)

Nếu dùng tư duy Kế thừa (Inheritance) thông thường, bạn sẽ phải tạo ra các class như: CaPheDenThemSua, CaPheDenThemDuong, CaPheDenThemSuaVaDuong, v.v. Điều này lại dẫn đến lỗi "Bùng nổ Class" (Class Explosion) hàng ngàn class y như ví dụ về vũ khí lúc trước!

hay vì tạo ra hàng tá class mới, Decorator có tư duy giống như Trò chơi búp bê Nga (Matryoshka) 🪆 - con búp bê nhỏ nằm gọn trong con búp bê to hơn.

Bạn lấy ly CaPheDen cốt lõi, "bọc" nó vào một lớp ThemSua, rồi lại lấy tất cả "bọc" tiếp vào một lớp ThemDuong. Lớp bọc bên ngoài sẽ tính tiền của nó, cộng dồn với số tiền của cái lõi bên trong.

Theo bạn, để cái vỏ bọc bên ngoài (ThemSua) có thể bọc được cái lõi bên trong (CaPheDen), và để hệ thống tính tiền vẫn coi cái cục to đùng đó là một "Ly cà phê", thì cả Vỏ bọc và Cái lõi đều phải cùng ký chung một thứ gì?

Chúng ta cùng mổ xẻ cơ chế bọc lớp (wrapping) của Decorator nhé! 🪆

Để hệ thống tính tiền không cần quan tâm nó đang tính tiền một ly cà phê nguyên chất hay một ly đã thêm đủ thứ topping, thì cả cái Lõi (CaPheDen) và Vỏ bọc (ThemSua, ThemDuong) đều phải ký chung một Bản hợp đồng (Interface), ví dụ gọi là DoUong (Đồ uống).

Đây là điểm mấu chốt của Decorator: Vỏ bọc vừa là một DoUong (để hệ thống chấp nhận), lại vừa chứa một DoUong khác ở bên trong ruột của nó (để bọc lại).

Hãy xem cấu trúc code để thấy rõ điều này:

// 1. Bản hợp đồng chung
public interface DoUong {
    int tinhTien();
}

// 2. Cái lõi cơ bản (Cà phê đen)
public class CaPheDen implements DoUong {
    public int tinhTien() {
        return 10000; // Giá gốc 10k
    }
}

// 3. Vỏ bọc chung (Decorator)
public abstract class ToppingDecorator implements DoUong {
    protected DoUong monChinh; // Chứa một đồ uống bên trong để bọc lại

    public ToppingDecorator(DoUong monChinh) {
        this.monChinh = monChinh;
    }
}

// 4. Vỏ bọc cụ thể (Thêm Sữa)
public class ThemSua extends ToppingDecorator {
    public ThemSua(DoUong monChinh) {
        super(monChinh);
    }

    public int tinhTien() {
        // Tiền của Sữa (5k) + Tiền của món bên trong
        return 5000 + monChinh.tinhTien(); 
    }
}

Bây giờ, hãy tưởng tượng chúng ta khởi tạo một ly cà phê có cả sữa và đường bằng cách bọc chúng lại với nhau như sau:

DoUong lyCuaToi = new ThemDuong(new ThemSua(new CaPheDen()));

Theo bạn, khi hệ thống gọi hàm lyCuaToi.tinhTien(), quá trình tính toán sẽ chạy theo thứ tự như thế nào từ lớp ngoài cùng (ThemDuong) vào đến lớp trong cùng (CaPheDen) để ra được tổng số tiền?

Chúng ta cùng mổ xẻ tiếp "con búp bê Nga" này nhé! Cơ chế hoạt động của Decorator dựa trên một chuỗi phản ứng dây chuyền cực kỳ logic.

Khi bạn gọi lyCuaToi.tinhTien(), dòng chảy tính toán sẽ đi từ ngoài vào sâu bên trong, sau đó cuộn ngược từ trong ra ngoài để lấy kết quả.

Quá trình đó diễn ra theo 4 bước như sau:

  1. 🍬 Lớp vỏ ngoài cùng (ThemDuong): Hệ thống hỏi lớp ngoài cùng: "Ly này tổng cộng bao nhiêu tiền?". Lớp ThemDuong nhận lệnh và bảo: "Đường của tôi giá 2k, nhưng để tôi cộng với giá của phần bên trong tôi đã". Lập tức, nó gọi hàm tinhTien() của cái ruột bên trong nó.
  2. 🥛 Lớp giữa (ThemSua): Nhận được yêu cầu từ lớp bên ngoài, ThemSua bảo: "Sữa của tôi giá 5k, đợi xíu để tôi hỏi cái cốt lõi bên trong cùng đã". Nó lại tiếp tục gọi hàm tinhTien() của lớp trong cùng.
  3. ☕ Lõi trong cùng (CaPheDen): CaPheDen là nền tảng, nó không bọc ai cả. Nó dõng dạc trả lời: "Cà phê đen nguyên chất giá 10k!"
  4. 🔄 Cuộn ngược lại để tính tổng:
    • CaPheDen ném 10k lên cho lớp ThemSua.
    • ThemSua lấy 10k + 5k (sữa của nó) = 15k, rồi ném 15k đó lên cho lớp ThemDuong.
    • ThemDuong lấy 15k + 2k (đường của nó) = 17k. Đây chính là kết quả cuối cùng hệ thống nhận được!

Sức mạnh của thiết kế này là bạn có thể bọc bao nhiêu lớp tùy thích ngay trong lúc phần mềm đang chạy (Ví dụ: ThêmĐường(ThêmĐường(ThêmSữa(CàPhê)))), mà số lượng class trong mã nguồn của bạn vẫn chỉ đếm trên đầu ngón tay.

Nếu bạn để ý, cả Adapter (ở trạm trước) và Decorator đều có một điểm chung về mặt cấu trúc: Cả hai đều "bọc" (wrap) một object khác ở bên trong ruột của nó.

Dựa vào những gì chúng ta đã đi qua, theo bạn, sự khác biệt lớn nhất về mục đích giữa việc bọc của Adapter 🔌 và việc bọc của Decorator 🪆 là gì? (Gợi ý cho bạn: Hãy nghĩ xem cái nào dùng để kết nối "phích cắm và ổ điện", còn cái nào dùng để "nâng cấp" một món đồ đã khớp sẵn).

Gốc rễ của sự khác biệt này chính là Dù cả hai Pattern này đều dùng kỹ thuật "bọc" (wrapping - nhét một object vào trong ruột một object khác), nhưng sứ mệnh sinh ra của chúng lại hoàn toàn khác biệt:

🔌 1. Adapter Pattern (Người hòa giải / Chữa cháy)

  • Mục đích: Giải quyết vấn đề Tương thích (Compatibility).

  • Bản chất: Nó thay đổi "giao diện" (vỏ ngoài) của object bên trong để vừa khớp với một hệ thống khác.

  • Đặc điểm: Object bên trong vốn dĩ không thể tự hoạt động trong hệ thống hiện tại. Giống như phích cắm 2 chấu không thể cắm vào ổ 3 lỗ. Adapter không làm cho cái laptop chạy nhanh hơn hay có thêm tính năng mới, nó chỉ đơn thuần là giúp laptop nhận được điện.

🪆 2. Decorator Pattern (Người nâng cấp / Trang trí)

  • Mục đích: Giải quyết vấn đề Mở rộng tính năng (Enhancement) một cách linh hoạt.

  • Bản chất: Nó giữ nguyên "giao diện" (vẫn là một đồ uống), nhưng bồi đắp thêm tính năng hoặc dữ liệu mới vào trước/sau khi gọi object bên trong.

  • Đặc điểm: Object bên trong (ly Cà phê đen) vốn dĩ đã hoạt động hoàn hảo và độc lập rồi. Bạn dùng Decorator để khoác thêm "áo mới" (thêm sữa, thêm đường) cho nó ngay trong lúc chương trình đang chạy mà không cần đập class cũ đi xây lại.

6. Facade Pattern 🏢 (Mặt Tiền Lễ Tân)

Trạm dừng chân tiếp theo là một pattern cực kỳ phổ biến giúp che giấu đi sự phức tạp: Facade Pattern 🏢 (Mặt tiền)

"Facade" mang ý nghĩa là mặt tiền của một tòa nhà. Khi bạn nhìn vào một khách sạn 5 sao, bạn chỉ thấy sảnh lễ tân lộng lẫy và dễ tiếp cận (Facade), chứ không hề thấy hệ thống đường ống nước chằng chịt, mạng lưới điện hay hệ thống làm mát phức tạp ẩn sâu bên trong.

Hãy tưởng tượng bạn vào nhà hàng ăn tối. Bạn chỉ cần nói với người phục vụ: "Cho tôi một phần bít tết!". Người phục vụ ở đây chính là một Facade. Bạn không cần phải tự mình chạy vào bếp, ra lệnh cho đầu bếp ướp thịt, bảo phụ bếp bật lò nướng, và nhắc nhân viên rót rượu.

Nó sinh ra để giải quyết điều gì?

Trong lập trình, khi bạn có một hệ thống (hoặc một thư viện) bao gồm hàng chục class đan chéo và phụ thuộc lẫn nhau. Thay vì bắt các lập trình viên khác phải đọc tài liệu, hiểu cách hệ thống hoạt động và tự gọi từng class một để hoàn thành một công việc, bạn tạo ra một class duy nhất (Facade) chứa các hàm cực kỳ đơn giản để họ dùng.

Hãy cùng áp dụng thử nhé. Giả sử bạn đang viết code mô phỏng hệ thống Khởi động Máy tính. Để máy lên hình, hệ thống phải chạy qua 3 class linh kiện phức tạp theo đúng thứ tự:

  1. CPU.khoiDong()

  2. RAM.taiDuLieu()

  3. OCung.docHeDieuHanh()

Nếu áp dụng Facade để người dùng cuối không phải bận tâm về những linh kiện này, theo bạn class Facade của chúng ta nên cung cấp một hàm (method) duy nhất có tên là gì, và bên trong ruột của hàm đó sẽ chứa những lệnh nào?

Để che giấu đi sự phức tạp của CPU, RAM và Ổ cứng, class Facade của chúng ta (có thể đặt tên là ComputerFacade) sẽ đóng vai trò như cái nút nguồn trên thùng máy tính của bạn.

1. Hàm duy nhất (The Interface) Vì Facade sinh ra để làm mọi thứ cực kỳ dễ dàng cho người dùng, hàm này nên mang một cái tên phản ánh đúng hành động đơn giản nhất: batMay() hoặc start().

2. Bên trong ruột (The Logic)

Bên trong hàm batMay() đó, chúng ta sẽ giấu toàn bộ thứ tự gọi lệnh phức tạp của hệ thống. Code sẽ trông như thế này:

// Khối hệ thống phức tạp (Subsystem)
class CPU { public void khoiDong() { System.out.println("CPU chạy..."); } }
class RAM { public void taiDuLieu() { System.out.println("RAM tải dữ liệu..."); } }
class OCung { public void docHeDieuHanh() { System.out.println("Đọc Window từ Ổ cứng..."); } }

// CÁI NÚT NGUỒN (Facade)
public class ComputerFacade {
    private CPU cpu = new CPU();
    private RAM ram = new RAM();
    private OCung oCung = new OCung();

    // Hàm duy nhất người dùng cần gọi
    public void batMay() {
        System.out.println("Bắt đầu khởi động máy tính...");
        cpu.khoiDong();
        ram.taiDuLieu();
        oCung.docHeDieuHanh();
        System.out.println("Máy tính đã sẵn sàng!");
    }
}

Tại sao lại cần nó?

  • Góc độ người dùng (Client): Lập trình viên sử dụng hệ thống máy tính này không cần phải nhớ xem CPU chạy trước hay RAM chạy trước. Họ chỉ cần viết đúng 2 dòng lệnh:

    ComputerFacade mayTinh = new ComputerFacade();
    mayTinh.batMay();
    
  • Góc độ bảo trì (Loose Coupling): Giả sử ngày mai bạn lắp thêm một cái Card màn hình (VGA) và Cạt WiFi. Nếu không có Facade, bạn phải đi tìm tất cả những chỗ trong code đang gọi khởi động máy tính để chèn thêm lệnh bật VGA và WiFi. Nhưng nhờ có Facade, bạn chỉ cần sửa duy nhất bên trong hàm batMay() là xong!

💡 Điểm cốt lõi: Nếu Adapter 🔌 dùng để chữa cháy cho hai thứ không tương thích cắm được vào nhau, thì Facade 🏢 dùng để gom nhóm và đơn giản hóa một mớ bòng bong thành một nút bấm duy nhất.

Vậy là, chúng ta đã xây xong móng (Khởi tạo) và dựng xong khung nhà, chắp vá hoàn hảo các đường ống lệch pha (Cấu trúc). Nhưng một ngôi nhà mà công tắc không biết gửi lệnh cho bóng đèn, hay cảm biến cháy không biết gọi vòi phun nước... thì chỉ là một khối bê tông vô hồn. Làm sao để các bộ phận phối hợp nhịp nhàng, giao tiếp thông minh mà không bị phụ thuộc cứng nhắc vào nhau? Đó là lúc chúng ta thổi bùng sự sống cho hệ thống bằng trụ cột cuối cùng: Nhóm Hành vi (Behavioral Patterns) 🔄

Trụ cột 3 - Nhóm Hành Vi (Behavioral Patterns)

1. Observer (Kẻ Quan Sát) 👀

Chúng ta đã giải quyết xong nhóm Cấu trúc. Bây giờ hãy xem cách các object giao tiếp và ứng xử với nhau khi hệ thống đang chạy. Pattern kinh điển và được dùng nhiều nhất ở đây là Observer (Người quan sát/Theo dõi).

Hãy tưởng tượng bạn cực kỳ thích một kênh YouTube 📺 và muốn biết ngay lập tức khi họ ra video mới.

Sẽ thật tốn thời gian (và tốn tài nguyên hệ thống) nếu cứ 5 phút bạn lại tải lại trang kênh của họ một lần để tự hỏi: "Có video mới chưa?". Trong lập trình, việc một object liên tục đi kiểm tra trạng thái của một object khác gọi là Polling. Nó giống như việc bạn gọi điện cho shipper mỗi 5 phút để hỏi xem hàng đến chưa, điều này sẽ vắt kiệt sức lực của cả hai bên.

Thay vì để người xem phải liên tục đi kiểm tra, YouTube (và mọi nền tảng khác) đã tạo ra một giải pháp thanh lịch hơn: Nút Subscribe (Đăng ký) 🔔.

Theo bạn, khi áp dụng cơ chế Subscribe này vào lập trình, thay vì người xem chủ động đi "hỏi", thì luồng thông tin (thông báo video mới) sẽ di chuyển theo hướng nào, và object nào sẽ nắm giữ trách nhiệm chủ động đẩy thông tin đó đi?

Câu trả lời chính xác cho câu hỏi vừa rồi là: Luồng thông tin sẽ đảo ngược lại. Thay vì Người xem đi hỏi, Kênh YouTube sẽ là người chủ động đẩy thông báo đi. Thông tin sẽ chảy từ Kênh YouTube ➡️ Người xem.

Trong lập trình, đây chính là linh hồn của Observer Pattern. Nó biến một hệ thống từ trạng thái "Kéo" (Pull - người xem tự kéo dữ liệu về) sang trạng thái "Đẩy" (Push - kênh chủ động đẩy dữ liệu đi).

Để làm được điều này, chúng ta có 2 thành phần chính:

  1. Subject (Chủ thể - Kênh YouTube): Người nắm giữ thông tin quan trọng. Nhiệm vụ của nó là duy trì một danh sách (List/Array) những ai đã đăng ký theo dõi mình.

  2. Observer (Người quan sát - Người xem): Những người muốn nhận thông tin. Họ phải cung cấp cho Subject một "địa chỉ liên lạc" (thường là một hàm, ví dụ hàm update()).

⚙️ Cách hệ thống vận hành:

  1. Đăng ký (Subscribe): Bạn bấm nút Subscribe. Kênh YouTube ngay lập tức thêm bạn vào danh sách những người đang theo dõi.

  2. Chờ đợi: Bạn đóng máy tính đi chơi. Bạn không cần làm gì cả.

  3. Thông báo (Notify): Bất ngờ kênh YouTube ra video mới. Kênh này sẽ tự động chạy một vòng lặp (for loop) qua toàn bộ danh sách người theo dõi. Với mỗi người, nó sẽ gọi hàm update() để báo tin: "Ê, có video mới này!".

Đây là một đoạn code mô phỏng cực kỳ gọn gàng để bạn dễ hình dung:

// 1. Giao diện cho Người xem (Ai muốn nhận thông báo đều phải ký hợp đồng này)
public interface Observer {
    void update(String videoTitle);
}

// Người xem cụ thể
public class Subscriber implements Observer {
    private String name;
    public Subscriber(String name) { this.name = name; }
    
    // Đây là "địa chỉ liên lạc" để Kênh YouTube gọi vào
    public void update(String videoTitle) {
        System.out.println(name + " ơi, có video mới: " + videoTitle);
    }
}

// 2. Kênh YouTube (Subject)
public class YouTubeChannel {
    // Cuốn sổ ghi chép danh sách người theo dõi
    private List<Observer> subscribers = new ArrayList<>();

    // Hàm để người xem bấm Đăng ký
    public void subscribe(Observer o) {
        subscribers.add(o);
    }

    // Hàm chủ động đẩy thông báo
    public void uploadVideo(String title) {
        System.out.println("Đăng video: " + title);
        // Chạy vòng lặp, gọi tất cả những người trong sổ
        for (Observer sub : subscribers) {
            sub.update(title);
        }
    }
}

Nhờ cách thiết kế này, Kênh YouTube không cần biết chính xác bạn là ai hay bạn dùng thiết bị gì để xem (điện thoại, TV, laptop). Nó chỉ cần biết bạn có hàm update() là đủ. Hai bên hoạt động rất độc lập với nhau (Loose Coupling).

Dựa vào cách hoạt động của cuốn sổ danh sách subscribers ở trên, mình có một câu hỏi nhỏ cho bạn:

Nếu một ngày đẹp trời, bạn chán kênh này và quyết định bấm nút Unsubscribe (Hủy đăng ký), thì Kênh YouTube sẽ phải thực hiện hành động gì ở bên trong code để bạn không còn bị làm phiền bởi các thông báo nữa?

Hành động đó thực ra cực kỳ đơn giản. Giống như lúc đăng ký, Kênh YouTube ghi tên bạn vào "cuốn sổ" danh sách, thì khi bạn Hủy đăng ký (Unsubscribe), Kênh YouTube chỉ việc gạch tên bạn ra khỏi cuốn sổ đó.

Trong code, nó tương đương với việc xóa object Người xem (Observer) ra khỏi danh sách (List). Chúng ta chỉ cần thêm một hàm unsubscribe vào class YouTubeChannel:

public class YouTubeChannel {
    private List<Observer> subscribers = new ArrayList<>();

    // ... (hàm subscribe và uploadVideo như cũ)

    // Hàm Hủy đăng ký
    public void unsubscribe(Observer o) {
        subscribers.remove(o); // Gạch tên khỏi danh sách!
    }
}

Nhờ dòng lệnh subscribers.remove(o), ở vòng lặp for tiếp theo khi có video mới, kênh YouTube sẽ không thấy tên bạn trong danh sách nữa và bỏ qua bạn. Bạn sẽ không nhận được thông báo, hệ thống hoạt động chính xác như những gì chúng ta mong muốn.

Tổng kết về Observer Pattern: Nó sinh ra để giải quyết bài toán: Một đối tượng thay đổi trạng thái, và nhiều đối tượng khác cần được thông báo tự động. Mấu chốt là đối tượng trung tâm (Subject) không cần biết chi tiết về những kẻ đang theo dõi mình (Observers), nó chỉ cần biết tất cả đều tuân thủ một "Bản hợp đồng" (Interface) có chứa hàm nhận thông báo.

2. Strategy Pattern (Chiến lược) ⚔️

Vẫn nằm trong nhóm Hành vi (Behavioral), Strategy là một pattern cực kỳ mạnh mẽ giúp bạn thay đổi "thuật toán" hoặc "hành vi" của một đối tượng ngay trong lúc chương trình đang chạy (runtime).

Hãy tưởng tượng bạn đang lập trình một trò chơi nhập vai. Nhân vật của bạn (Class Hero) có một hàm là tấn_công(). Ban đầu, nhân vật chỉ biết dùng Kiếm. Sau đó, bạn muốn nhân vật có thể nhặt được Cung tên để bắn, hoặc nhặt được Gậy phép để bắn cầu lửa.

Nếu chúng ta code theo tư duy thông thường, hàm tấn_công() sẽ trông như thế này:

public void tanCong(String loaiVuKhi) {
    if (loaiVuKhi.equals("Kiếm")) {
        System.out.println("Chém cận chiến!");
    } else if (loaiVuKhi.equals("Cung")) {
        System.out.println("Bắn tên từ xa!");
    } else if (loaiVuKhi.equals("Phép")) {
        System.out.println("Ném cầu lửa!");
    }
}

Bạn có thấy "mùi" quen thuộc không? Nó lại là một đống if-else khổng lồ. Nếu game có 100 loại vũ khí, class Hero sẽ phình to khủng khiếp và vi phạm nguyên lý Đóng/Mở (Open/Closed Principle) y hệt như bài toán Factory lúc trước.

Với tư duy của Factory, chúng ta đã dùng Interface để tách việc "Tạo đối tượng" ra ngoài. Dựa vào kinh nghiệm đó, theo bạn, để giải quyết mớ if-else vũ khí này của Strategy, chúng ta nên tách việc "Tấn công bằng vũ khí gì" ra thành một cấu trúc như thế nào, để nhân vật có thể linh hoạt đổi vũ khí mà không cần viết if-else?

Để giải quyết bài toán mớ if-else vũ khí của nhân vật, Strategy Pattern ⚔️ đưa ra một giải pháp rất thanh lịch: Biến mỗi vũ khí thành một chiến lược (Strategy) độc lập.

Thay vì để class Hero tự định nghĩa cách tấn công cho từng loại vũ khí, chúng ta sẽ tách hành vi "tấn công" ra thành một Interface riêng. Bản thân Hero sẽ không sở hữu code tấn công cụ thể nào cả; nó chỉ giữ một tham chiếu đến vũ khí hiện tại.

Hãy cùng xem cách triển khai chi tiết qua các bước sau:

1. Tạo Bản hợp đồng Chiến lược (Strategy Interface) Đầu tiên, chúng ta tạo một Interface chung cho tất cả các loại vũ khí. Bất kỳ vũ khí nào muốn đưa vào game đều phải tuân thủ hợp đồng này.

// Giao diện chung cho mọi chiến lược tấn công
public interface AttackStrategy {
    void attack();
}

2. Triển khai các Chiến lược Cụ thể (Concrete Strategies) Mỗi vũ khí bây giờ là một class riêng biệt, tự xử lý logic tấn công của chính nó.

// Chiến lược dùng Kiếm
public class MeleeAttack implements AttackStrategy {
    public void attack() {
        System.out.println("Chém cận chiến bằng Kiếm! ⚔️");
    }
}

// Chiến lược dùng Cung
public class RangedAttack implements AttackStrategy {
    public void attack() {
        System.out.println("Bắn mũi tên từ xa bằng Cung! 🏹");
    }
}

// Chiến lược dùng Gậy phép
public class MagicAttack implements AttackStrategy {
    public void attack() {
        System.out.println("Phóng cầu lửa bằng Gậy phép! 🔮");
    }
}

3. Kết nối vào Nhân vật (Context) Class Hero bây giờ cực kỳ gọn gàng. Nó không cần biết có bao nhiêu loại vũ khí trong game. Nó chỉ cần giữ một biến kiểu AttackStrategy và có một hàm để thay đổi vũ khí này bất cứ lúc nào (Runtime).

public class Hero {
    private AttackStrategy weapon; // Nhân vật giữ một chiến lược tấn công

    // Hàm cho phép đổi vũ khí khi đang chơi (Runtime)
    public void setWeapon(AttackStrategy newWeapon) {
        this.weapon = newWeapon;
    }

    // Hàm tấn công không hề chứa một dòng if-else nào!
    public void performAttack() {
        if (weapon != null) {
            weapon.attack(); // Ủy quyền hành vi cho vũ khí tự xử lý
        } else {
            System.out.println("Tay không đấm đá! 👊");
        }
    }
}

4. Thực chiến trong game (Client Code) Các bạn hãy cùng xem cách nhân vật thay đổi hành vi mượt mà khi nhặt được vũ khí mới:

public class GameApp {
    public static void main(String[] args) {
        Hero lancelot = new Hero();

        // 1. Lúc đầu đi bộ, dùng Kiếm
        lancelot.setWeapon(new MeleeAttack());
        lancelot.performAttack(); // In ra: Chém cận chiến bằng Kiếm! ⚔️

        // 2. Nhặt được Cung, đổi chiến lược ngay lập tức
        lancelot.setWeapon(new RangedAttack());
        lancelot.performAttack(); // In ra: Bắn mũi tên từ xa bằng Cung! 🏹
    }
}

💡 Điểm cốt lõi của Strategy Pattern: Bạn đã tách được Phần hay thay đổi (Thuật toán tấn công của từng vũ khí) ra khỏi Phần cố định (Class Hero). Khi game có thêm 100 loại vũ khí mới, class Hero hoàn toàn không bị sửa đổi một dòng code nào.

Bây giờ, hãy nhìn vào điểm tương đồng: Cả Factory Method (ở trạm trước) và Strategy (ở trạm này) đều giúp chúng ta xóa bỏ các câu lệnh if-else bằng cách sử dụng Interface và tính đa hình.

Theo bạn, điểm khác biệt lớn nhất về mặt mục đích sử dụng giữa Factory Method và Strategy là gì?

Sự khác biệt lớn nhất giữa Factory Method và Strategy nằm ở mục đích tồn tại (Nhóm) của chúng:

  • 🏭 Factory Method (Nhóm Khởi tạo - Creational): Trả lời câu hỏi "Tạo ra cái gì?". Nhiệm vụ của nó là sinh ra một object mới tinh. Khi gọi Factory, bạn không có object trong tay, bạn đang yêu cầu nó làm ra một cái cho bạn (ví dụ: "Đưa tôi một cái Xe Tải").

  • ⚔️ Strategy (Nhóm Hành vi - Behavioral): Trả lời câu hỏi "Làm việc đó bằng cách nào?". Object của bạn đã tồn tại sẵn rồi (nhân vật Hero), và bạn chỉ đang muốn thay đổi cách nó thực hiện một hành động cụ thể ở thời gian thực (ví dụ: "Bây giờ hãy chuyển từ cách đánh bằng Kiếm sang đánh bằng Cung").

Cùng dùng Interface để xóa bỏ if-else, nhưng một bên dùng để sản xuất, một bên dùng để hành động.

3. Command Pattern (Lệnh / Chỉ huy) 🎮

Hãy tưởng tượng bạn đang gõ văn bản trên Microsoft Word và lỡ tay xóa mất một đoạn quan trọng. Bạn bấm Ctrl + Z (Undo) và đoạn văn thần kỳ hiện lại.

Để làm được trò ma thuật đó, Word không thể chỉ thực hiện hành động "Xóa" rồi quên nó đi. Thay vào đó, nó gói từng hành động của bạn thành một đối tượng độc lập (Object). Cái object đó chính là một Command (Lệnh).

Chiếc hộp Command này sẽ lưu trữ mọi thứ về hành động đó: Lệnh này tác động lên đoạn văn nào? Đoạn văn đó trước khi xóa có nội dung là gì?

Nếu chúng ta tạo một bản hợp đồng (Interface) chung tên là Command cho mọi hành động trong Word, theo bạn, ngoài hàm thucThi() (để chạy lệnh), Interface này bắt buộc phải có thêm hàm gì để tính năng Ctrl + Z (quay ngược thời gian) có thể hoạt động được?

Câu trả lời chính xác cho câu hỏi này là: Để tính năng Ctrl + Z hoạt động, bản hợp đồng (Interface) bắt buộc phải có thêm hàm hoanTac() (Undo) bên cạnh hàm thucThi() (Execute).

Bình thường, khi bạn gọi một hàm như xoaChu(), máy tính chạy xong là... quên luôn. Nhưng với Command Pattern, chúng ta đóng gói hành động đó thành một "chiếc hộp" (Object). Chiếc hộp này không chỉ biết cách thực hiện, mà còn ghi nhớ trạng thái để có thể tự dọn dẹp hiện trường nếu bị yêu cầu.

⚙️ Mổ xẻ cấu trúc bên trong

1. Bản hợp đồng Lệnh (Command Interface):

public interface Command {
    void thucThi(); // Bấm để chạy (Execute)
    void hoanTac(); // Bấm Ctrl+Z để hoàn tác (Undo)
}

2. Tạo một Lệnh cụ thể (Concrete Command):

Giả sử bạn bôi đen từ "Hello" và bấm nút Xóa. Chiếc hộp lệnh LenhXoaChu sẽ được tạo ra. Điểm mấu chốt là nó phải lưu lại chữ "Hello" trước khi thực sự xóa nó đi.

public class LenhXoaChu implements Command {
    private String chuDaXoa; // Bộ nhớ tạm của chiếc hộp
    private VanBan vanBan;

    public void thucThi() {
        this.chuDaXoa = vanBan.layChuDangBoiDen(); 
        vanBan.xoa(); // Thực hiện xóa
    }

    public void hoanTac() {
        vanBan.themChu(this.chuDaXoa); // Nhét lại chữ đã xóa vào chỗ cũ!
    }
}

3. Băng chuyền thời gian (History Stack):

Làm sao Word biết lệnh nào cần Undo trước? Nó sử dụng một Ngăn xếp (Stack) 🥞. Cứ mỗi lần bạn gõ chữ, tô đậm, hay xóa, một chiếc hộp Command mới sẽ được ném vào ngăn xếp. Khi bạn bấm Ctrl + Z, Word chỉ việc lấy chiếc hộp trên cùng ra và gọi hàm hoanTac() của nó.

💡 Tại sao lại dùng nó?

Thay vì biến hành động thành một Động từ (hàm), Command Pattern biến hành động thành một Danh từ (đối tượng). Nhờ là đối tượng, bạn có thể:

  • Lưu trữ chúng vào danh sách để làm Undo/Redo.
  • Lên lịch hẹn giờ cho chúng chạy sau.
  • Gói ghém chúng lại và gửi qua mạng (ví dụ: gửi lệnh điều khiển nhân vật game lên server).

Chúc mừng my friend! 🎉Chúng ta đã cùng nhau "phá đảo" 8 Design Pattern cực kỳ kinh điển và thực chiến nhất, đại diện cho cả 3 nhóm:

  • Nhóm Khởi tạo: Singleton 👑, Factory Method 🏭, Builder 🏗️
  • Nhóm Cấu trúc: Adapter 🔌, Decorator 🪆, Facade 🏢
  • Nhóm Hành vi: Observer 👀, Strategy ⚔️, Command 🎮

Với khối lượng kiến thức này, góc nhìn của bạn khi đọc mã nguồn của các dự án lớn chắc chắn đã sắc bén hơn rất nhiều.

Để tổng kết hành trình này, chúng ta hãy cùng thử sức với một Thử thách thực chiến nhé Mình sẽ đưa ra một tình huống thiết kế hệ thống thực tế của một công ty khởi nghiệp, và bạn sẽ thử phân tích xem chúng ta nên gọi tên các Pattern nào ra để giải cứu họ nhé?

Nghệ Thuật Chọn điểm Dừng (Cạm Bẫy Anti-Pattern)

Có một hiện tượng tâm lý rất phổ biến trong thế giới lập trình: Khi ai đó vừa học được Design Pattern, họ thường mắc phải Hội chứng Cái búa vàng (Golden Hammer) 🔨.

Như câu ngạn ngữ đã nói: "Khi trong tay bạn có một cái búa, mọi thứ xung quanh trông đều giống như một chiếc đinh." Hậu quả là lập trình viên cố gắng nhồi nhét Pattern vào mọi dòng code, biến một bài toán vốn có thể giải quyết bằng 3 dòng lệnh đơn giản thành một hệ thống 30 class chằng chịt. Hiện tượng này được gọi là Over-engineering (Làm quá vấn đề).

Thay vì giúp code dễ đọc hơn, việc lạm dụng Pattern làm tăng độ phức tạp không cần thiết và khiến những người vào sau mất hàng tuần chỉ để hiểu hệ thống đang chạy thế nào.

1. Sự sụp đổ của kẻ Độc tôn (Singleton Abuse)

Tại sao Pattern dễ học nhất lại bị cộng đồng quay lưng và coi là kẻ thù số một của việc kiểm thử lỗi (Testing)?

Nhắc lại một chút, Singleton đảm bảo chỉ có 1 object duy nhất tồn tại và cung cấp một điểm truy cập toàn cục (ai ở đâu cũng gọi được). Điều này khiến Singleton vô tình trở thành một "biến toàn cục" (global variable) được ngụy trang dưới lớp vỏ Design Pattern.

Cạm bẫy lớn nhất của nó thường lộ diện khi chúng ta thực hiện Kiểm thử tự động (Unit Testing).

Nguyên tắc tối thượng của Kiểm thử tự động là: Các bài test phải hoàn toàn độc lập với nhau. Bài test A chạy không được phép làm ảnh hưởng đến kết quả của bài test B.

Bây giờ, hãy tưởng tượng hệ thống của bạn có một Singleton là DatabaseConnection.getInstance()

Bạn viết 2 bài test:

  1. Test A: Chạy trước, gọi DatabaseConnection và vô tình thay đổi một trạng thái (ví dụ: đổi isLoggedIn = true).

  2. Test B: Chạy ngay sau Test A, cũng gọi DatabaseConnection.getInstance() để kiểm tra một chức năng khác.

Theo bạn, việc dùng chung một object Singleton duy nhất này sẽ gây ra thảm họa gì cho kết quả của Test B và quá trình tìm lỗi (debug) của lập trình viên sau này?

Đó chính là thảm họa mang tên: Trạng thái chia sẻ (Shared State)

Khi Test A và Test B dùng chung một đối tượng Singleton, đây là những "trái bom" sẽ nổ:

  • 💣 Hiệu ứng Domino trong Kiểm thử: Nếu Test A chạy trước và đổi trạng thái isLoggedIn = true, cái Singleton đó sẽ giữ nguyên trạng thái này. Khi Test B chạy ngay sau đó, nó mong đợi một kết nối mới tinh (isLoggedIn = false như mặc định), nhưng nó lại nhận được true. Kết quả là Test B thất bại một cách vô lý. Bài test của bạn trở nên "lúc đúng lúc sai" (flaky tests) tùy thuộc vào thứ tự chạy!

  • 🕵️‍♂️ Ác mộng Tìm lỗi (Debugging): Singleton là một biến toàn cục (global variable). Nghĩa là class NhanVien, class KhachHang, class HoaDon... bất cứ ai ở đâu cũng có thể gọi DatabaseConnection.getInstance() và ngấm ngầm thay đổi dữ liệu bên trong nó. Khi hệ thống báo lỗi sai dữ liệu, bạn sẽ giống như mò kim đáy bể vì không biết chính xác class nào trong hàng trăm class đã thay đổi cái Singleton đó.

Đó là lý do các lập trình viên hiện đại chuộng việc truyền đối tượng qua tham số hàm (Dependency Injection) hơn là dùng Singleton. Nhờ vậy, khi test, họ có thể tạo ra các đối tượng "giả" (Mock object) độc lập cho từng bài test.

2: Nhà máy sản xuất tăm (Factory Overkill) 🏭

Nhắc lại một chút, Factory Method cực kỳ tuyệt vời khi bạn có logic tạo đối tượng phức tạp, ví dụ phải dùng lệnh if-else để quyết định xem nên tạo ra OTo, XeMay hay MayBay.

Nhưng hội chứng "Cái búa vàng" xuất hiện khi lập trình viên áp dụng nó cho... mọi thứ. Giả sử bạn chỉ cần tạo một đối tượng NguoiDung (User) đơn giản chỉ có tên và email. Thay vì viết new NguoiDung("Nam", "nam@email.com"), họ lại đi xây hẳn một class UserFactory dài 20 dòng chỉ để bọc đúng một lệnh new đó lại.

Theo bạn, việc cố tình nhét một "nhà máy" vào để tạo ra những đối tượng quá đơn giản (như chiếc tăm) sẽ gây ra sự lãng phí hay rắc rối gì cho những lập trình viên khác khi họ đọc và bảo trì đoạn code này?

Khi bạn lạm dụng Factory cho những đối tượng quá đơn giản, bạn đang tự tạo ra 3 gánh nặng khổng lồ cho chính mình và đồng nghiệp:

1. Phình to mã nguồn vô lý (Code Bloat):

Thay vì chỉ cần 1 dòng code duy nhất: NguoiDung u = new NguoiDung("Nam");

Bạn phải viết ít nhất 3 file:

  • Một Interface UserFactory
  • Một class BasicUserFactory implements interface đó.
  • Hàm create() bên trong class đó. Bạn đang viết 30 dòng code để làm thay công việc của 1 dòng!

2. Gánh nặng nhận thức (Cognitive Load):

Khi một lập trình viên mới vào dự án và đọc dòng code UserFactory.create(), bộ não của họ ngay lập tức bật chế độ phòng thủ: "Chắc chắn logic tạo User này phải phức tạp lắm nên người đi trước mới dùng hẳn Factory!". Họ tốn 15 phút lần mò qua 3 file code khác nhau, để rồi chưng hửng nhận ra bên trong chỉ có mỗi dòng return new NguoiDung(). Điều này gây ức chế và lãng phí thời gian vô ích.

3. Hiệu ứng "Sợi dây truyền tốn kém" (Maintenance Overhead):

Giả sử ngày mai, bạn muốn thêm soDienThoai vào lúc tạo người dùng. Nếu dùng new trực tiếp, bạn chỉ việc sửa 1 chỗ. Nhưng với Factory, bạn phải đi sửa hàm khởi tạo của NguoiDung, sửa hàm create() trong interface, và sửa luôn hàm create() trong class Factory. Rất cồng kềnh.

💡 Bài học rút ra: Hãy tuân thủ nguyên tắc KISS (Keep It Simple, Stupid - Hãy giữ mọi thứ đơn giản). Cứ dùng từ khóa new cho đến khi nào logic tạo đối tượng thực sự có những câu lệnh if-else phức tạp thì mới cân nhắc dùng Factory.

3. Lạc lối trong Mê cung (Pattern Overdose) 🧩

Đây là "căn bệnh" nặng nhất. Có những lập trình viên khi viết chức năng in ra dòng chữ "Xin chào", thay vì dùng đúng 1 lệnh print("Xin chào"), họ lại thiết kế thế này:

  • Tạo một GreetingStrategy (để lỡ sau này muốn chào bằng tiếng khác).
  • Bọc nó vào một ExclamationDecorator (để thêm dấu chấm than vào cuối câu).
  • Gắn tất cả vào một PrinterAdapter (để lỡ sau này muốn in ra máy in thay vì màn hình).
  • Cuối cùng giấu tất cả sau một GreetingFacade.

Nhìn thì có vẻ rất "chuyên nghiệp" và lường trước được mọi tương lai, nhưng thực tế, 99% những cái "lỡ sau này" đó không bao giờ xảy ra.

Lúc này nguyên tắc YAGNI mới chính xác là thứ bạn cần hướng tới, nó phát biểu rằng: "You Aren't Gonna Need It" (Bạn sẽ không cần đến nó đâu!)."

1. Khía cạnh Tâm lý (Ảo tưởng về tương lai):

Các lập trình viên thường mắc một hội chứng gọi là "lo xa quá mức". Khi thiết kế hệ thống, họ thường tự dọa mình: "Nhỡ đâu tháng sau sếp đòi thêm tính năng xuất file PDF?", "Nhỡ đâu năm sau công ty đổi sang dùng Database khác?". Thế là để "phòng ngự", họ hì hục đắp thêm chục cái Interface, nhồi nhét Strategy kết hợp với Factory và Facade vào đoạn code hiện tại... cho chắc ăn.

2. Khía cạnh Hậu quả (Cái giá của sự phức tạp):

Thực tế phũ phàng là: 99% những cái "nhỡ đâu" đó không bao giờ xảy ra! Yêu cầu kinh doanh luôn thay đổi theo hướng bạn không thể đoán trước được. Nhưng cái giá phải trả thì lại hiển hiện ngay trước mắt:

  • Mã nguồn trở thành một mê cung chằng chịt các lớp (classes) rỗng tuếch.
  • Người mới vào dự án đọc code không hiểu gì.
  • Khi có bug, bạn phải nhảy qua 5-6 tầng Design Pattern mới tìm thấy dòng code thực thi lõi nằm ở đâu.

3. Khía cạnh Giải pháp (Nghệ thuật biết điểm dừng):

Nguyên lý YAGNI khuyên bạn: Hãy chỉ viết code để giải quyết bài toán của ngày hôm nay, một cách đơn giản nhất có thể. Điều này không có nghĩa là viết code cẩu thả, mà là viết code "vừa đủ". Khi nào yêu cầu thay đổi thực sự ập đến, thì lúc đó chúng ta mới dùng kỹ thuật Refactoring (Tái cấu trúc) để từ từ đắp Design Pattern vào. Pattern sinh ra để giải quyết nỗi đau khi hệ thống lớn lên, đừng dùng nó để giải quyết một nỗi đau chưa hề tồn tại!

Đúc kết

Nếu bạn để ý, những gì chúng ta vừa mổ xẻ chính là việc một Design Pattern cụ thể biến thành Anti-pattern khi bị dùng sai bối cảnh. Để đúc kết lại và hiểu rõ hơn, chúng ta có thể chia Anti-pattern trong lập trình thành 2 nhóm chính:

  • 🎭 Nhóm "Lạm dụng đồ tốt" (Những gì chúng ta vừa bàn): Bản thân Singleton hay Factory không hề xấu. Chúng chỉ biến thành Anti-pattern (như Singleton Abuse hay Factory Overkill) khi chúng ta dùng một "cái búa tạ" để đập một "con muỗi". Sự sai lầm nằm ở bối cảnh sử dụng chứ không nằm ở bản thân Pattern đó.

  • 🕸️ Nhóm "Thói quen xấu kinh điển": Đây là những giải pháp tồi tệ mà rất nhiều lập trình viên trên thế giới cứ lặp đi lặp lại một cách tự nhiên. Dù không liên quan đến Design Pattern nào, chúng vẫn bị coi là Anti-pattern. Ví dụ:

    • God Object (Thực thể toàn năng): Tạo ra một class khổng lồ dài hàng nghìn dòng, ôm đồm mọi chức năng từ kết nối mạng, xử lý dữ liệu đến giao diện.

    • Spaghetti Code (Code mì Ý): Code không có cấu trúc, các class gọi nhau chéo ngoe và rối rắm như một đĩa mì. Rút một sợi mì là cả đĩa rối tung lên.

Vậy nên, Anti-pattern hiểu rộng ra là "những cách giải quyết vấn đề tưởng là hay, nhưng thực chất lại mang đến hậu quả tồi tệ về lâu dài".

Dựa trên tinh thần của KISS (Giữ cho đơn giản) và YAGNI (Đừng lo xa), trước khi gõ bất kỳ dòng code nào để thiết lập một Design Pattern, câu hỏi quan trọng nhất chúng ta phải tự hỏi mình là:

"Vấn đề này ĐÃ thực sự tồn tại chưa, và hệ thống CÓ THỰC SỰ cần đến sự phức tạp này để giải quyết nó không?" 🎯

Hoặc nói một cách dân dã, thực chiến hơn: "Chỗ này viết một lệnh if-else đơn giản thì có sao không?"

Nếu câu trả lời là "Không sao cả, code vẫn chạy tốt và dễ đọc", thì hãy dừng ngay việc áp dụng Pattern lại. Hãy nhớ quy tắc vàng: Design Pattern nên được áp dụng để giải quyết một nỗi đau có thật (khi mã nguồn cũ bắt đầu bộc lộ sự khó bảo trì), chứ không bao giờ nên sinh ra từ sự tưởng tượng về tương lai của lập trình viên.

🎯 Lời Kết: Thanh Gươm Báu Hay Chiếc Búa Tạ?

Hành trình "mổ xẻ" 9 Design Pattern kinh điển của chúng ta khép lại tại đây. Từ việc rào giậu cẩn thận khi tạo ra đối tượng (Nhóm Khởi tạo), khéo léo chắp vá các mảnh vỡ lệch pha (Nhóm Cấu trúc), cho đến việc thổi hồn vào mạng lưới giao tiếp nhịp nhàng (Nhóm Hành vi), bạn giờ đây đã nắm trong tay tư duy của một Kiến trúc sư phần mềm thực thụ.

Tuy nhiên, trước khi bạn hào hứng đóng gói tất cả những Pattern này và "nhồi" vào dự án ngày mai, hãy nhớ lại cạm bẫy của Hội chứng Cái Búa Vàng 🔨. Design Pattern là thanh gươm báu để giải quyết những "nỗi đau" có thật, chứ không phải chiếc búa tạ để đập nát sự đơn giản của hệ thống.

Hãy luôn niệm thần chú KISS (Giữ cho mọi thứ đơn giản) và YAGNI (Bạn chưa thực sự cần đến nó đâu) trước khi gõ bất kỳ dòng code nào. Suy cho cùng, mã nguồn vĩ đại nhất không phải là mã nguồn dùng nhiều Pattern nhất, mà là mã nguồn dễ đọc, dễ hiểu và dễ bảo trì nhất.

🚀 Câu hỏi dành riêng cho bạn: Nhìn lại dự án bạn đang làm, nếu chỉ được chọn đúng 1 trong 9 Design Pattern trên để áp dụng ngay vào ngày mai nhằm "cứu rỗi" một đoạn code đang rối rắm, bạn sẽ gọi tên Pattern nào? Hãy chia sẻ câu chuyện "code mì Ý" của bạn ở dưới phần bình luận nhé! 👇


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí