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() và 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 và gửi email và 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.