← SOLID Principles

Bài 8 · Nâng cao · 30 phút· Cập nhật 11/06/2026

Dự án: refactor codebase legacy theo nguyên tắc SOLID

Biên soạn bởi Nguyễn Anh Tuấn

Ví dụ thực tế đặt bạn vào tình huống nhận nhiệm vụ refactor codebase legacy theo nguyên tắc SOLID để cải thiện khả năng bảo trì, mở rộng và kiểm thử.

Đề bài: dịch vụ ghi danh khoá học. Bản đầu tiên chạy được, nhưng vi phạm gần như mọi nguyên tắc SOLID:

❌ Trước - God class, gắn chặt mọi thứ

class EnrollmentService {
  enroll(userId: string, courseId: string, card: Card): void {
    // (1) kiem tra hop le
    if (!userId || !courseId) throw new Error("Thieu thong tin");

    // (2) lay gia + thanh toan - gan chat Stripe & DB cu the
    const price = mysql.query("SELECT price FROM courses WHERE id=?", courseId);
    stripe.charge(card, price);

    // (3) luu ghi danh - gan chat MySQL
    mysql.query("INSERT INTO enrollments(user, course) VALUES(?,?)", userId, courseId);

    // (4) gui email - gan chat SMTP
    smtp.send(userId, "Ban da ghi danh thanh cong!");
  }
}
  • SRP: một lớp gánh 4 việc - kiểm tra, thanh toán, lưu trữ, gửi mail.
  • DIP: phụ thuộc thẳng vào mysql/stripe/smtp cụ thể (global) → coupling chặt.
  • OCP: thêm cổng thanh toán (MoMo) hay đổi DB là phải mổ lại lớp này.
  • Testability ≈ 0: gọi enroll() là charge thẻ thật, ghi DB thật, gửi mail thật.

Trước khi sửa, hãy chốt hành vi hiện tại bằng một characterization test để refactor không vô tình đổi hành vi:

Đặc tả hành vi hiện tại (lưới an toàn)

it("ghi danh: charge dung gia roi luu + bao tin", () => {
  // thay the cac dich vu that bang spy de QUAN SAT hanh vi hien tai
  const calls = trackCalls(); // ghi lai charge/insert/send da xay ra
  new EnrollmentService().enroll("u1", "c1", card);
  expect(calls).toEqual(["charge", "insert", "send"]); // chot thu tu hien tai
});

Vì sao test trước?

Refactor nghĩa là đổi cấu trúc, GIỮ hành vi. Không có test, bạn không biết mình có lỡ làm lệch hành vi không. Có lưới an toàn, bạn refactor mạnh dạn - mỗi bước nhỏ chạy test, xanh thì commit.

Chẻ God class thành các lớp nhỏ, mỗi lớp một việc:

Tách theo SRP

class EnrollmentValidator {
  validate(userId: string, courseId: string): void {
    if (!userId || !courseId) throw new Error("Thieu thong tin");
  }
}
class PaymentService    { pay(card: Card, amount: number): void { /* ... */ } }
class EnrollmentRepo    { save(userId: string, courseId: string): void { /* ... */ } }
class EnrollmentMailer  { confirm(userId: string): void { /* ... */ } }
  • Mỗi lớp giờ có một lý do để thay đổi duy nhất.
  • Đã test được từng phần riêng (vd PaymentService) - dù vẫn còn gắn chi tiết.
  • Đây là bước mở đường: tách xong, đưa interface + tiêm phụ thuộc rất tự nhiên.

Đưa interface vào các ranh giới hay đổi, rồi tiêm implementation từ ngoài:

Sau - nghiệp vụ chỉ phụ thuộc abstraction

interface PaymentGateway { charge(card: Card, amount: number): void; } // OCP: them cong = them lop
interface EnrollmentRepository { save(userId: string, courseId: string): void; } // DIP
interface Mailer { confirm(userId: string): void; } // ISP: interface nho
interface PriceLookup { of(courseId: string): number; } // tra cuu gia

class EnrollmentService {
  constructor(
    private validator: EnrollmentValidator,
    private payments: PaymentGateway,        // tiem abstraction
    private repo: EnrollmentRepository,
    private mailer: Mailer,
    private prices: PriceLookup,
  ) {}

  enroll(userId: string, courseId: string, card: Card): void {
    this.validator.validate(userId, courseId);
    this.payments.charge(card, this.prices.of(courseId));
    this.repo.save(userId, courseId);
    this.mailer.confirm(userId);
  }
}

