Skip to content

Clean Code & SOLID trong thực tế: Từ Code Review đến Refactoring một Payment System

By Nhân Nguyễn on Apr 28, 2026

Clean Code & SOLID trong thực tế: Từ Code Review đến Refactoring một Payment System

Chào bạn,

Nếu bạn đã từng làm việc với một codebase “legacy”, hẳn bạn không lạ gì cảm giác “chỉ muốn viết lại từ đầu” khi nhìn thấy một method dài 200 dòng, 7 tham số và vô số if-else lồng nhau.

Nhưng hãy tưởng tượng bạn là một Senior Developer trong một ngân hàng. Một junior đồng nghiệp vừa tạo Pull Request với một class PaymentProcessor “thần thánh”. Code chạy đúng, test pass hết, nhưng bạn biết ngay rằng chỉ cần một yêu cầu thay đổi nhỏ từ phòng Kinh doanh (ví dụ: thêm phí cho khách VIP), cả hệ thống sẽ trở thành cơn ác mộng.

Bài viết này sẽ cùng nhau đi qua một tình huống thực tế như vậy, “mổ xẻ” code, định danh Code Smells, phân tích vi phạm SOLID, và cuối cùng là refactoring từng bước để đưa ra một giải pháp production-grade. Đây là những kỹ năng cốt lõi của một lập trình viên senior trong bất kỳ buổi Code Review hay phỏng vấn kỹ thuật nào.


[1] Bài toán thực tế: Khi PR “hoàn hảo” lại là một thảm họa tiềm ẩn

Tình huống

Bạn là senior tại X Bank. Một junior dev đã code xong chức năng xử lý phí chuyển tiền và gửi lên PR. Đây là đoạn code bạn cần review:

public class PaymentProcessor {
    private AccountRepository accountRepo;
    private NotificationService notificationService;

    public TransferResponse processTransfer(
        long fromAccountId,
        long toAccountId,
        double amount,
        String transferType,
        String currency,
        String reason,
        boolean isUrgent
    ) {
        // 200 dòng code thực tế đã được rút gọn cho mục đích minh họa:
        // - Validate tài khoản
        // - Lấy balance từ DB
        // - Tính phí: 0.001 nếu transferType=="DOMESTIC",
        //              0.005 nếu transferType=="INTERNATIONAL"
        // - Kiểm tra số dư >= số tiền + phí
        // - Lấy email để gửi thông báo, Lấy phone để gửi SMS
        // - Cập nhật 2 tài khoản
        // - Log vào database
        // - Trả về response
        // - Chứa các khối if-else lồng 3 cấp, magic numbers, và gọi DB ở khắp nơi.
    }
}

Thoạt nhìn, code hoạt động ổn. Nhưng hãy tưởng tượng bạn phải bảo trì nó 6 tháng sau. Mình sẽ đặt ra những câu hỏi then chốt mà bạn cần trả lời khi Code Review:

  1. Code Smells: Bạn thấy những “mùi code” nào ở đây? Tại sao chúng là vấn đề?
  2. SOLID Violations: Đoạn code này đã giẫm đạp lên những nguyên tắc SOLID nào?
  3. Refactoring Strategy: Bạn sẽ tái cấu trúc nó theo trình tự nào? Bắt đầu từ đâu?
  4. Open/Closed Principle: Nếu ngân hàng muốn thêm loại phí VIP (0.0001 cho khách hàng premium), làm thế nào để bạn làm được điều đó mà không phải sửa PaymentProcessor hiện tại?
  5. Interface Segregation: Nếu có một client khác chỉ cần tính phí mà không cần gửi thông báo, thiết kế hiện tại có vấn đề gì?

[2] “Mùi” và Nguyên tắc: Công cụ sinh tồn của Senior Dev

Trước khi refactoring, chúng ta cần có “kính hiển vi” để soi ra vấn đề. Hãy cùng nhau mổ xẻ.

Phần 1: Code Smells — Những “Red Flag” không thể bỏ qua

