Software Architecture

SOLID Principle từ A đến Z

Admin · Cập nhật 19/07/2026

Giải thích 5 nguyên lý SOLID trong lập trình hướng đối tượng kèm ví dụ code sai/đúng, giúp viết mã dễ bảo trì, mở rộng và kiểm thử.

Chuyên mục: Software Architecture

Mục lục

  • SOLID là gì?
  • Ý nghĩa của từng nguyên lý
  • Ví dụ trước và sau khi áp dụng
  • Ví dụ thực tế với Next.js + Prisma
  • So sánh trước và sau khi áp dụng
  • Khi nào nên áp dụng
  • Sai lầm thường gặp
  • Checklist
  • Câu hỏi thường gặp

SOLID là gì?

SOLID là tập hợp 5 nguyên lý thiết kế hướng đối tượng giúp mã nguồn dễ bảo trì, mở rộng và kiểm thử.

S - Single Responsibility Principle

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

Sai

class UserService {
  createUser() {}
  sendEmail() {}
  exportExcel() {}
}

Đúng

class UserService {
  createUser() {}
}

class EmailService {
  sendWelcomeEmail() {}
}

class ExportService {
  exportUsers() {}
}

O - Open/Closed Principle

Mở rộng hành vi bằng cách thêm mã mới thay vì sửa mã cũ. Nói cách khác: đóng với sửa đổi, mở với mở rộng. Dấu hiệu vi phạm rõ nhất là một khối if/else hoặc switch cứ phình to mỗi khi có yêu cầu mới.

Sai

Mỗi khi thêm một cổng thanh toán, ta phải sửa lại hàm pay — dễ gây lỗi ở các nhánh cũ:

class PaymentService {
  pay(method: string, amount: number) {
    if (method === "stripe") {
      // gọi Stripe
    } else if (method === "paypal") {
      // gọi PayPal
    } else if (method === "momo") {
      // vừa thêm hôm nay, phải sửa lại class này
    }
  }
}

Đúng

Định nghĩa một abstraction, mỗi cổng là một class riêng. Thêm cổng mới = thêm file mới, không đụng code cũ:

interface PaymentProcessor {
  pay(amount: number): Promise<void>;
}

class StripePayment implements PaymentProcessor {
  async pay(amount: number) { /* gọi Stripe */ }
}

class PaypalPayment implements PaymentProcessor {
  async pay(amount: number) { /* gọi PayPal */ }
}

class MomoPayment implements PaymentProcessor {
  async pay(amount: number) { /* gọi Momo */ }
}

// PaymentService không bao giờ cần sửa nữa
class PaymentService {
  constructor(private processor: PaymentProcessor) {}
  checkout(amount: number) {
    return this.processor.pay(amount);
  }
}

L - Liskov Substitution Principle

Lớp con phải có thể thay thế lớp cha mà không làm thay đổi hành vi mong đợi. Nếu một hàm nhận Bird mà truyền Penguin vào lại làm chương trình vỡ, thì thiết kế kế thừa đã sai.

Sai

Penguin bị ép kế thừa fly() dù không bay được, buộc phải ném lỗi — người dùng lớp cha không lường trước:

class Bird {
  fly(): string {
    return "Đang bay";
  }
}

class Penguin extends Bird {
  fly(): string {
    throw new Error("Chim cánh cụt không biết bay!"); // phá vỡ hợp đồng
  }
}

function letItFly(bird: Bird) {
  console.log(bird.fly()); // nổ tung khi nhận Penguin
}

Đúng

Tách khả năng "bay" thành một hành vi riêng, chỉ loài nào bay được mới có:

interface Bird {
  eat(): void;
}

interface FlyingBird extends Bird {
  fly(): string;
}

class Sparrow implements FlyingBird {
  eat() {}
  fly() { return "Đang bay"; }
}

class Penguin implements Bird {
  eat() {} // không có fly, và đó là điều đúng đắn
}

function letItFly(bird: FlyingBird) {
  console.log(bird.fly()); // chỉ nhận loài thật sự bay được
}

I - Interface Segregation Principle

Không ép client phụ thuộc vào những phương thức mà nó không dùng. Interface càng lớn, class implement càng phải viết nhiều phương thức "cho có".

Sai

RobotWorker bị buộc implement eat()sleep() dù robot không ăn, không ngủ:

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

