Design Patterns trong C# — Hướng dẫn cho người mới mà nhớ được
Tour cơ bản về design pattern trong C# / .NET 10: chúng là gì, khi nào dùng, và cách đọc 23 mẫu GoF kinh điển mà không bị ngợp thuật ngữ.
Mục lục
- Design pattern trong C# / .NET là gì?
- Vì sao design pattern vẫn quan trọng năm 2026?
- 23 pattern Gang of Four được chia nhóm thế nào?
- Đọc một mô tả pattern thế nào để khỏi bị ngợp?
- Khi nào nên tránh ép pattern vào code?
- Trông một ví dụ "trước - sau" siêu nhỏ ngay bây giờ thế nào?
- Đi tiếp trong series ở đâu?
Bạn đã thấy triệu chứng rồi. Một method ban đầu chỉ mười dòng dễ đọc, nay
phình thành cái thang if/else 200 dòng đầy if (request.Type == "X").
Mỗi feature mới thêm một nhánh, mỗi bug-fix đụng vào ba nhánh, file test thì
dài 2.000 dòng. Code chạy được. Không ai muốn mở nó ra.
Phần lớn lập trình viên đến đây sẽ Google "cách refactor switch dài". Họ thấy
một kết quả nhắc đến Strategy pattern — và rời đi còn rối hơn lúc tới,
vì lời giải thích là sơ đồ UML đầy ConcreteStrategyA, ConcreteStrategyB.
Series này sinh ra để sửa cảnh đó. Chúng ta sẽ đi qua từng pattern Gang of
Four bằng C# .NET 10 thật, triệu chứng thật, không có chỗ cho
AbstractFactoryFactory lấp chỗ trống.
Bài đầu tiên này là tấm bản đồ. Đọc xong, bạn sẽ biết design pattern là gì, đọc một pattern thế nào cho đỡ ngợp, khi nào không nên dùng, và nên bắt đầu từ đâu trong những bài sau.
Design pattern trong C# / .NET là gì?
Một design pattern là giải pháp có tên, có thể tái sử dụng, cho một bài toán cứ tái diễn ở các codebase hướng đối tượng. Ba thứ làm nó trở thành "pattern" thay vì "lời khuyên":
- Có tên. "Strategy" là một từ mà hai senior nói trong PR review là hiểu nhau ngay. Cái tên chiếm một nửa giá trị.
- Có hình. Pattern mô tả các vai (interface, class, method) và cách chúng phối hợp. Vẽ được lên bảng.
- Có cái giá phải trả. Pattern liệt kê chi phí — thêm file, tăng tầng gián tiếp, có thể chậm hơn — để bạn cân nhắc trước khi áp dụng.
Trong .NET, bạn đã dùng cả chục pattern mà không gọi tên chúng.
IEnumerable<T> là pattern Iterator. Middleware ASP.NET Core là
Chain of Responsibility. IOptions<T> lai giữa Strategy và
Adapter. Lazy loading của EF Core là Proxy. Đội phát triển framework
học trước những cái tên này để doc của họ gọn lại trong một trang; khi bạn
học xong, doc của họ cũng tự ngắn đi đáng kể đối với bạn.
Vì sao design pattern vẫn quan trọng năm 2026?
Câu trả lời thật lòng không giống sách giáo trình đại học. Pattern quan trọng vì ba lý do thực dụng: giao tiếp, nhận diện, đòn bẩy refactor.
- Giao tiếp. "Bọc cái legacy SDK đó bằng Adapter, rồi thêm Decorator để retry" — một câu đủ thay cả buổi họp 30 phút.
- Nhận diện. Khi bạn đặt được tên cho 23 hình dạng bài toán phổ biến, bạn không còn phát minh lại bánh xe. Thấy hình quen, mở đúng hộp đồ nghề, rút đúng dụng cụ. Người mới tranh cãi; người nhiều kinh nghiệm chỉ nhận diện.
- Đòn bẩy refactor. Đa số pattern mô tả một hướng để dịch chuyển code
xấu. Nhận ra cái
switchlằng nhằng muốn trở thành Strategy cho bạn một điểm đến cụ thể để refactor từng bước. Bạn không còn đổi mười thứ cùng lúc; bạn đổi một thứ, hướng về một đích đã biết.
C# hiện đại có làm pattern thay đổi hình dạng. Record giúp Memento bất
biến gần như miễn phí. Source generator thay được vài Visitor. Func<T, TResult> rút nhiều class Command/Strategy về một dòng. Ý đồ vẫn nguyên,
phần boilerplate teo lại.
23 pattern Gang of Four được chia nhóm thế nào?
Năm 1994, bốn tác giả (Gamma, Helm, Johnson, Vlissides — tên gọi tắt là GoF) xuất bản cuốn sách đặt tên cho những pattern này và xếp chúng vào ba nhóm:
flowchart TB
GOF["23 GoF Design Patterns"]
GOF --> CRE["Khởi tạo · 5 pattern<br>object được tạo ra sao"]
GOF --> STR["Cấu trúc · 7 pattern<br>object ghép với nhau ra sao"]
GOF --> BEH["Hành vi · 11 pattern<br>object giao tiếp ra sao"]
CRE --> C1[Singleton]
CRE --> C2[Factory Method]
CRE --> C3[Abstract Factory]
CRE --> C4[Builder]
CRE --> C5[Prototype]
STR --> S1[Adapter]
STR --> S2[Bridge]
STR --> S3[Composite]
STR --> S4[Decorator]
STR --> S5[Facade]
STR --> S6[Flyweight]
STR --> S7[Proxy]
BEH --> B1[Chain of Responsibility]
BEH --> B2[Command]
BEH --> B3[Interpreter]
BEH --> B4[Iterator]
BEH --> B5[Mediator]
BEH --> B6[Memento]
BEH --> B7[Observer]
BEH --> B8[State]
BEH --> B9[Strategy]
BEH --> B10[Template Method]
BEH --> B11[Visitor]
Cách nhóm này hữu ích hơn vẻ ngoài của nó:
- Khởi tạo trả lời ai tạo object, với hiểu biết gì? Khi bạn thấy mình
đang nói "tôi cần X nhưng không muốn
new X()ngay đây", khả năng cao là một creational pattern là câu trả lời. - Cấu trúc trả lời các class này ghép vào nhau thế nào mà không bị hàn dính? Là về dây nối, không phải mối hàn.
- Hành vi trả lời các object truyền việc cho nhau lúc runtime ra sao? Phần lớn xoay quanh ai quyết định cái gì, vào lúc nào.
Đặt được bài toán vào một trong ba nhóm trên là bạn đã thu hẹp tìm kiếm từ 23 pattern xuống còn khoảng 7. Riêng việc đó cũng đáng năm phút bỏ ra để nhớ sơ đồ phía trên.
Đọc một mô tả pattern thế nào để khỏi bị ngợp?
Mô tả pattern theo một khuôn cố định. Biết khuôn đó rồi, kể cả pattern lạ hoắc bạn cũng lướt qua được. Mọi bài trong series này đều dùng đúng các heading sau, một cách có chủ đích:
| Mục | Trả lời cho |
|---|---|
| Intent (Ý đồ) | Một câu duy nhất "pattern này giải quyết bài toán nào". |
| Structure (Cấu trúc) | Sơ đồ class — ai biết về ai. |
| Khi nào dùng | Các triệu chứng cụ thể trong code. |
| Khi nào KHÔNG dùng | Các bẫy — thường là "vừa mới học xong". |
| Ví dụ C# | Một Program.cs chạy được, có output. |
| So sánh | Phân biệt với pattern hay bị nhầm lẫn nhất. |
| Cạm bẫy | Cái gì hỏng khi vào production. |
Khi đọc một pattern mới, đọc theo thứ tự này: Intent → Khi nào dùng → Ví dụ C# → Cạm bẫy. Bỏ qua Cấu trúc trong lần đọc đầu. Sơ đồ chỉ có nghĩa sau khi bạn đã thấy code; đọc nó trước là lý do chính khiến người mới nghĩ pattern là chuyện trừu tượng vô bổ.
Khi nào nên tránh ép pattern vào code?
Đây là phần không ai để lên đầu bài, mà là phần bạn cần nhất.
Nguyên tắc của ba. Pattern xứng đáng với độ phức tạp của nó vào lần
thứ ba bạn gặp lại bài toán cũ, không phải lần đầu. Lúc này chỉ có một
thứ thay đổi? Một câu if là câu trả lời đúng. Hai? Vẫn nên là if. Ba?
Lúc này mới hợp lý để tách Strategy hoặc Factory.
Cái búa của Maslow. Hai tuần sau khi học Visitor, bạn sẽ thấy bài toán
Visitor ở khắp mọi nơi. Thực ra không phải. Hãy đợi triệu chứng — cái
thang switch thật, đoạn clone trùng lặp thật — rồi mới đụng pattern.
Chi phí là có thật. Mỗi pattern thêm file, thêm tên gọi, thêm tầng gián tiếp. Người đọc code của bạn sáu tháng sau (thường là chính bạn) sẽ trả cái giá đó mỗi lần ghé. Nếu pattern không mang lại lợi ích cụ thể — khả năng thêm tính năng mà không sửa code cũ, có thể isolate để unit-test, loại trùng lặp thật — thì nó là thuế nhưng không có doanh thu.
Framework đã đóng gói sẵn nhiều pattern. Pipeline ASP.NET Core là Chain of Responsibility. DI container là Service Locator + Abstract Factory. MediatR là Mediator + Command. Nếu framework đã trao bạn pattern đó, không cần tự cuộn; cái cần là học API của framework.
Trông một ví dụ "trước - sau" siêu nhỏ ngay bây giờ thế nào?
Đây là triệu chứng mà chúng ta mở bài — một bộ tính chiết khấu cứ thêm
nhánh if mỗi lần có hạng khách mới. Bản trước là cái team check-in
vào một chiều thứ Ba dưới sức ép deadline:
// Trước — mỗi hạng mới là một nhánh + một bề mặt sinh bug.
public decimal CalculateDiscount(Customer customer, decimal subtotal)
{
if (customer.Tier == "Standard") return subtotal * 0.00m;
if (customer.Tier == "Silver") return subtotal * 0.05m;
if (customer.Tier == "Gold") return subtotal * 0.10m;
if (customer.Tier == "Platinum") return subtotal * 0.15m + 5m;
if (customer.Tier == "Employee") return subtotal * 0.30m;
return 0m;
}
Pattern Strategy nói: "tách mỗi nhánh ra, đặt tên cho nó, để chỗ gọi tự chọn cái cần". Trong C# hiện đại, việc đó còn không cần class mới:
// Sau — mỗi luật một dòng, thêm hạng mới gói gọn một chỗ, dispatcher ngu ngơ.
using DiscountRule = System.Func<decimal, decimal>;
public static class DiscountRules
{
public static readonly Dictionary<string, DiscountRule> ByTier = new()
{
["Standard"] = sub => 0m,
["Silver"] = sub => sub * 0.05m,
["Gold"] = sub => sub * 0.10m,
["Platinum"] = sub => sub * 0.15m + 5m,
["Employee"] = sub => sub * 0.30m,
};
}
public decimal CalculateDiscount(Customer customer, decimal subtotal)
=> DiscountRules.ByTier.TryGetValue(customer.Tier, out var rule)
? rule(subtotal)
: 0m;
Bản "sau" chính là Strategy pattern, dù không hề có interface
IDiscountStrategy nào. Cốt lõi của pattern là để phần thay đổi có thể
thay thế được, không phải đẻ ra một hình dạng class cụ thể. Func<> của
C# là đủ. Khi luật bắt đầu có trạng thái — cần log, cần gọi service khác,
cần được unit-test riêng — đó là lúc bạn lên đời thành interface thật. Mà
khi đó, bạn đã biết phải gọi tên nó thế nào.
Chúng ta sẽ quay lại đúng ví dụ này, với ràng buộc thực tế tăng dần, ở bài chuyên sâu về pattern Strategy.
Đi tiếp trong series ở đâu?
Series được sắp để đọc tuần tự từ trên xuống, nhưng bạn cũng có thể nhảy vào theo bài toán. Ba lộ trình gợi ý:
- "Cuối tuần này tôi muốn lướt một vòng cho biết." Đọc bài này, rồi Singleton (pattern bị lạm dụng nhất nên đáng hiểu kỹ), tiếp đến Factory Method, Strategy, và Observer. Đó là 80% bạn gặp hằng tuần.
- "Tôi đang có bài toán cụ thể ngay bây giờ." Mở Cách chọn design pattern phù hợp và đi theo cây quyết định trong đó.
- "Tôi muốn tour đầy đủ GoF." Cứ đọc tiếp — bài kế là Singleton.
Một lưu ý cuối về cách đọc mỗi bài: mọi bài trong series có khối Quick Answer ngay dưới tiêu đề. Nếu bạn chỉ có 30 giây — giữa cuộc họp, đang review PR — đọc Quick Answer và FAQ là đủ. Quay lại đọc code khi rảnh. Cấu trúc đó là cố ý; design pattern phải làm bạn đọc nhanh hơn, không phải chậm hơn.
Câu hỏi thường gặp
C# hiện đại đã có record, pattern matching, minimal API thì design pattern còn cần không?
record khiến Memento và Prototype gần như miễn phí. Func<> và lambda thay luôn nhiều class Strategy hay Command. Ý đồ của pattern không đổi, chỉ có cách viết gọn hẳn lại. Biết pattern giúp bạn nhận ra khi nào một dòng Func<> là đủ và khi nào mới cần đến hệ thống interface đầy đủ.Có cần học thuộc lòng cả 23 pattern Gang of Four không?
Design pattern, nguyên lý SOLID và kiến trúc phần mềm khác nhau ở đâu?
Có nên refactor code cũ sang pattern chỉ vì mới nhận ra pattern đó không?
if/else đơn lẻ chỉ làm khổ người đọc sau bạn. Hãy đợi đến trường hợp thứ hai mới đụng tay.