1. Long Method (Method quá dài)

  • Vấn đề cốt lõi: Một method 200 dòng là cực kỳ khó để test, khó hiểu và dễ sinh bug. Nó là dấu hiệu rõ ràng nhất của việc vi phạm Single Responsibility Principle.
  • Nguyên tắc: Một function nên làm “một việc và chỉ một việc”. Nếu nó vượt quá 20-30 dòng, đó là lúc bạn cần cảnh giác.
  • Cảm giác khi Code Review: Bạn phải scroll mỏi tay, không thể nắm bắt toàn bộ luồng xử lý trong một lần nhìn.

2. Long Parameter List (Quá nhiều tham số truyền vào)

  • Vấn đề cốt lõi: 7 tham số truyền vào như một “bãi mìn”. Người gọi rất dễ truyền sai thứ tự, và việc thay đổi signature của method sẽ gây ảnh hưởng dây chuyền.
  • Giải pháp sơ bộ: Nhóm chúng thành một Parameter Object (ví dụ: TransferRequest). Điều này không chỉ làm gọn gàng method mà còn giúp việc validate dữ liệu trở nên tự nhiên hơn (validation logic nằm trong chính TransferRequest).

3. Magic Numbers (Những con số “ma thuật”)

  • Vấn đề cốt lõi: 0.0010.005 là gì? Một dev mới sẽ không hiểu. Sáu tháng sau, chính bạn cũng không nhớ. Code không tự giải thích được bản thân, và nếu con số này thay đổi, bạn phải “săn lùng” trong toàn bộ codebase.
  • Giải pháp sơ bộ: Đặt tên hằng số có ý nghĩa. DOMESTIC_FEE_RATE = 0.001 và một comment giải thích “Phí chuyển khoản nội địa 0.1% theo quy định NHNN”.

4. Mixed Concerns (Trộn lẫn các mối quan tâm)

  • Vấn đề cốt lõi: Method của chúng ta đang làm quá nhiều việc: tính toán phí (business logic), cập nhật DB (persistence) và gửi email/SMS (notification). Đây là “tử huyệt” của kiến trúc.
  • Giải pháp sơ bộ: Tách chúng ra thành các class chuyên biệt.

5. Feature Envy (Ghen tị tính năng)

  • Định nghĩa: Một class “ghen tị” khi nó dùng dữ liệu hoặc method của một class khác còn nhiều hơn của chính nó.
  • Ví dụ: PaymentProcessor liên tục gọi account.getEmail(), account.getPhone(), account.getName(). Nó đang “hỏi” Account đủ điều rồi thay mặt Account xử lý mọi thứ.
  • Cảm nhận: Bạn có thấy code như một ông hàng xóm tọc mạch, luôn hỏi han và tự ý làm việc nhà người khác không?
  • Giải pháp sơ bộ: Đưa logic đó trở lại đúng chủ nhân của nó — class Account. Đây là nền tảng của nguyên tắc “Tell, Don’t Ask” (Đừng hỏi, hãy ra lệnh).

6. God Class (Lớp “Chúa tể”)

  • Định nghĩa: Một class làm mọi thứ: payment, notification, logging, validation…
  • Dấu hiệu nhận biết: Có hơn 10 dependency được inject, dài hơn 1000 dòng, và có tới hơn 30 public methods. Khi class này thay đổi, bạn không biết nó sẽ kéo theo những gì.

7. Shotgun Surgery (Phẫu thuật bằng súng săn)

  • Định nghĩa: Một thay đổi logic đơn giản (ví dụ: thêm một loại phí mới) buộc bạn phải sửa hàng loạt file khác nhau: PaymentProcessor, PaymentValidator, PaymentLogger, NotificationService… Vết đạn bắn ra từ một chỗ nhưng găm vào rất nhiều nơi.
  • Giải pháp sơ bộ: Tăng tính Cohesion (gắn kết). Mọi thứ liên quan đến phí nên được đặt trong một module FeePolicy duy nhất.

Phần 2: SOLID Principles — Áp dụng trong ngữ cảnh Ngân hàng

SOLID là bộ 5 nguyên tắc nền tảng giúp giải quyết triệt để các vấn đề trên. Chúng ta sẽ đi sâu vào từng nguyên tắc với chính ví dụ bank transfer.

