Code bớt "tham tham", đời bớt khổ: Sức mạnh của Single Responsibility dưới góc nhìn thực chiến

Trong suốt những năm tháng làm việc với đủ loại dự án—từ những hệ thống monolith cổ điển, microservices phức tạp cho đến các app mobile—mình nhận ra một sự thật cay đắng: Đại đa số bug chí mạng trên Production không đến từ việc chúng ta dùng thuật toán quá dở, mà đến từ việc chúng ta gom quá nhiều thứ vào một chỗ.
Chúng ta thường hay bị cuốn vào cái bẫy mang tên "tiện tay".
“Tiện tay đang ở trong hàm xử lý logic, hook thêm vài dòng ghi log, bắn luôn một cái event sang bên third-party cho nhanh.”
Kết quả là gì? Hệ thống chạy mượt mà trong... 2 tuần đầu. Đến tháng thứ 3, khi business scale lên, các yêu cầu thay đổi dồn dập tới, đoạn code "tiện tay" ngày nào chính thức biến thành một Quái vật Frankenstein (God Object). Sửa chỗ này thì sập chỗ kia, không ai trong team dám động vào vì sợ "rút dây động rừng".
Hôm nay, mình muốn ngồi lại với anh em để mổ xẻ về nguyên lý cơ bản nhất, nhưng lại là thứ phân định rõ ràng nhất giữa một người "viết code chạy được" và một "Software Engineer thực thụ": Single Responsibility Principle (SRP) – Nguyên lý Đơn Nhiệm.
1. SRP dưới góc nhìn thực dụng: Đừng nhìn vào định nghĩa sách vở
Nếu anh em lên mạng gõ SRP, sách giáo khoa sẽ bảo: "Một class/module chỉ nên có một lý do duy nhất để thay đổi".
Nghe thì hàn lâm, nhưng khi làm hệ thống lớn, mình định nghĩa nó thực dụng hơn: "Một hàm hoặc một class chỉ nên chịu trách nhiệm cho đúng một ranh giới business (Context/Boundary) và phục vụ cho một đối tượng duy nhất (Actor)."
Hãy tưởng tượng một hệ thống giống như một nhà hàng:
Ông đầu bếp (Developer viết core logic) thì chỉ lo nấu ăn sao cho ngon, nhanh.
Bạn thu ngân (Database/Accounting) thì chỉ lo tính tiền cho chuẩn.
Nếu ông đầu bếp vừa đứng xào phở, vừa chạy ra ngoài quẹt thẻ tính tiền cho khách, rồi tiện tay cầm chổi đi lau sàn. Khi quán đông lên (System High Load), hệ thống chắc chắn gãy. Phở cháy, tiền thu lộn, khách bỏ về.
Trong code cũng chính xác là như vậy.
2. Bản phác thảo của một "Legacy Code tương lai"
Hãy nhìn thẳng vào một đoạn code xử lý đơn hàng điển hình mà mình dám cá là anh em đã gặp không dưới 3 lần trong đời.
TypeScript
// CẢNH BÁO: Đây là tấm vé một chiều đưa bạn đến những đêm On-call không ngủ!
async function processOrder(order: any) {
// 1. Business Logic Validation
if (!order.items || order.items.length === 0) {
throw new Error("Invalid order items");
}
// 2. Pricing & Tax Calculation (Core Logic)
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
}
if (order.vipCode) {
total = total * 0.85; // Giảm 15% cho VIP
}
// 3. Data Persistence (Database Interaction)
const db = await getDatabaseConnection();
await db.query("INSERT INTO orders ... VALUES ...", [order.id, total]);
// 4. Side Effects / Notification (External Integration)
const emailClient = new EmailClient({ provider: "SendGrid" });
await emailClient.send(order.email, "Order Confirmed", `Total: ${total}`);
return { success: true, amount: total };
}
Tại sao dưới góc nhìn Senior, hàm này là một "bãi mìn"?
Khủng hoảng bảo trì (Maintainability Disaster): Hôm nay, team Business bảo: "Chúng ta bỏ SendGrid, chuyển sang dùng AWS SES để tối ưu chi phí". Bạn mở hàm này ra sửa. Ngày mai, bên Kế toán bảo: "Thay đổi công thức tính thuế VAT cho mặt hàng này". Bạn lại mở đúng cái hàm này ra sửa. Một hàm gánh vác quá nhiều quyền lợi của các bên khác nhau thì tỷ lệ sinh bug lan truyền (Regression Bug) là cực kỳ cao.
Nghẹt thở khi viết Unit Test: Để test xem cái logic giảm giá 15% cho VIP có chạy đúng hay không, bạn buộc phải thiết lập (mock) cả kết nối Database lẫn Email Client. Việc viết test tốn thời gian gấp 5 lần viết code core. Thường thì kết quả là team sẽ... bỏ luôn không viết test nữa.
Không có khả năng tái sử dụng (Zero Reusability): Nếu ở một màn hình khác (ví dụ: Checkout Preview), người dùng chỉ muốn bấm xem trước tổng tiền mà chưa mua. Bạn không thể gọi hàm này được, vì nó tự động insert vào DB và bắn email luôn rồi. Kết quả? Bạn lại copy đoạn logic tính tiền ra một chỗ khác. Codebase bắt đầu phình to và rác.
3. Cách Senior "Refactor": Chia để trị và Trả lại sự đơn nhiệm
Để giải quyết bài toán này, chúng ta cần bóc tách các tầng trách nhiệm (Layers of Responsibility). Chúng ta biến hàm chính thành một Nhạc trưởng (Orchestrator), chỉ làm nhiệm vụ điều phối luồng đi (Workflow), còn công việc chuyên môn sẽ đẩy về cho các Chuyên gia (Services/Repositories).
TypeScript
// 1. Domain Service: Chỉ lo việc tính toán tiền bạc (Pure Logic, No Side Effects)
class PricingEngine {
public calculateTotal(items: OrderItem[], vipCode?: string): number {
let total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
if (vipCode) {
total = total * 0.85;
}
return total;
}
}
// 2. Data Access Layer: Chỉ lo giao tiếp với Database
class OrderRepository {
public async save(orderId: string, totalAmount: number): Promise<void> {
const db = await getDatabaseConnection();
await db.query("INSERT INTO orders ...", [orderId, totalAmount]);
}
}
// 3. Infrastructure Service: Chỉ lo giao tiếp với bên thứ ba (Email Server)
class NotificationService {
public async sendOrderConfirmation(email: string, amount: number): Promise<void> {
const emailClient = new EmailClient({ provider: "AWS_SES" }); // Giả sử đã switch sang AWS
await emailClient.send(email, "Order Confirmed", `Total: ${amount}`);
}
}
Và đây là cách hàm Core Workflow của chúng ta vận hành sau khi được dọn dẹp sạch sẽ:
TypeScript
// Sạch sẽ, tường minh, chuẩn tinh thần Enterprise
class OrderApplicationService {
constructor(
private pricingEngine: PricingEngine,
private orderRepo: OrderRepository,
private notificationService: NotificationService
) {}
public async execute(order: any): Promise<boolean> {
// 1. Validation cơ bản ngay tại đầu vào
if (!order.items || order.items.length === 0) return false;
// 2. Ủy quyền cho các chuyên gia xử lý công việc của họ
const totalAmount = this.pricingEngine.calculateTotal(order.items, order.vipCode);
await this.orderRepo.save(order.id, totalAmount);
await this.notificationService.sendOrderConfirmation(order.email, totalAmount);
return true;
}
}
4. Cái giá của sự gọn gàng: Chúng ta được và mất gì?
Mình luôn nói với team của mình: Trong kiến trúc phần mềm, không có viên đạn bạc (No silver bullet). Mọi lựa chọn đều là Trade-off (Sự đánh đổi).
Cái giá phải trả (The Cost): Anh em sẽ thấy số lượng file tăng lên. Thay vì 1 file, giờ chúng ta có 4 files. Thay vì đọc từ trên xuống dưới một mạch, giờ chúng ta phải nhảy qua các class khác nhau để tracking. Nhìn qua thì có vẻ "over-engineering" đối với một dự án nhỏ.
Giá trị nhận lại (The ROI): Nhưng khi dự án phình to lên 50, 100 tính năng, cách tổ chức này chính là thứ giữ cho dự án không bị sụp đổ.
Việc viết Unit Test cho
PricingEnginegiờ đây dễ như ăn kẹo, chạy trong 1 mili-giây vì nó là một hàm thuần túy (Pure Function), không dính dáng đến I/O (DB, Network).Khi cần thay đổi hạ tầng (đổi DB, đổi Email Provider), rủi ro làm ảnh hưởng đến Core Business Logic bằng 0.

THẢO LUẬN
0 BÌNH LUẬN
Hãy là người đầu tiên bình luận.