class RobotWorker implements Worker {
  work() {}
  eat() { throw new Error("Robot không ăn"); }   // thừa
  sleep() { throw new Error("Robot không ngủ"); } // thừa
}

Đúng

Tách interface lớn thành các interface nhỏ theo vai trò, class chỉ implement thứ nó cần:

interface Workable { work(): void; }
interface Eatable  { eat(): void; }
interface Sleepable { sleep(): void; }

class HumanWorker implements Workable, Eatable, Sleepable {
  work() {}
  eat() {}
  sleep() {}
}

class RobotWorker implements Workable {
  work() {} // gọn gàng, không phương thức thừa
}

D - Dependency Inversion Principle

Module cấp cao không phụ thuộc module cấp thấp; cả hai cùng phụ thuộc abstraction. Đây cũng chính là "chất keo" nối SOLID với Clean Architecture.

Sai

Use Case (cấp cao) tự khởi tạo và phụ thuộc trực tiếp vào lớp Prisma (cấp thấp) — không thể test nếu không có database thật:

import { PrismaUserRepository } from "@/infrastructure/prisma-user-repository";

class GetUserUseCase {
  private repo = new PrismaUserRepository(); // dính chặt vào Prisma
  execute(id: string) {
    return this.repo.findById(id);
  }
}

Đúng

Use Case chỉ phụ thuộc interface, còn implementation được "tiêm" từ bên ngoài:

interface UserRepository {
  findById(id: string): Promise<User | null>;
}

class GetUserUseCase {
  constructor(private repo: UserRepository) {} // phụ thuộc abstraction

  execute(id: string) {
    return this.repo.findById(id);
  }
}

// Khi test: truyền một repo giả trong bộ nhớ
const fakeRepo: UserRepository = {
  async findById() {
    return { id: "1", email: "test@demo.com" } as User;
  },
};
const useCase = new GetUserUseCase(fakeRepo);

Lợi ích

  • Dễ mở rộng
  • Dễ test
  • Giảm coupling
  • Tăng khả năng tái sử dụng

Ví dụ thực tế với Next.js + Prisma

Xét một tình huống quen thuộc: đăng ký người dùng rồi gửi email chào mừng. Cách viết "gộp tất cả" dưới đây vi phạm cả S (một hàm làm quá nhiều việc), D (dính chặt Prisma và nhà cung cấp email) và khó test:

// app/api/register/route.ts — cách vi phạm SOLID
export async function POST(req: Request) {
  const { email, password } = await req.json();
  const hashed = await bcrypt.hash(password, 10);
  const user = await prisma.user.create({ data: { email, password: hashed } });
  await resend.emails.send({ to: email, subject: "Chào mừng", html: "..." });
  return Response.json(user);
}

Refactor theo SOLID: tách trách nhiệm và phụ thuộc vào abstraction.

// application/ports.ts
interface UserRepository { create(email: string, hash: string): Promise<User>; }
interface Mailer { sendWelcome(email: string): Promise<void>; }

// application/register-user.ts (Single Responsibility + Dependency Inversion)
class RegisterUserUseCase {
  constructor(
    private users: UserRepository,
    private mailer: Mailer,
  ) {}

  async execute(email: string, password: string): Promise<User> {
    const hash = await bcrypt.hash(password, 10);
    const user = await this.users.create(email, hash);
    await this.mailer.sendWelcome(email);
    return user;
  }
}

// app/api/register/route.ts — Presentation chỉ lắp ráp và gọi
export async function POST(req: Request) {
  const { email, password } = await req.json();
  const useCase = new RegisterUserUseCase(
    new PrismaUserRepository(prisma),
    new ResendMailer(),
  );
  const user = await useCase.execute(email, password);
  return Response.json(user);
}

Giờ đây muốn đổi Resend sang SendGrid (Open/Closed), hay test luồng đăng ký mà không gửi email thật (Dependency Inversion), bạn chỉ cần thay một implementation.

So sánh trước và sau khi áp dụng SOLID