S — Single Responsibility Principle (SRP)

Mỗi class chỉ nên có một lý do duy nhất để thay đổi.

  • Vi phạm thực tế: PaymentProcessor hiện tại sẽ bị thay đổi nếu:

    1. Logic tính phí thay đổi (do Phòng Tài chính yêu cầu).
    2. Mẫu email thay đổi (do Phòng Marketing yêu cầu).
    3. Cấu trúc DB thay đổi (do Team DBA yêu cầu). -> Một class có tới 3 lý do để thay đổi, vi phạm trầm trọng SRP.
  • Giải pháp đúng: Tách thành FeeCalculator (lý do của Finance), NotificationService (lý do của Marketing), TransactionRepository (lý do của DBA). Mỗi bên thay đổi thế nào cũng không làm ảnh hưởng đến bên kia.

O — Open/Closed Principle (OCP)

Mở để mở rộng, đóng để chỉnh sửa. Bạn phải có khả năng thêm tính năng mới mà không cần sửa code cũ đã chạy ổn định.

  • Vi phạm thực tế (Code cũ):
public double calculateFee(Transaction t) {
    if (t.getType().equals("DOMESTIC")) return t.getAmount() * 0.001;
    if (t.getType().equals("INTERNATIONAL")) return t.getAmount() * 0.005;
    // Để thêm VIP, chúng ta buộc phải MỞ file này ra và SỬA method này -> RỦI RO!
}
  • Giải pháp đúng — Strategy Pattern: Ta sẽ định nghĩa một interface FeePolicy và tạo các implementation cho từng loại phí. Khi cần thêm phí VIP, ta chỉ việc tạo class mới mà không cần đụng vào code cũ.
public interface FeePolicy {
    double calculateFee(double amount);
}

public class VipFeePolicy implements FeePolicy {
    private static final double VIP_RATE = 0.0001;
    public double calculateFee(double amount) {
        return amount * VIP_RATE;
    }
}

L — Liskov Substitution Principle (LSP)

Các đối tượng của class con phải có thể thay thế class cha mà không làm hỏng chương trình.

  • Vi phạm thực tế: Hãy tưởng tượng một SavingsAccount kế thừa Account. Nếu bạn override method debit() và ném ra Exception khi chưa đến cuối tháng, thì code của client dùng Account acc = new SavingsAccount(); acc.debit(100); sẽ đột ngột chết.
  • Giải pháp đúng — Không phá vỡ contract: SavingsAccount vẫn phải cho phép debit(), nhưng thay vì ném lỗi, nó có thể tự động trừ thêm một khoản phí rút trước hạn. Hành vi này không làm hỏng kỳ vọng của client.

I — Interface Segregation Principle (ISP)

Client không nên bị ép phụ thuộc vào những interface mà họ không dùng. Đừng tạo ra các “fat interface” (giao diện mập mạp).

  • Vi phạm thực tế: Nếu ta có một interface PaymentProcessor khổng lồ chứa cả processTransfer(), getTransactionHistory(), và generateMonthlyReport(), thì một Payment Gateway chỉ cần process transfer sẽ vẫn phải implement (hoặc ít nhất là biết đến) các method còn lại.
  • Giải pháp đúng: Tách thành các interface chuyên biệt: PaymentProcessor, TransactionHistoryService, ReportGenerator. Client nào cần gì thì dùng nấy.

D — Dependency Inversion Principle (DIP)

Module cấp cao không nên phụ thuộc vào module cấp thấp. Cả hai nên phụ thuộc vào abstraction.

  • Vi phạm thực tế: PaymentProcessor trực tiếp dùng JpaAccountRepository (một class cụ thể). Nếu muốn chuyển sang MongoDB, ta phải sửa cả PaymentProcessor.
  • Giải pháp đúng: Cả PaymentProcessorJpaAccountRepository đều phụ thuộc vào IAccountRepository (một interface). Điều này giúp viết Unit Test cực kỳ dễ dàng (mock interface), và thay đổi công nghệ không làm sập hệ thống.

[3] Refactoring thực chiến: Lột xác Payment System

