اصول SOLID
پنج اصل ساده که کمک میکنند کد شیءگرا با تغییر نشکند. هر کلاس یک دلیل برای تغییر داشته باشد، رفتار جدید با کلاس جدید اضافه شود و بخشهای مهم به جزئیات وابسته نباشند. هدف، کد سادهتر برای تغییر است، نه کلاسهای بیشتر.
نویسنده: bezzad
مشکل: کدی که با هر تغییر میشکند
فروشگاه اینترنتی ما یک کلاس OrderService دارد. اول کوچک بود. حالا قیمت را حساب میکند، کالا را رزرو میکند، پول را میگیرد و ایمیل تبلیغاتی میفرستد.
هر ماه یک اتفاق تکراری میافتد:
- تیم بازاریابی متن ایمیل را عوض میکند. یک برنامهنویس OrderService را باز میکند.
- یک خط اشتباه در همان فایل، محاسبه قیمت را خراب میکند. کسی انتظارش را نداشت.
- برای تست ایمیل، باید دیتابیس، درگاه پرداخت و سرور ایمیل را آماده کنی. پس تستی نوشته نمیشود.
اصول SOLID پنج قانون ساده برای همین مشکل است. هر حرف یک اصل است:
| حرف | اسم کامل | یک جمله ساده |
|---|---|---|
| S | Single Responsibility | هر کلاس فقط یک دلیل برای تغییر داشته باشد. |
| O | Open/Closed | رفتار جدید را با کد جدید اضافه کن، نه با عوض کردن کد قدیمی. |
| L | Liskov Substitution | هر پیادهسازی باید بتواند بدون غافلگیری جای نوع پایه بنشیند. |
| I | Interface Segregation | کسی را مجبور نکن به متدی وابسته باشد که لازم ندارد. |
| D | Dependency Inversion | بخشهای مهم به اینترفیس وابسته باشند، نه به جزئیات. |
اصل اول (S): یک دلیل برای تغییر
«یک مسئولیت» یعنی چه؟ «فقط یک کار» تعریف مبهمی است. تعریف بهتر این است: کلاس فقط باید به یک گروه از آدمها جواب بدهد. هر گروه دلیل خودش را برای تغییر دارد.
چرا این مهم است؟
- تغییر یک تیم، کار تیم دیگر را خراب نمیکند. متن ایمیل در کلاسی است که هیچ ربطی به قیمت ندارد.
- کلاسها کوچک میشوند. هر کدام فقط وابستگیهای خودش را دارد.
- تست ساده میشود. برای تست قیمت، فقط به کلاس قیمت نیاز داری.
بعد از جدا کردن، OrderService فقط کارها را هماهنگ میکند. خودش قانونی را اجرا نمیکند:
public sealed class PlaceOrderService(
PriceCalculator prices,
IStockReservation stock,
IOrderRepository orders)
{
public async Task<Guid> PlaceAsync(Order order, CancellationToken ct)
{
order.SetTotal(prices.Calculate(order));
await stock.ReserveAsync(order, ct);
await orders.AddAsync(order, ct);
return order.Id;
}
}
اصل دوم (O): باز برای اضافه شدن، بسته برای تغییر
تیم فروش هر چند هفته یک تخفیف تازه میخواهد: تخفیف مشتری ویژه، تخفیف نوروز، تخفیف اولین خرید. اگر همه در یک switch باشند، هر تخفیف جدید یعنی باز کردن و تغییر یک کد تستشده.
راه بهتر این است: یک اینترفیس برای «قانون تخفیف» بساز. هر تخفیف یک کلاس جدا است.
public interface IDiscountRule
{
decimal Apply(Order order, decimal amount);
}
public sealed class VipDiscount : IDiscountRule
{
public decimal Apply(Order order, decimal amount) =>
order.Customer.IsVip ? amount * 0.9m : amount;
}
public sealed class PriceCalculator(IEnumerable<IDiscountRule> rules)
{
public decimal Calculate(Order order) =>
rules.Aggregate(order.Subtotal, (amount, rule) => rule.Apply(order, amount));
}
ثبت در DI:
builder.Services.AddScoped<IDiscountRule, VipDiscount>();
builder.Services.AddScoped<IDiscountRule, NowruzDiscount>();
builder.Services.AddScoped<PriceCalculator>();
حالا تخفیف جدید یعنی یک کلاس جدید و یک خط ثبت. کلاس PriceCalculator هیچ وقت عوض نمیشود. این همان الگوی Strategy است که در درس الگوهای طراحی میبینی.
اصل سوم (L): جایگزینی بدون غافلگیری
هر کلاسی که یک اینترفیس را پیادهسازی میکند، یک قول میدهد. کدی که با اینترفیس کار میکند، به این قول اعتماد دارد. اصل لیسکوف میگوید: هر پیادهسازی باید همان قول را نگه دارد.
فروشگاه ما سه روش پرداخت دارد: کارت بانکی، کیف پول و کارت هدیه. همه یک اینترفیس دارند:
public interface IPaymentMethod
{
Task PayAsync(Order order, CancellationToken ct);
Task RefundAsync(Order order, CancellationToken ct);
}
public sealed class GiftCardPayment : IPaymentMethod
{
public Task PayAsync(Order order, CancellationToken ct) => Task.CompletedTask; // simplified
public Task RefundAsync(Order order, CancellationToken ct) =>
throw new NotSupportedException("Gift cards cannot be refunded.");
}
کد لغو سفارش برای همه روشها RefundAsync را صدا میزند. با کارت بانکی کار میکند. با کارت هدیه، در زمان اجرا خطا میدهد. کامپایلر هیچ هشداری نداد.
یک پیادهسازی خوب این سه قانون را نگه میدارد:
- ورودی سختگیرانهتر نخواهد. اگر اینترفیس هر سفارشی را قبول میکند، پیادهسازی نگوید «فقط سفارش بالای یک میلیون».
- خروجی ضعیفتر ندهد. اگر قول داده همیشه نتیجه برگرداند، گاهی null برنگرداند.
- خطای غافلگیرکننده ندهد. خطایی مثل NotSupportedException برای متدی که قول داده شده، نشانه شکستن این اصل است.
اصل چهارم (I): اینترفیس کوچک
مشکل کارت هدیه از کجا آمد؟ از یک اینترفیس بزرگ. اینترفیس همه روشهای پرداخت را مجبور کرد برگشت پول را هم داشته باشند. اصل چهارم میگوید: کسی را مجبور نکن به متدی وابسته باشد که لازم ندارد.
public interface IPaymentMethod
{
Task PayAsync(Order order, CancellationToken ct);
}
public interface IRefundable
{
Task RefundAsync(Order order, CancellationToken ct);
}
public sealed class CardPayment : IPaymentMethod, IRefundable { /* ... */ }
public sealed class GiftCardPayment : IPaymentMethod { /* ... */ }
کد لغو سفارش حالا فقط با IRefundable کار میکند. اگر روش پرداخت برگشت پول ندارد، این را از همان اول میداند و میتواند مثلاً اعتبار هدیه جدید بدهد.
- نشانه بدی اینترفیس: پیادهسازیهایی که چند متد را خالی میگذارند یا خطا میدهند.
- اندازه درست: اینترفیس را بر اساس نیاز استفادهکننده بساز، نه بر اساس همه کارهایی که کلاس میتواند انجام دهد.
اصل پنجم (D): وارونگی وابستگی
سرویس ثبت سفارش بعد از ثبت، به مشتری خبر میدهد. نسخه اول، کلاس ایمیل را مستقیم میسازد. حالا اگر بخواهیم پیامک بفرستیم، باید منطق سفارش را عوض کنیم. یعنی بخش مهم (سیاست کسبوکار) به بخش کماهمیت (جزئیات فنی) وابسته است.
راه حل سه قدم دارد:
- بخش سفارش یک اینترفیس تعریف میکند. این اینترفیس به زبان سفارش است: «به مشتری خبر بده که سفارش ثبت شد.» کلمه ایمیل در آن نیست.
- سرویس سفارش فقط این اینترفیس را میشناسد. آن را در سازنده میگیرد.
- کلاس ایمیل این اینترفیس را پیادهسازی میکند. حالا جزئیات به سیاست وابسته است.
// Owned by the ordering code. It speaks the business language.
public interface IOrderNotifier
{
Task OrderPlacedAsync(Order order, CancellationToken ct);
}
// Infrastructure detail. It depends on the interface above.
public sealed class EmailOrderNotifier(IEmailClient email) : IOrderNotifier
{
public Task OrderPlacedAsync(Order order, CancellationToken ct) =>
email.SendAsync(order.CustomerEmail, "Your order is placed", ct);
}
builder.Services.AddScoped<IOrderNotifier, EmailOrderNotifier>();
فردا پیامک لازم شد؟ یک کلاس SmsOrderNotifier مینویسی و فقط خط ثبت را عوض میکنی. در تست هم یک نسخه ساختگی از IOrderNotifier کافی است.
اصلها با هم کار میکنند
این پنج اصل جدا از هم نیستند. در مثال ما:
- با جدا کردن مسئولیتها (S)، کلاسهای کوچکی داریم که هر کدام یک کار دارند.
- با اینترفیس کوچک (I)، هر کلاس فقط قولی را میدهد که نگه میدارد. پس جایگزینی (L) امن است.
- با وارونگی وابستگی (D)، کلاس جدید بدون تغییر کد قدیمی وصل میشود. پس کد برای اضافه شدن باز است (O).
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| یک اینترفیس برای هر کلاس، همیشه | فایلها دو برابر میشوند، بدون هیچ سودی. | اینترفیس وقتی که دو پیادهسازی، یا مرز تست واقعی، وجود دارد. |
| تکهتکه کردن تا کلاسهای یکمتدی | برای فهمیدن یک کار ساده باید ده فایل را باز کنی. | جدا کردن بر اساس «دلیل تغییر»، نه تعداد خط. |
| پیادهسازی که خطای NotSupported میدهد | قول اینترفیس شکسته شده و خطا در زمان اجرا پیدا میشود. | اینترفیس را کوچکتر کن. |
| اینترفیس به زبان فنی، کنار کد فنی | با اینکه DI داری، منطق کسبوکار هنوز به جزئیات وابسته است. | اینترفیس به زبان کسبوکار و مال بخش استفادهکننده. |
| ساختن ساختار برای تغییری که شاید هیچ وقت نیاید | پیچیدگی امروز، برای سودی که شاید هرگز نیاید (YAGNI). | اول ساده بنویس. وقتی تغییر واقعاً تکرار شد، ساختار بده. |
چه وقت سختگیر باشیم؟
ارزشش را دارد
- منطق کسبوکار مهم که مدام تغییر میکند، مثل قیمت و تخفیف.
- جایی که چند پیادهسازی واقعی داریم، مثل روشهای پرداخت.
- مرز با دنیای بیرون: دیتابیس، ایمیل، درگاه بانک.
- کدی که چند تیم روی آن کار میکنند.
ارزشش را ندارد
- یک اسکریپت کوچک یا ابزار یکباره.
- صفحههای ساده CRUD بدون قانون خاص.
- کلاسی که فقط یک پیادهسازی دارد و هیچ وقت در تست عوض نمیشود.
- وقتی هنوز نمیدانی چه چیزی قرار است تغییر کند.
خلاصه در شش خط
- هر کلاس فقط به یک گروه از آدمها جواب بدهد و یک دلیل برای تغییر داشته باشد.
- رفتار تازه را با یک کلاس تازه اضافه کن، نه با باز کردن کد تستشده.
- هر پیادهسازی قول اینترفیس را کامل نگه دارد. خطای NotSupported نشانه خطر است.
- اینترفیس را کوچک و بر اساس نیاز استفادهکننده بساز.
- منطق مهم صاحب اینترفیس است. جزئیات فنی آن را پیادهسازی میکنند.
- این اصلها را وقتی به کار ببر که تغییر واقعی میبینی، نه برای «شاید روزی».