Vượt qua nỗi lo “Code Spaghetti”
Viết code chạy được là phần dễ dàng. Thách thức thực sự bắt đầu khi dự án của bạn đạt tới 10.000 dòng và mỗi thay đổi nhỏ đều khiến bạn cảm thấy như đang chơi trò rút gỗ Jenga. Trong những ngày đầu tiếp cận TypeScript, các file của tôi thường biến thành những “con quái vật” 500 dòng chứa đầy biến toàn cục và các khối if-else lồng nhau. Chuyển sang sử dụng design pattern không chỉ là lựa chọn về phong cách; đó là cách duy nhất để tôi giữ được sự tỉnh táo khi codebase ngày càng phình to.
Design pattern là những bản thiết kế đã được kiểm chứng qua thực tế để giải quyết các vấn đề kiến trúc lặp đi lặp lại. Trong TypeScript, các pattern này thậm chí còn trở nên mạnh mẽ hơn bằng cách sử dụng interface và strict typing để bắt lỗi ngay cả trước khi bạn nhấn lưu. Chúng ta sẽ cùng xem xét ba pattern thiết yếu: Singleton, Factory và Strategy.
So sánh các phương pháp: Lập trình thủ tục vs. Hướng Pattern
Sẽ dễ dàng đánh giá cao các pattern hơn khi bạn thấy được sự hỗn độn mà chúng ngăn chặn. Hãy xem cách chúng ta thường xử lý logic trước khi nâng cấp kiến trúc của mình.
Sự hỗn độn của lập trình thủ tục
Trong mô hình thủ tục, logic chạy theo một đường thẳng. Nếu bạn cần kết nối cơ sở dữ liệu, bạn có thể khởi tạo nó ở cấp toàn cục hoặc truyền nó qua một chuỗi mười hai hàm khác nhau. Khi bạn cần hỗ trợ ba loại thanh toán khác nhau, bạn sẽ kết thúc với một câu lệnh switch khổng lồ bên trong logic nghiệp vụ chính. Cách này có thể ổn với một dự án cá nhân nhỏ cuối tuần, nhưng nó là một cơn ác mộng khi cần unit test hoặc mở rộng khi có nhiều người cùng tham gia đóng góp.
Giải pháp hướng Pattern
Sử dụng pattern giúp thay đổi tư duy của bạn sang thiết kế hướng đối tượng. Thay vì tập trung vào trình tự các bước thô, bạn nghĩ về việc thành phần nào nắm giữ logic và cách chúng giao tiếp với nhau. Logic được gói gọn ở nơi nó thuộc về. Nếu khách hàng yêu cầu thêm phương thức thanh toán ‘Crypto’ mới, bạn chỉ cần thêm một class mới. Bạn không cần phải chạm vào code hiện tại đã được kiểm thử. Sự phân tách này giúp hệ thống của bạn có tính module và linh hoạt.
Sự đánh đổi: Có đáng để viết thêm code không?
Pattern không phải là chiếc đũa thần cho mọi vấn đề. Tôi đã thấy nhiều lập trình viên over-engineer một trang landing page đơn giản thành một mạng lưới factory phức tạp. Đây là thực tế khi sử dụng chúng trong môi trường production.
Lợi ích
- Bảo trì dễ dàng hơn: Bạn có thể sửa lỗi trong logic logging mà không lo làm hỏng quy trình xử lý thanh toán.
- Nắm bắt ngữ cảnh tức thì: Khi một lập trình viên mới nhìn thấy thư mục ‘Factory’, họ ngay lập tức hiểu ý đồ của đoạn code đó mà không cần đọc từng dòng.
- Kiểm thử tin cậy: Vì logic đã được đóng gói, bạn có thể viết các unit test tập trung. Việc mocking một interface duy nhất dễ dàng hơn nhiều so với việc mocking một global state.
Chi phí
- Dài dòng: Bạn có thể phải viết thêm 15-20% code boilerplate ban đầu.
- Đường cong học tập: Các lập trình viên junior trong team có thể cần được hướng dẫn nhanh để hiểu tại sao bạn không chỉ sử dụng một lệnh gọi
new MyClass()đơn giản.
Thiết lập môi trường
Nếu bạn muốn thử nghiệm các pattern này, bạn chỉ cần một thiết lập TypeScript cơ bản. Tôi thường sắp xếp thư mục nguồn theo tên pattern để giữ mọi thứ ngăn nắp.
# Thiết lập nhanh
mkdir ts-patterns && cd ts-patterns
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init
Hãy thử sắp xếp các file của bạn như thế này:
src/
├── singleton/
├── factory/
└── strategy/
└── index.ts
1. Singleton Pattern: Kiểm soát thực thể duy nhất
Singleton đảm quả một class chỉ có duy nhất một thực thể (instance) trong suốt vòng đời của ứng dụng. Đây là lựa chọn hàng đầu để quản lý database pool hoặc các thiết lập cấu hình toàn cục, nơi mà việc có nhiều thực thể sẽ gây lãng phí bộ nhớ hoặc xung đột đồng bộ.
Triển khai
class DatabaseConnection {
private static instance: DatabaseConnection;
private constructor() {
// Constructor private ngăn chặn việc gọi 'new DatabaseConnection()'
console.log("Đang khởi tạo database pool duy nhất...");
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
public query(sql: string) {
console.log(`Đang thực thi: ${sql}`);
}
}
// Cách sử dụng
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true - cả hai biến đều trỏ đến cùng một địa chỉ bộ nhớ
Bằng cách khóa constructor, tôi ngăn các lập trình viên khác vô tình mở năm kết nối cơ sở dữ liệu khác nhau. Điều này giúp việc sử dụng tài nguyên có thể dự đoán được và ngăn rò rỉ kết nối.
2. Factory Pattern: Khởi tạo đối tượng linh hoạt
Factory pattern cung cấp một cách để tạo các đối tượng mà không cần chỉ định chính xác class của đối tượng sẽ được tạo. Tôi sử dụng pattern này thường xuyên nhất khi làm việc với các tích hợp, như các dịch vụ thông báo khác nhau hoặc các cấp độ logging.
Triển khai
interface Logger {
log(message: string): void;
}
class FileLogger implements Logger {
log(message: string) { console.log(`[File] ${message}`); }
}
class ConsoleLogger implements Logger {
log(message: string) { console.log(`[Console] ${message}`); }
}
class LoggerFactory {
public static createLogger(type: 'file' | 'console'): Logger {
if (type === 'file') return new FileLogger();
return new ConsoleLogger();
}
}
// Cách sử dụng
const logger = LoggerFactory.createLogger('file');
logger.log("Người dùng đã đăng nhập");
Thiết lập này tách biệt việc tạo đối tượng ‘như thế nào’ khỏi logic ‘làm cái gì’. Nếu bạn cần chuyển từ logger file cục bộ sang AWS S3 logger, bạn chỉ cần thay đổi một dòng trong factory. Phần còn lại của ứng dụng vẫn được giữ nguyên.
3. Strategy Pattern: Thay đổi logic linh hoạt
Strategy pattern định nghĩa một tập hợp các thuật toán và giúp chúng có thể hoán đổi cho nhau. Đây là pattern yêu thích của tôi để xử lý các hệ thống thanh toán. Nó cho phép ứng dụng chuyển đổi giữa thanh toán PayPal, Stripe hoặc Bitcoin tại thời điểm runtime mà không cần các câu lệnh điều kiện rắc rối.
Triển khai
interface PaymentStrategy {
pay(amount: number): void;
}
class PaypalStrategy implements PaymentStrategy {
pay(amount: number) { console.log(`Đang xử lý $${amount} qua PayPal.`); }
}
class CreditCardStrategy implements PaymentStrategy {
pay(amount: number) { console.log(`Đang tính phí $${amount} vào Thẻ tín dụng.`); }
}
class ShoppingCart {
private strategy: PaymentStrategy;
constructor(strategy: PaymentStrategy) {
this.strategy = strategy;
}
public setStrategy(strategy: PaymentStrategy) {
this.strategy = strategy;
}
public checkout(amount: number) {
this.strategy.pay(amount);
}
}
// Cách sử dụng
const cart = new ShoppingCart(new PaypalStrategy());
cart.checkout(49.99);
// Người dùng đổi ý và muốn sử dụng thẻ
cart.setStrategy(new CreditCardStrategy());
cart.checkout(49.99);
ShoppingCart không cần biết chi tiết nội bộ API của PayPal. Nó chỉ đơn giản tin tưởng vào hợp đồng của hàm pay. Điều này giúp việc thêm phương thức thanh toán thứ mười cũng dễ dàng như thêm phương thức thứ hai.
Lời kết và Thực hành tốt nhất
Pattern là công cụ, không phải quy tắc cứng nhắc. Khi bạn tích hợp chúng vào quy trình làm việc với TypeScript, hãy ghi nhớ ba điểm sau:
- Tránh over-engineering: Nếu một hàm 10 dòng đơn giản có thể giải quyết được vấn đề, đừng xây dựng một Factory cho nó. Chỉ sử dụng pattern khi bạn dự đoán được sự phức tạp hoặc cần khả năng kiểm thử cao.
- Dựa vào Interface: Điểm mạnh lớn nhất của TypeScript là hệ thống kiểu (type system). Luôn định nghĩa interface rõ ràng cho các strategy và factory để đảm bảo code của bạn có tính tự tài liệu (self-documenting).
- Giải thích lý do ‘Tại sao’: Nếu bạn triển khai một pattern phức tạp, hãy để lại một bình luận ngắn gọn. Giải thích lý do tại sao bạn chọn Strategy thay vì một câu lệnh switch đơn giản để đồng đội có thể theo kịp logic của bạn.
Làm chủ ba pattern này sẽ thay đổi cách bạn nhìn nhận về phần mềm. Bạn sẽ ngừng viết các đoạn script rời rạc và bắt đầu xây dựng các hệ thống thực sự mang lại niềm vui khi bảo trì.