Giờ là lúc chúng ta bắt tay vào việc.

Step 1: Extract Parameter Object (Gom tham số)

Giải quyết Long Parameter List bằng cách tạo một Value Object.

public class TransferRequest {
    private final long fromAccountId;
    private final long toAccountId;
    private final double amount;
    // ... các trường khác và getters
    // Constructor đã có thể chứa các validation cơ bản (ví dụ: amount > 0)
}

Step 2: Extract Fee Calculation (Chiến lược tính phí)

Giải quyết Magic Numbers và OCP bằng Strategy Pattern. Chúng ta sẽ có FeePolicy interface và một FeePolicyFactory để chọn chiến lược phù hợp dựa trên loại chuyển tiền và status của khách hàng.

public class FeePolicyFactory {
    public FeePolicy getFeePolicy(String transferType, Account fromAccount) {
        if ("INTERNATIONAL".equals(transferType)) {
            return new InternationalFeePolicy();
        }
        if (fromAccount.isPremium()) {
            return new VipFeePolicy();
        }
        return new DomesticFeePolicy();
    }
}

Step 3 đến 5: Phân tách các mối quan tâm còn lại

Chúng ta sẽ extract ra các class riêng biệt, mỗi class đảm nhiệm một nhiệm vụ (SRP):

  • TransferValidator: Chuyên trách việc validate tài khoản và số dư.
  • TransactionExecutor: Chịu trách nhiệm cho việc debit/credit và lưu vào DB một cách nhất quán (có thể dùng @Transactional ở đây).
  • NotificationDispatcher: Gửi thông báo. Đáng chú ý, ta sẽ thiết kế nó là bất đồng bộ (async)fire-and-forget để việc gửi email không làm chậm tiến trình chuyển tiền chính.

Bức tranh cuối cùng: The Orchestrator

TransferService cuối cùng sẽ cực kỳ gọn gàng và đóng vai trò là người điều phối (Facade pattern). Nhìn vào đây, bạn đọc là hiểu toàn bộ business flow:

public class TransferService {
    // ... constructor injection các dependency

    public TransferResponse transfer(TransferRequest request) {
        // 1. Validate
        ValidationResult validation = validator.validate(request);
        if (!validation.isValid()) {
            return TransferResponse.fail(validation.getErrorMessage());
        }

        // 2. Tính phí
        double fee = feeCalculator.calculateFee(request, fromAccount);

        // 3. Kiểm tra số dư cuối cùng (Tell, Don't Ask)
        if (!fromAccount.canDebit(request.getAmount() + fee)) {
            return TransferResponse.fail("Insufficient balance");
        }

        // 4. Thực thi giao dịch
        Transaction transaction = executor.executeTransfer(fromAccount, toAccount, request, fee);

        // 5. Gửi thông báo (bất đồng bộ)
        notificationDispatcher.dispatchTransferConfirmation(fromAccount, transaction);

        return TransferResponse.success(transaction.getId(), fee);
    }
}

Sự khác biệt là rõ ràng: Dễ đọc, dễ test, dễ mở rộng.


[4] Mặt trái của sự trừu tượng: Trade-offs và Anti-Patterns

SOLID và Clean Code là kim chỉ nam, nhưng không phải là Chân Lý tuyệt đối. Điều làm nên sự khác biệt của một Senior Dev là biết khi nào KHÔNG NÊN áp dụng chúng.

Anti-Pattern 1: Premature Abstraction (Trừu tượng hóa quá sớm)

  • Hiện tượng: Mới chỉ có một loại phí, nhưng bạn đã tạo cả interface FeePolicy, FeeStrategyFactory, FeeCalculationService
  • Hậu quả: 1 dòng code logic bị biến thành 4 file. Hệ thống trở nên phức tạp không cần thiết.
  • Lời khuyên: Hãy đợi cho đến khi có ít nhất implementation thứ hai xuất hiện, rồi hãy extract interface. Nguyên tắc này được gọi là “Rule of Three” trong refactoring.

