Ngôn ngữ nào cũng có khoảng trống. JavaScript không có interface lúc runtime, Java bắt bạn viết class cho mọi thứ, Go hoàn toàn không có kế thừa. Qua nhiều thập kỷ, lập trình viên liên tục gặp cùng những bài toán thiết kế trong các khoảng trống đó và liên tục đi đến cùng những hình dạng lời giải. Một design pattern là một trong những hình dạng ấy, được ghi lại kèm một cái tên, để người đến sau không phải tự khám phá lại.
Trước khi học bất kỳ pattern nào, nên trả lời năm câu hỏi: pattern là gì, danh mục pattern đến từ đâu, có thể bỏ qua không, học như thế nào, và làm sao học nhanh. Chương này trả lời chúng và chuẩn bị cho phần còn lại của hướng dẫn.
Design pattern là gì
Một pattern là mô tả có thể tái sử dụng cho lời giải của một vấn đề cứ lặp lại trong một bối cảnh nhất định. Nó không phải thư viện, cũng không phải snippet để dán vào. Nó gần với một công thức nấu ăn hơn: nó gọi tên các thành phần tham gia (các class, hàm hoặc module liên quan), nói rõ mỗi thành phần chịu trách nhiệm gì, và liệt kê các trade-off.
Giá trị của nó là bạn bắt đầu từ những thiết kế đã chạy tốt trong nhiều hệ thống, thay vì tự nghĩ ra thiết kế của riêng mình dưới áp lực deadline.
23 pattern kinh điển đến từ đâu
Danh mục mà hầu hết mọi người nhắc tới là cuốn sách của "Gang of Four", Design Patterns: Elements of Reusable Object-Oriented Software của Erich Gamma, Richard Helm, Ralph Johnson và John Vlissides, xuất bản năm 1994. Ý tưởng về một ngôn ngữ pattern được mượn từ kiến trúc sư Christopher Alexander, người dùng nó để mô tả các lời giải lặp lại trong nhà cửa và đô thị.
Cuốn sách liệt kê 23 pattern trong ba nhóm:
| Nhóm | Số lượng | Câu hỏi nó trả lời | Pattern |
|---|---|---|---|
| Creational | 5 | Object được tạo ra như thế nào? | Factory Method, Abstract Factory, Builder, Prototype, Singleton |
| Structural | 7 | Object được ghép thành cấu trúc lớn hơn như thế nào? | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| Behavioral | 11 | Object giao tiếp và chia việc như thế nào? | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor |
Simple Factory, mà bạn sẽ gặp ở chương 5, không nằm trong 23 pattern. Nó là một dạng đơn giản hóa phổ biến của Factory Method và là bước khởi đầu tốt.
Không gì trong số này chỉ dành riêng cho Java hay C++. Ví dụ trong sách là hướng đối tượng, nhưng các vấn đề (tách rời việc khởi tạo, bọc các API không tương thích, thông báo cho nhiều listener) xuất hiện ở mọi ngôn ngữ và trong các framework bạn đang dùng. Middleware của Express là một chain of responsibility, sự kiện DOM là observer, và container dependency injection của NestJS mặc định trả về singleton cho bạn.
Có thể bỏ qua không?
Bạn có thể ship phần mềm mà không bao giờ gọi tên một pattern. Bạn vẫn nên học chúng, vì bốn lý do:
- Chúng đã được kiểm chứng. Mỗi pattern là kinh nghiệm được chắt lọc từ nhiều team. Dùng một pattern thường cho bạn code dễ mở rộng và tái sử dụng hơn một thiết kế nghĩ ra tại chỗ.
- Chúng là từ vựng. Nói "đặt một facade trước các payment SDK" hay "biến config thành singleton" truyền đạt cả một thiết kế trong một câu. Review code và thảo luận thiết kế ngắn đi rất nhiều.
- Chúng giúp bàn giao dễ hơn. Khi bạn được thăng chức, đổi team hay đổi công ty, người khác sẽ thừa kế code của bạn. Nếu thiết kế theo những pattern quen thuộc, họ hiểu ý đồ mà không cần hỏi bạn. Điều ngược lại cũng đúng khi bạn vào một codebase mới: nhận ra các pattern là cách nhanh nhất để đọc nó.
- Chúng dạy tư duy hướng đối tượng. Pattern là nơi những ý tưởng như "lập trình theo interface", "ưu tiên composition hơn kế thừa" và "đóng gói phần thay đổi" thôi là khẩu hiệu và trở thành code cụ thể.
Cách học một pattern
Bạn không cần cày hết cuốn sách từ đầu đến cuối. Thứ bạn cần là một checklist nhất quán cho mỗi pattern. Sau khi học xong một pattern, bạn nên trả lời được:
- Mục đích. Trong một câu, pattern này dùng để làm gì?
- Vấn đề. Nó giải quyết vấn đề thực tế nào? Tìm một ví dụ trong đời sống và một ví dụ trong phần mềm.
- Phạm vi áp dụng. Khi nào nên dùng, và dấu hiệu nào cho thấy không nên dùng?
- Thực hành. Viết nó ra. Đọc về một pattern mà không dùng thì gần như chẳng đọng lại gì. Dùng nó ít nhất một lần trong một dự án thật hoặc dự án luyện tập.
Mọi chương trong hướng dẫn này đều theo checklist đó, và mỗi chương kết thúc bằng bài tập cho bước 4.
Có một lời cảnh báo đáng nghiêm túc ngay từ ngày đầu: đừng cố dùng mọi pattern trong một hệ thống. Hộp đồ nghề không phải là checklist. Một người thợ mộc có bốn mươi dụng cụ không dùng hết bốn mươi cái để đóng một chiếc ghế, và một codebase dùng pattern ở chỗ một hàm là đủ sẽ khó đọc hơn chứ không dễ hơn.
Xếp hạng pattern trước khi học
Cách thực tế nhất để lên kế hoạch học là một bảng chấm mỗi pattern trên hai trục, độ khó và độ phổ biến, rồi chọn thứ tự từ đó:
- Dễ và phổ biến: học trước. Singleton khó khoảng một sao và phổ biến bốn sao.
- Khó nhưng phổ biến: học tiếp theo, chậm rãi, kèm code.
- Khó và ít dùng: tạm bỏ qua, quay lại cuối cùng.
Đây là bảng của mình cho công việc TypeScript và Node.js năm 2026, kèm chương nói về từng pattern. Số sao là đánh giá chủ quan, không phải phép đo, nên hãy điều chỉnh theo stack bạn đang làm.
| Pattern | Nhóm | Độ khó | Độ phổ biến trong JS/TS | Chương |
|---|---|---|---|---|
| Singleton | Creational | ★☆☆☆☆ | ★★★★☆ | 6 |
| Simple Factory / Factory Method | Creational | ★★☆☆☆ | ★★★★★ | 5 |
| Builder | Creational | ★★★☆☆ | ★★★☆☆ | 8 |
| Prototype | Creational | ★★☆☆☆ | ★★★☆☆ | 7 |
| Adapter | Structural | ★★☆☆☆ | ★★★★☆ | 9 |
| Facade | Structural | ★★☆☆☆ | ★★★★★ | 12 |
| Proxy | Structural | ★★★☆☆ | ★★★★☆ | 11 |
| Bridge | Structural | ★★★☆☆ | ★★★☆☆ | 10 |
| Strategy | Behavioral | ★★★☆☆ | ★★★★☆ | 13 |
| Observer | Behavioral | ★★★☆☆ | ★★★★★ | 14 |
| Decorator, Composite, Command, Chain of Responsibility, State, Template Method, Iterator | Hỗn hợp | ★★–★★★ | ★★★☆☆ | Vòng sau |
| Abstract Factory, Flyweight, Mediator, Memento, Visitor, Interpreter | Hỗn hợp | ★★★–★★★★ | ★★☆☆☆ | Để cuối cùng |
Mười trên 23 pattern không phải là đường tắt. Đó gần như là tập pattern bạn sẽ gặp trong một tuần làm backend hoặc frontend bình thường, và là một kế hoạch một tuần hợp lý: hai pattern mỗi ngày, mỗi pattern đi kèm checklist bốn câu hỏi và một bài tập.
Lần refactor đầu tiên theo dáng một pattern
Lần refactor phổ biến nhất hướng tới một pattern bắt đầu từ một switch. Đây là hàm tính phí ship viết theo cách hiển nhiên:
type Carrier = "ghn" | "ghtk" | "viettel-post";
export function shippingFee(carrier: Carrier, weightKg: number): number {
switch (carrier) {
case "ghn":
return 15_000 + weightKg * 5_000;
case "ghtk":
return 18_000 + weightKg * 4_000;
case "viettel-post":
return 20_000 + Math.max(0, weightKg - 2) * 3_500;
}
}
Nó chạy được, nhưng mỗi nhà vận chuyển mới nghĩa là phải sửa hàm này, và quy tắc của mọi nhà vận chuyển nằm chung một chỗ mà ai cũng đụng vào. Biến các case thành dữ liệu cho bạn một bảng tra cứu:
type FeeRule = (weightKg: number) => number;
const feeRules: Record<Carrier, FeeRule> = {
ghn: (kg) => 15_000 + kg * 5_000,
ghtk: (kg) => 18_000 + kg * 4_000,
"viettel-post": (kg) => 20_000 + Math.max(0, kg - 2) * 3_500,
};
export function shippingFee(carrier: Carrier, weightKg: number): number {
return feeRules[carrier](weightKg);
}
Giờ mỗi quy tắc là một hàm nhỏ với một nhiệm vụ, thêm nhà vận chuyển là thêm một entry, và Record<Carrier, FeeRule> khiến TypeScript làm build thất bại nếu thiếu nhà vận chuyển nào. Đó là cốt lõi của Strategy pattern (chương 13) và là một áp dụng trực tiếp của Open/Closed Principle (chương 4), viết ra mà không cần một class nào.
Pattern ẩn trong framework bạn đang dùng
Trước khi tự viết pattern nào, nên nhìn xem bạn đã phụ thuộc vào bao nhiêu pattern rồi. Phần lớn công việc hằng ngày của mình là NestJS, và gần như mọi tính năng của framework này là một pattern có tên, với API riêng của Nest đặt lên trên:
| Bạn viết gì trong Nest | Pattern bên dưới | Chương |
|---|---|---|
@Injectable() với scope mặc định |
Singleton, do container sở hữu thay vì một field static | 6 |
{ provide, useFactory, inject } |
Simple Factory | 5 |
{ provide: TOKEN, useClass: ... } đổi theo môi trường |
Strategy cộng Dependency Inversion | 13 |
ConfigurableModuleBuilder, forRoot()/forRootAsync() |
Builder và Factory cho cả một module | 8 |
Interceptor (NestInterceptor) |
Proxy bọc quanh một route handler | 11 |
@nestjs/platform-express và @nestjs/platform-fastify |
Adapter: một interface HttpAdapter, hai HTTP server |
9 |
| Middleware, guard, pipe, filter chạy theo thứ tự | Chain of Responsibility | Vòng sau |
@nestjs/event-emitter với @OnEvent() |
Observer | 14 |
| Một module export một service che năm service nội bộ | Facade | 12 |
Có một cái tên trong danh sách đó là cái bẫy. Decorator của TypeScript (@Injectable(), @Get(), @UseGuards()) không phải Decorator pattern của Gang of Four. Chúng gắn metadata vào class hoặc method lúc định nghĩa, và Nest đọc metadata đó lúc bootstrap. Decorator của GoF bọc một object lúc runtime bằng một object khác có cùng interface. Interceptor gần với Decorator (hoặc Proxy) của GoF hơn nhiều so với cú pháp @. Nhầm hai thứ này trong phỏng vấn là cách mất điểm khá phổ biến.
Trong NestJS
Đây là bảng tra phí ship ở phần trước, chuyển vào một service Nest theo cách mình sẽ viết ở công ty. Bảng quy tắc trở thành một provider đứng sau một injection token, để service phụ thuộc vào hình dạng của một bộ quy tắc chứ không phụ thuộc vào các con số:
import { Inject, Injectable, Module } from "@nestjs/common";
export type Carrier = "ghn" | "ghtk" | "viettel-post";
export type FeeRule = (weightKg: number) => number;
export type FeeRules = Record<Carrier, FeeRule>;
export const FEE_RULES = Symbol("FEE_RULES");
export const defaultFeeRules: FeeRules = {
ghn: (kg) => 15_000 + kg * 5_000,
ghtk: (kg) => 18_000 + kg * 4_000,
"viettel-post": (kg) => 20_000 + Math.max(0, kg - 2) * 3_500,
};
@Injectable()
export class ShippingFeeService {
constructor(@Inject(FEE_RULES) private readonly rules: FeeRules) {}
quote(carrier: Carrier, weightKg: number): number {
if (!Number.isFinite(weightKg) || weightKg <= 0) throw new RangeError("Weight must be positive");
return this.rules[carrier](weightKg);
}
}
@Module({
providers: [{ provide: FEE_RULES, useValue: defaultFeeRules }, ShippingFeeService],
exports: [ShippingFeeService],
})
export class ShippingModule {}
Ba pattern đã hoạt động trong khoảng ba mươi dòng. ShippingFeeService là singleton vì scope mặc định tạo nó một lần cho cả ứng dụng. FEE_RULES với useValue là bảng Strategy ở trên, giờ thay được mà không cần đụng vào service. Và module là một facade nhỏ: các module khác import ShippingModule và chỉ thấy ShippingFeeService.
Lợi ích hiện ra trong test. Testing module của Nest dựng đúng container mà app của bạn dùng, và cho phép thay bất kỳ provider nào:
import { Test } from "@nestjs/testing";
it("uses whatever rule set the container provides", async () => {
const moduleRef = await Test.createTestingModule({ imports: [ShippingModule] })
.overrideProvider(FEE_RULES)
.useValue({ ghn: () => 1, ghtk: () => 2, "viettel-post": () => 3 })
.compile();
const service = moduleRef.get(ShippingFeeService);
expect(service.quote("ghtk", 10)).toBe(2);
expect(() => service.quote("ghn", 0)).toThrow(RangeError);
});
Test đó không quan tâm giá thật, nên nó sẽ không vỡ khi bộ phận tài chính đổi một con số. Một test riêng, đơn giản, cho defaultFeeRules lo phần con số. Tách "wiring có đúng không?" khỏi "con số có đúng không?" là thói quen đáng xây từ chương 1.
Cách mình dùng điều này khi đọc một codebase mới
Khi vào một codebase Nest, mình đọc ba thứ trước mọi business logic: các import của AppModule, mọi custom provider (provide: với useFactory, useClass hoặc useValue), và mọi enhancer toàn cục đăng ký bằng APP_GUARD, APP_INTERCEPTOR, APP_PIPE hoặc APP_FILTER. Custom provider cho bạn biết team đã ra quyết định ở đâu về việc khởi tạo hoặc thay thế. Enhancer toàn cục cho bạn biết điều gì xảy ra với mọi request trước khi code của bạn chạy. Với một service 50.000 dòng, việc này thường mất chưa tới một giờ và vẽ ra được phần lớn kiến trúc.
Một trade-off mình sẽ nêu khi review code
Injection token khiến việc thay thế trở nên rẻ, và đó chính là lý do chúng bị lạm dụng. Nếu một pull request thêm token, interface và factory provider cho thứ chỉ có một implementation và không bao giờ bị thay trong test, mình sẽ hỏi tác giả họ chờ đợi implementation thứ hai nào và khi nào. Mỗi token là thêm một bước nhảy khi ai đó dùng "go to definition" và rơi vào @Inject(FEE_RULES) thay vì code thật. Một token cho mỗi điểm nối thật (payment provider, đồng hồ, API bên ngoài, bộ quy tắc) là ngân sách hợp lý. Một token cho mọi service là một dự án khó điều hướng hơn cả cái switch mà nó thay thế.
Khi nào không cần bận tâm
Pattern tốn chi phí gián tiếp. Bỏ qua chúng khi:
- Code nhỏ, dùng ở một chỗ và ít khả năng thay đổi. Ba nhánh
iflà ổn. - Bạn đang đoán yêu cầu tương lai. Hãy đưa pattern vào khi biến thể thứ hai hoặc thứ ba thật sự xuất hiện, không phải để đón đầu.
- Ngôn ngữ hoặc framework đã cho bạn pattern đó. Một module JavaScript vốn đã là singleton;
Array.prototype.mapvới callback vốn đã là strategy;EventEmittercủa Node vốn đã là observer.
Bài tập
- Chép bảng ở trên và chấm lại từng pattern cho stack bạn dùng ở công ty. Chọn năm pattern bạn sẽ học trước.
- Với Singleton, viết ra một ví dụ trong đời sống và một ví dụ trong phần mềm trước khi đọc chương 6. Kiểm tra lại câu trả lời sau đó.
- Tìm một câu lệnh
switchtrong codebase bạn đang làm, rẽ nhánh theo type hoặc status. Viết lại nó thành một bảng tra cứu các hàm và xem các chỗ gọi có đơn giản hơn không. - Tìm một pattern mà framework bạn dựa vào (Express, NestJS, React, Prisma) đã dùng sẵn và gọi tên nó.
- Trong một dự án Nest, liệt kê mọi custom provider và mọi enhancer toàn cục
APP_*. Với mỗi cái, viết pattern nó cài đặt bằng một từ, và đánh dấu những cái bạn sẽ xóa vì chỉ có một implementation không bao giờ bị thay.