// Composition root - chon implementation o MOT noi
const service = new EnrollmentService(
  new EnrollmentValidator(),
  new StripeGateway(),     // doi sang MoMoGateway -> chi sua o day
  new SqlEnrollmentRepo(),
  new SmtpMailer(),
  new SqlPriceLookup(),
);
  • Đổi Stripe → MoMo, SQL → Mongo, SMTP → SendGrid: chỉ sửa ở composition root.
  • Thêm cổng thanh toán mới = thêm một lớp implement PaymentGateway (OCP).
  • Nghiệp vụ enroll() không còn biết chi tiết nào - sạch và ổn định.

enrollment.test.ts - tiêm fake, không chạm dịch vụ thật

class FakeGateway implements PaymentGateway {
  charged: number[] = [];
  charge(_card: Card, amount: number) { this.charged.push(amount); }
}

it("charge dung gia cua khoa hoc", () => {
  const gw = new FakeGateway();
  const service = new EnrollmentService(
    new EnrollmentValidator(), gw,
    new FakeRepo(), new FakeMailer(),
    { of: () => 499000 },           // PriceLookup gia lap
  );
  service.enroll("u1", "c1", card);
  expect(gw.charged).toEqual([499000]);
});
  • Test chạy nhanh, không charge thẻ/ghi DB/gửi mail thật.
  • Kiểm được từng hành vi (đã charge đúng giá) một cách cô lập.
  • So với ban đầu (không thể test): đây là lợi ích rõ nhất của SOLID.

Checklist hoàn thành

✅ Mỗi lớp một trách nhiệm · ✅ Đổi nhà cung cấp không đụng nghiệp vụ · ✅ Thêm biến thể chỉ cần thêm lớp · ✅ Test được từng phần bằng bản giả. Đạt 4 gạch này là module đã "đủ tốt" - dừng đúng lúc, đừng trừu tượng hoá thêm cho thứ chưa cần.

Câu hỏi thường gặp

Trước tiên VIẾT TEST cho hành vi hiện tại (characterization test) để có "lưới an toàn" - bảo đảm refactor không đổi hành vi. Sau đó đi từng bước nhỏ: tách một trách nhiệm, chạy test, commit; lặp lại. Đừng đập đi xây lại một lần - rủi ro rất cao.

Không. Thường bắt đầu bằng SRP (tách trách nhiệm) vì nó mở đường cho mọi thứ khác. Tách xong, việc đưa interface + tiêm phụ thuộc (DIP) và cho phép mở rộng (OCP) trở nên tự nhiên. Áp tới đâu thấy ĐỦ thì dừng - đừng cố nhồi cho đủ chữ.

Bài O - Open/Closed: mở rộng không sửa code cũ →

Viết test "đặc tả hành vi" (characterization) trước: gọi code hiện tại với vài đầu vào, ghi lại đầu ra thực tế làm kỳ vọng. Test này không phán xét đúng/sai nghiệp vụ - nó chỉ chốt "hành vi hiện tại" để refactor không làm lệch. Khi code đã tách & tiêm được, viết unit test "thật" cho từng phần.

Khi: mỗi lớp một trách nhiệm rõ; đổi nhà cung cấp (DB/thanh toán/mail) không phải sửa nghiệp vụ; test được từng phần bằng bản giả; thêm biến thể mới chỉ cần thêm lớp. Vượt quá mức đó (abstraction cho thứ chỉ-một-bản) là bắt đầu over-engineering.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/4 Điểm: 0

Trước khi refactor một module lớn, việc an toàn nên làm đầu tiên là gì?

Bài tập về nhà

  1. 1

    Liệt kê vi phạm

    Với lớp EnrollmentService "bẩn" ở Bước 1, liệt kê mọi vi phạm SOLID và giải thích ngắn từng cái.

    ✅ Hoàn thành khi: Chỉ ra ≥3 vi phạm (vd SRP, DIP, OCP) đúng chỗ, kèm lý do.

  2. 2

    Lưới an toàn trước

    Viết một characterization test cho hành vi hiện tại của module (của mèo con hoặc ví dụ trong bài) trước khi đụng vào.

    ✅ Hoàn thành khi: Có ít nhất một test chạy xanh mô tả hành vi hiện tại; dùng làm mốc khi refactor.

  3. 3

    Refactor từng bước

    Refactor EnrollmentService: (1) tách trách nhiệm theo SRP; (2) đưa interface + tiêm phụ thuộc theo DIP; (3) cho phép thêm cổng thanh toán theo OCP.

    ✅ Hoàn thành khi: Mỗi bước commit riêng; test luôn xanh; nghiệp vụ không còn new chi tiết cụ thể.

  4. 4

    Dự án của mèo con

    Áp dụng đúng quy trình trên cho một module THẬT trong dự án của mèo con; viết bộ unit test cho các phần đã tách.

    ✅ Hoàn thành khi: Trước/sau rõ ràng; ≥1 phần test bằng bản giả; nêu được lợi ích & đánh đổi.