Tiêu chí Trước (vi phạm SOLID) Sau (tuân thủ SOLID)
Trách nhiệm mỗi class Ôm đồm nhiều việc Một việc rõ ràng
Thêm tính năng mới Sửa code cũ, dễ gãy Thêm class mới
Khả năng test Phải mock nhiều thứ Truyền fake qua constructor
Mức độ coupling Cao, dính chặt hạ tầng Thấp, qua interface
Tái sử dụng Khó Dễ
Rủi ro khi sửa Ảnh hưởng dây chuyền Cô lập trong một class

Sai lầm thường gặp

  • Lạm dụng abstraction cho dự án nhỏ. Tạo interface cho mọi class dù chỉ có một implementation duy nhất chỉ làm code rối thêm mà không tăng giá trị.
  • Tạo quá nhiều interface không cần thiết. Interface chỉ nên xuất hiện khi có nhu cầu thay thế/hiện thực hóa nhiều cách hoặc để phục vụ test.
  • Áp dụng máy móc mà không cân nhắc bối cảnh. SOLID là kim chỉ nam, không phải luật cứng; ép đủ 5 nguyên lý vào một script nhỏ là phản tác dụng.
  • Hiểu sai Single Responsibility thành "một class một hàm". SRP nói về một lý do để thay đổi, không phải chia nhỏ đến mức vụn vặt gây khó theo dõi.
  • Kế thừa để tái sử dụng code thay vì để thay thế được (vi phạm Liskov). Nếu lớp con phải ghi đè rồi ném lỗi hoặc để trống, hãy dùng composition thay cho inheritance.
  • Nhầm Dependency Inversion với Dependency Injection. DIP là nguyên lý (phụ thuộc abstraction); DI chỉ là một kỹ thuật để đạt được nó. Có thể làm DI mà vẫn vi phạm DIP nếu tiêm vào một class cụ thể thay vì interface.

Best Practices

  • Kết hợp SOLID với Clean Architecture.
  • Ưu tiên composition hơn inheritance.
  • Áp dụng từng nguyên lý khi thật sự cần.

Checklist

  • Mỗi class có một trách nhiệm chính.
  • Có thể mở rộng mà ít sửa mã cũ.
  • Interface nhỏ, đúng mục đích.
  • Use Case phụ thuộc abstraction.
  • Dễ viết Unit Test.

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

SOLID chỉ dùng cho lập trình hướng đối tượng?

Về nguồn gốc thì đúng, SOLID sinh ra trong bối cảnh OOP. Nhưng tinh thần của nó (tách trách nhiệm, phụ thuộc abstraction, mở rộng không sửa) hoàn toàn áp dụng được cho lập trình hàm và TypeScript hiện đại — ví dụ dùng function nhận callback thay cho interface.

Nên học SOLID trước hay Clean Architecture trước?

Nên nắm SOLID trước, vì Clean Architecture về bản chất là cách tổ chức code ở quy mô lớn dựa trên các nguyên lý SOLID (đặc biệt là Dependency Inversion). Hiểu SOLID rồi thì Clean Architecture trở nên tự nhiên.

Làm sao biết một class đã vi phạm Single Responsibility?

Hãy thử mô tả class bằng một câu. Nếu câu đó chứa chữ "và" (ví dụ "tạo user gửi email xuất Excel"), khả năng cao class đang gánh nhiều trách nhiệm. Một tín hiệu khác: mỗi lần yêu cầu thay đổi ở nhiều lĩnh vực khác nhau đều đụng vào cùng một file.

Áp dụng SOLID có làm dự án chạy chậm hơn không?

Không đáng kể. Chi phí thêm một lớp abstraction (gọi qua interface) hầu như không đo được ở tầng ứng dụng web. Cái "chậm" nếu có chỉ là thời gian viết code ban đầu, đổi lại tốc độ bảo trì về sau nhanh hơn nhiều.

Có nên refactor toàn bộ dự án cũ theo SOLID ngay không?

Không nên làm ồ ạt. Hãy áp dụng theo nguyên tắc Boy Scout — dọn dẹp phần code bạn đang chạm tới. Ưu tiên refactor những module thay đổi thường xuyên và có nhiều lỗi, đó là nơi SOLID mang lại giá trị cao nhất.

Kết luận

SOLID không phải là bộ quy tắc bắt buộc, mà là kim chỉ nam để xây dựng hệ thống có khả năng bảo trì lâu dài. Khi kết hợp với Clean Architecture, CQRS hoặc DDD, SOLID giúp mã nguồn rõ ràng và linh hoạt hơn.