Khi project còn nhỏ, rất nhiều đoạn code “viết nhanh cho chạy” vẫn có thể sống ổn.
Nhưng khi dự án bắt đầu lớn lên, có thêm nhiều module, nhiều flow business và nhiều người cùng sửa, các vấn đề sẽ xuất hiện rất nhanh:
- class quá to
- service ôm quá nhiều việc
- sửa một chỗ vỡ chỗ khác
- thêm tính năng mới phải đụng vào code cũ quá nhiều
- test khó
- dependency chằng chịt
Đây là lúc các nguyên tắc SOLID bắt đầu thực sự có giá trị.
Kết luận nhanh
Nếu cần hiểu ngắn gọn:
SOLIDkhông phải để viết code “đẹp về lý thuyết”, mà để code lớn đỡ rối hơn- giá trị lớn nhất của SOLID là giảm coupling và chia code theo trách nhiệm rõ hơn
- không cần áp dụng máy móc cả 5 nguyên tắc ở mọi chỗ
- nếu vận dụng đúng, SOLID giúp code dễ sửa, dễ test và đỡ phát sinh bug dây chuyền hơn
Vì sao dự án lớn rất dễ bị rối code?
Khi dự án lớn dần, code thường rơi vào các pattern xấu như:
- một class vừa validate, vừa query DB, vừa gọi API ngoài, vừa format response
- business logic nằm rải rác ở controller, service, helper
- code phụ thuộc trực tiếp vào implementation cụ thể
- thêm một case mới phải sửa nhiều file cũ
Ban đầu nhìn vẫn chạy được. Nhưng về lâu dài, giá phải trả rất rõ:
- team onboard khó
- sửa nhanh nhưng dễ gãy
- refactor tốn công
- test mock khó
- bug lan sang các phần khác
SOLID không giải quyết mọi vấn đề kiến trúc, nhưng nó giúp tránh nhiều kiểu rối rất điển hình.
SOLID là gì?
SOLID là 5 nguyên tắc thiết kế hướng đối tượng:
S- Single Responsibility PrincipleO- Open/Closed PrincipleL- Liskov Substitution PrincipleI- Interface Segregation PrincipleD- Dependency Inversion Principle
Điều quan trọng là:
- đừng học SOLID như 5 định nghĩa để đi phỏng vấn
- hãy nhìn nó như 5 cách giảm rối khi code lớn
1. Single Responsibility Principle: một class đừng ôm quá nhiều việc
Đây là nguyên tắc dễ thấy hiệu quả nhất trong project lớn.
Ví dụ code xấu:
export class UserService {
async createUser(data: CreateUserDto) {
if (!data.email.includes("@")) {
throw new Error("Invalid email");
}
const existed = await this.userRepo.findByEmail(data.email);
if (existed) {
throw new Error("Email existed");
}
const user = await this.userRepo.create(data);
await this.mailer.sendWelcomeEmail(user.email);
return {
id: user.id,
email: user.email,
createdAt: user.createdAt,
};
}
}
Một function đang làm quá nhiều việc:
- validate input
- query database
- create user
- gửi email
- format output
Khi logic lớn lên, đoạn này sẽ rất nhanh thành cục rối.
Tách tốt hơn có thể là:
- validator
- repository
- use case / service
- mail service
- presenter / mapper
Ý chính của SRP không phải là tách cho nhiều file bằng mọi giá.
Ý chính là:
- mỗi class hoặc module nên có trách nhiệm tương đối rõ
2. Open/Closed Principle: thêm mới tốt hơn sửa đè code cũ
Ở dự án lớn, một nguồn bug rất phổ biến là:
- thêm tính năng mới bằng cách chèn thêm
if/elsevào service cũ
Ví dụ:
if (paymentMethod === "momo") {
// logic momo
} else if (paymentMethod === "vnpay") {
// logic vnpay
} else if (paymentMethod === "stripe") {
// logic stripe
}
Lúc ban đầu thì ổn. Nhưng càng nhiều case, đoạn code càng khó giữ ổn định.
Áp dụng OCP tốt hơn là:
- định nghĩa một abstraction chung
- mỗi payment provider là một implementation riêng
- thêm provider mới bằng cách thêm class mới thay vì sửa nát service cũ
Giá trị thực tế của nguyên tắc này là:
- giảm nguy cơ làm hỏng flow cũ
- dễ mở rộng hơn
- code review dễ hơn
3. Liskov Substitution Principle: kế thừa mà thay vào lại chạy sai thì rất nguy hiểm
Đây là nguyên tắc nhiều người thấy trừu tượng, nhưng ở code lớn nó rất thực tế.
Nếu một class con không thể dùng thay cho class cha một cách an toàn, thiết kế đang có vấn đề.
Ví dụ xấu:
- abstraction nói rằng mọi notifier đều gửi được message
- nhưng có một implementation thực tế lại throw ở một số case “không ngờ tới”
- caller tưởng dùng cùng contract, nhưng runtime behavior lại khác hẳn
Trong dự án lớn, lỗi kiểu này làm:
- abstraction trở nên vô nghĩa
- code gọi phía trên phải biết quá nhiều chi tiết từng implementation
Muốn tránh, contract phải đủ rõ:
- method này nhận gì
- trả gì
- có guarantee gì
- có trường hợp nào không hỗ trợ không
Nếu implementation không phù hợp contract chung, thường là dấu hiệu:
- abstraction đang sai
- hoặc đang ép kế thừa không đúng chỗ
4. Interface Segregation Principle: đừng bắt code phụ thuộc vào thứ nó không cần
Một interface quá to sẽ tạo ra rất nhiều dependency thừa.
Ví dụ xấu:
interface StorageService {
upload(): Promise<void>;
download(): Promise<void>;
delete(): Promise<void>;
generatePublicUrl(): string;
syncToBackupServer(): Promise<void>;
}
Không phải mọi nơi dùng storage cũng cần đủ toàn bộ các method này.
Nếu một module chỉ cần upload, nó không nên phụ thuộc cả một interface khổng lồ.
Trong dự án lớn, interface to quá thường gây:
- khó mock khi test
- khó thay implementation
- class implement phải gánh nhiều method không liên quan
Áp dụng ISP tốt hơn là chia nhỏ interface theo use case thật.
Ví dụ:
FileUploaderFileDownloaderPublicUrlGenerator
Như vậy nơi nào cần gì thì chỉ phụ thuộc đúng phần đó.
5. Dependency Inversion Principle: code business không nên dính cứng vào implementation
Đây là nguyên tắc cực quan trọng nếu project có:
- database
- queue
- mail provider
- payment provider
- external API
Ví dụ xấu:
export class CreateOrderService {
private mailer = new SendGridMailer();
private payment = new StripePaymentService();
}
Code business đang dính thẳng vào implementation cụ thể.
Hậu quả:
- khó test
- khó đổi provider
- khó mock
- dependency lan rất nhanh
Áp dụng DIP tốt hơn là phụ thuộc vào abstraction:
export class CreateOrderService {
constructor(
private readonly mailer: Mailer,
private readonly paymentService: PaymentService,
) {}
}
Lúc này:
- business logic không cần biết đang dùng SendGrid hay SES
- payment là Stripe hay provider khác
Điều này cực có giá trị trong dự án lớn vì phần thay đổi nhiều nhất thường là implementation, không phải business rule cốt lõi.
SOLID giúp giảm những vấn đề gì trong dự án lớn?
Nếu áp dụng đúng, SOLID thường giúp giảm:
- class quá to và khó đọc
- coupling quá chặt giữa các module
- sửa một nơi vỡ nhiều nơi
- khó test unit
- khó thay provider hoặc dependency ngoài
- khó chia việc cho nhiều dev
- khó mở rộng flow business
Đây mới là giá trị thật của SOLID trong project lớn.
Không nên áp dụng SOLID kiểu giáo điều
Đây là lỗi ngược lại rất hay gặp.
Nghe SOLID xong rồi:
- interface cho mọi thứ
- class nào cũng tách 4-5 lớp
- abstraction quá sớm
- code nhỏ mà kiến trúc như framework
Kết quả:
- code còn khó đọc hơn
- flow bị phân mảnh
- debug mất thời gian
SOLID chỉ có ý nghĩa khi nó giúp code:
- rõ hơn
- ít phụ thuộc cứng hơn
- dễ đổi hơn
Nếu áp dụng mà code rối hơn, tức là đang đi sai hướng.
Nên bắt đầu vận dụng SOLID từ đâu?
Không cần refactor cả hệ thống trong một lần.
Bạn có thể bắt đầu từ 3 điểm thực dụng nhất:
- tách bớt class hoặc service đang ôm quá nhiều việc
- giảm chỗ business logic phụ thuộc trực tiếp vào external implementation
- tránh nhồi thêm
if/elsevô hạn vào những flow đang lớn dần
Chỉ 3 điểm này thôi đã tạo khác biệt lớn ở nhiều codebase.
Một ví dụ mindset rất hữu ích
Trước khi viết hoặc sửa một module quan trọng, tự hỏi:
- module này đang có quá nhiều trách nhiệm không?
- nếu thêm use case mới, mình sẽ phải sửa code cũ hay thêm phần mở rộng mới?
- abstraction hiện tại có thật sự đúng với các implementation không?
- class này có đang phụ thuộc vào thứ nó không thực sự cần không?
- business logic có đang dính chặt vào framework hoặc vendor cụ thể không?
Đó chính là cách dùng SOLID thực tế hơn là học thuộc lòng định nghĩa.
Khi nào SOLID đặc biệt đáng dùng?
SOLID đặc biệt đáng đầu tư khi:
- dự án sẽ còn sống lâu
- có nhiều người cùng sửa
- có nhiều integration ngoài
- business logic đang tăng dần
- cần test ổn định
Nếu project chỉ là script nhỏ hoặc prototype rất ngắn hạn, không phải lúc nào cũng cần áp lực SOLID quá mạnh.
Kết luận
SOLID không làm dự án lớn trở nên đơn giản ngay lập tức. Nhưng nếu vận dụng đúng, nó giúp giảm rất nhiều vấn đề điển hình của codebase lớn:
- code rối
- coupling cao
- khó test
- khó mở rộng
- dễ vỡ khi sửa
Giá trị quan trọng nhất của SOLID không nằm ở 5 chữ cái, mà nằm ở việc:
- chia trách nhiệm rõ hơn
- phụ thuộc ít cứng hơn
- mở rộng dễ hơn
- giữ cho code sống được lâu hơn khi dự án lớn dần
Nếu cần bắt đầu, hãy bắt đầu từ chỗ đau nhất của codebase hiện tại, không cần cố “chuẩn SOLID” toàn bộ ngay từ ngày đầu.