Anti-Pattern 2: SOLID Over-Engineering

  • Hiện tượng: Áp dụng SOLID một cách mù quáng cho mọi logic, dù là nhỏ nhất.
  • Ví dụ: Cần một hàm tính giảm giá 5%. Thay vì return price * 0.95, bạn lại xây dựng cả một hệ thống Strategy Pattern cho “Discount”. Đây là bệnh “Architecture Astronaut”.
  • Khi nào nên áp dụng: Với hệ thống ngân hàng, nơi logic phí thay đổi liên tục theo vùng, loại khách hàng, quy định NHNN, thì việc “engineer” là xứng đáng.

Anti-Pattern 3: Refactoring Without Tests

  • Hiện tượng: Đây là hành động tự sát chậm. Bạn thay đổi cấu trúc code nhưng không có hệ thống test để đảm bảo hành vi không thay đổi.
  • Ví dụ: Bạn đổi kiểu trả về của method getBalance() từ double sang một object Money. Nếu không có test, bạn sẽ không biết dòng code nào của client đang so sánh == và bị sai.
  • Nguyên tắc sống còn: Phải có Unit Test đầy đủ trước khi đụng vào refactoring.

[5] “Luyện công” cho buổi Phỏng vấn: 3 Cấp độ

Dưới đây là framework để bạn thể hiện tư duy của mình từ cơ bản đến chuyên sâu.

Cấp độ 1: Phát hiện vi phạm SOLID (Identification)

Khi được đưa một đoạn code, hãy chỉ ra được:

  • Tên nguyên tắc bị vi phạm (S, O, L, I, hay D).
  • Lý do cụ thể trong code.
  • Hậu quả (khó test, dễ bug, khó mở rộng).
  • Đề xuất giải pháp sửa nhanh.

Cấp độ 2: Refactor từng bước (Step-by-Step)

Hãy thể hiện bạn có một quy trình chứ không phải “đập đi xây lại”:

  1. Liệt kê tất cả các “trách nhiệm” (responsibilities) của method dài.
  2. Chọn trách nhiệm ít rủi ro nhất và dễ tách nhất để bắt đầu.
  3. ”Extract” nó, chạy ngay bộ test.
  4. Lặp lại cho đến khi method chính chỉ còn là một bản tóm tắt đẹp đẽ.

Cấp độ 3: Tư duy phản biện (When NOT to Apply)

Nhà tuyển dụng muốn thấy sự trưởng thành. Bạn phải nêu được những tình huống nên “phá vỡ” nguyên tắc:

  • Startup giai đoạn đầu: Tốc độ quan trọng hơn kiến trúc. Premature optimization/abstraction giết chết startup.
  • Script dùng một lần: Code để migrate dữ liệu, code sinh report đơn giản không cần quá phức tạp.
  • Sự ổn định tuyệt đối: Một logic không bao giờ thay đổi và được quy định cứng bởi luật.

[6] Kết luận: Checklist để đạt đến sự thành thạo

Clean Code và SOLID không phải là đích đến, chúng là hành trình. Hãy dùng checklist dưới đây như một “cẩm nang” cho mỗi lần bạn Code Review hoặc tự review code của chính mình:

  • S — Single Responsibility: Tôi có thể mô tả class này trong 1 câu không?
  • O — Open/Closed: Để thêm tính năng mới, tôi chỉ cần thêm class mới, hay phải sửa class cũ?
  • L — Liskov Substitution: Class con của tôi có làm hỏng code khi thay thế class cha không?
  • I — Interface Segregation: Client có bị ép phụ thuộc vào những method họ không cần?
  • D — Dependency Inversion: Tôi có phải sửa code Business Logic khi đổi Database từ PostgreSQL sang MongoDB không?

Và trên hết, hãy luôn nhớ nguyên tắc “Tell, Don’t Ask”. Đừng hỏi order.getCustomer().getWallet().getBalance() nữa, hãy ra lệnh order.canBePaid(). Hãy để dữ liệu và hành vi sống cùng nhau trong cùng một class, bạn sẽ thấy thế giới code của mình đẹp đẽ và dễ bảo trì hơn rất nhiều.

Chúc bạn luôn viết ra những dòng code sạch và những kiến trúc vững chắc!

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.