
اصول SOLID یکی از پرتکرارترین مفاهیمی هستند که در مصاحبههای فنی، کدریویوها و بحثهای معماری شنیده میشوند، اما خیلی از توسعهدهندهها فقط تعریف کتابی آنها را حفظ کردهاند بدون اینکه واقعاً بدانند این پنج قانون در کد روزمره چه شکلی پیدا میکنند. این مقاله دقیقاً همین شکاف را پر میکند: هر اصل را با یک مثال واقعی از کد بد (Violation) و نسخهی اصلاحشدهی آن بررسی میکنیم تا بدانید دقیقاً کِی و چرا باید از هرکدام استفاده کنید.
SOLID مخفف پنج اصل طراحی شیءگرا است که در دههی ۲۰۰۰ توسط رابرت مارتین (Uncle Bob) گردآوری و تثبیت شد:
این اصول مختص هیچ زبان یا فریمورک خاصی نیستند؛ چه در بکاند با NestJS کار کنید، چه در فرانتاند با React و TypeScript، منطق پشت این قوانین یکسان است. در مثالهای این مقاله از TypeScript استفاده شده چون خواناترین شکل ممکن برای نمایش تایپها و قراردادهاست، اما مفهوم هر اصل کاملاً قابل انتقال به زبانهای دیگر (Java، C#، PHP، Python) است.
این اصل میگوید هر کلاس یا ماژول باید فقط یک دلیل برای تغییر کردن داشته باشد. اگر یک کلاس هم منطق دامنه را پیادهسازی میکند، هم مسئول ذخیرهسازی است، هم نوتیفیکیشن میفرستد، یعنی سه دلیل مجزا برای تغییر دارد و همین باعث میشود هر تغییر کوچک، ریسک شکستن بخشهای دیگر را داشته باشد.
کد ناقض این اصل:
class Invoice {
constructor(private items: { price: number; qty: number }[]) {}
calculateTotal(): number {
return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
saveToDatabase(): void {
// اتصال مستقیم به دیتابیس داخل همین کلاس
console.log('Saving invoice to database...');
}
sendEmailNotification(email: string): void {
// منطق ارسال ایمیل هم اینجا قرار گرفته
console.log(`Sending invoice email to ${email}`);
}
}نسخهی اصلاحشده با تفکیک مسئولیتها:
class Invoice {
constructor(private items: { price: number; qty: number }[]) {}
calculateTotal(): number {
return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
}
class InvoiceRepository {
save(invoice: Invoice): void {
console.log('Saving invoice to database...');
}
}
class InvoiceNotifier {
sendEmail(email: string, invoice: Invoice): void {
console.log(`Sending invoice email to ${email}`);
}
}حالا هر کلاس فقط یک وظیفه دارد و تغییر در منطق ایمیل، هیچ تأثیری روی محاسبهی فاکتور یا لایهی ذخیرهسازی نمیگذارد.
یک ماژول باید برای توسعه باز و برای تغییر بسته باشد. یعنی وقتی رفتار جدیدی نیاز دارید، باید بتوانید آن را با افزودن کد جدید اضافه کنید، نه با دستکاری کد قبلی که ممکن است چیزی را بشکند.
کد ناقض این اصل، جایی که برای هر روش پرداخت جدید باید همین تابع را ویرایش کنیم:
class PaymentProcessor {
process(type: 'card' | 'wallet' | 'crypto', amount: number): void {
if (type === 'card') {
console.log(`Charging ${amount} via card`);
} else if (type === 'wallet') {
console.log(`Charging ${amount} via wallet`);
} else if (type === 'crypto') {
console.log(`Charging ${amount} via crypto`);
}
// برای هر روش پرداخت جدید، این تابع باید دوباره تغییر کند
}
}نسخهی اصلاحشده با استفاده از انتزاع (Abstraction) و پلیمورفیسم:
interface PaymentMethod {
charge(amount: number): void;
}
class CardPayment implements PaymentMethod {
charge(amount: number): void {
console.log(`Charging ${amount} via card`);
}
}
class WalletPayment implements PaymentMethod {
charge(amount: number): void {
console.log(`Charging ${amount} via wallet`);
}
}
class CryptoPayment implements PaymentMethod {
charge(amount: number): void {
console.log(`Charging ${amount} via crypto`);
}
}
class PaymentProcessor {
process(method: PaymentMethod, amount: number): void {
method.charge(amount);
}
}
// افزودن روش پرداخت جدید بدون لمس PaymentProcessor:
class BankTransferPayment implements PaymentMethod {
charge(amount: number): void {
console.log(`Charging ${amount} via bank transfer`);
}
}الان برای اضافه کردن یک روش پرداخت جدید، فقط یک کلاس جدید میسازیم؛ کد قبلی دستنخورده باقی میماند و ریسک شکستن رفتار موجود به صفر میرسد.
هر کلاس فرزند باید بتواند جای کلاس والد خودش را بدون بر هم زدن صحت برنامه بگیرد. اگر کدی که با کلاس والد کار میکند، وقتی زیرکلاس جایگزین آن میشود دچار رفتار غیرمنتظره یا خطا شود، این اصل نقض شده است.
مثال کلاسیک نقض این اصل با سلسلهمراتب Rectangle/Square:
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number) { this.width = width; }
setHeight(height: number) { this.height = height; }
getArea(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(width: number) {
this.width = width;
this.height = width; // رفتار غیرمنتظره برای کدی که Rectangle انتظار دارد
}
setHeight(height: number) {
this.width = height;
this.height = height;
}
}
function resizeAndCheck(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(10);
console.assert(rect.getArea() === 50); // برای Square این تست شکست میخورد
}نسخهی اصلاحشده با جدا کردن انتزاع مشترک بهجای وراثت اجباری:
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
getArea(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(private side: number) {}
getArea(): number { return this.side * this.side; }
}
function printArea(shape: Shape) {
console.log(shape.getArea());
}بهجای اینکه Square را زیرکلاس اجباریِ Rectangle کنیم، هر دو را پیادهکنندهی یک اینترفیس مشترک (Shape) قرار دادیم؛ هیچ فرضیات نادرستی دربارهی رفتار مشترک باقی نمیماند.
کلاینتها نباید مجبور شوند به متدهایی وابسته باشند که استفاده نمیکنند. یک اینترفیس بزرگ و همهکاره، کلاسهای پیادهکننده را مجبور میکند متدهایی را پیادهسازی کنند که برایشان بیمعنی است.
کد ناقض این اصل:
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work(): void { console.log('Working...'); }
eat(): void { console.log('Eating lunch...'); }
}
class RobotWorker implements Worker {
work(): void { console.log('Working...'); }
eat(): void {
throw new Error('Robots do not eat!'); // پیادهسازی بیمعنی و اجباری
}
}نسخهی اصلاحشده با شکستن اینترفیس به بخشهای کوچکتر و متمرکز:
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class HumanWorker implements Workable, Eatable {
work(): void { console.log('Working...'); }
eat(): void { console.log('Eating lunch...'); }
}
class RobotWorker implements Workable {
work(): void { console.log('Working...'); }
// نیازی به پیادهسازی eat() نیست
}هر کلاس فقط قراردادهایی را پیادهسازی میکند که به آنها نیاز دارد؛ نه بیشتر.
ماژولهای سطح بالا نباید مستقیماً به ماژولهای سطح پایین وابسته باشند؛ هر دو باید به انتزاع (Interface) وابسته باشند. این اصل پایهی اصلی الگوهایی مثل Dependency Injection در فریمورکهایی مانند NestJS و Angular است.
کد ناقض این اصل، جایی که سرویس سطح بالا مستقیماً به یک پیادهسازی خاص از دیتابیس وابسته است:
class MySQLUserRepository {
save(user: { name: string }): void {
console.log(`Saving ${user.name} to MySQL`);
}
}
class UserService {
private repository = new MySQLUserRepository(); // وابستگی مستقیم به پیادهسازی
createUser(name: string): void {
this.repository.save({ name });
}
}نسخهی اصلاحشده با وارونگی وابستگی از طریق تزریق وابستگی:
interface UserRepository {
save(user: { name: string }): void;
}
class MySQLUserRepository implements UserRepository {
save(user: { name: string }): void {
console.log(`Saving ${user.name} to MySQL`);
}
}
class MongoUserRepository implements UserRepository {
save(user: { name: string }): void {
console.log(`Saving ${user.name} to MongoDB`);
}
}
class UserService {
constructor(private repository: UserRepository) {} // وابستگی به انتزاع
createUser(name: string): void {
this.repository.save({ name });
}
}
// جایگزین کردن پیادهسازی دیتابیس، بدون تغییر UserService:
const service = new UserService(new MongoUserRepository());با این تغییر، UserService دیگر نمیداند دیتابیس زیرین MySQL است یا Mongo؛ فقط با یک قرارداد (Interface) کار میکند و تستنویسی و جایگزینی پیادهسازیها بسیار سادهتر میشود.

اعمال کورکورانهی هر پنج اصل روی یک اسکریپت کوچک یا یک پروژهی MVP، معمولاً بیش از حد پیچیده و زمانبر میشود. SOLID برای کدی طراحی شده که قرار است در طول زمان رشد کند، توسط چند نفر نگهداری شود و دائم در حال تغییر باشد. قانون عملی این است: وقتی یک کلاس یا تابع شروع به بزرگ شدن میکند، وقتی هر تغییر کوچک چند فایل نامرتبط را تحت تأثیر قرار میدهد، یا وقتی نوشتن تست واحد برایش سخت میشود، وقت آن رسیده که این اصول را پیاده کنید.
SOLID یک چکلیست دستوری نیست که باید همیشه و در هر شرایطی کورکورانه دنبال شود، بلکه مجموعهای از راهنماهاست که وقتی کد در حال رشد و پیچیده شدن است، مسیر را برایتان روشن میکنند. شروع خوب این است که با اصل تک مسئولیتی (S) و اصل وارونگی وابستگی (D) کار را آغاز کنید، چون این دو بیشترین تأثیر را در کاهش کوپلینگ و افزایش قابلیت تست کد دارند؛ سه اصل دیگر معمولاً بهطور طبیعی وقتی معماری کد تمیزتر میشود، خودشان را نشان میدهند.