Levelwise
فارسی
طراحی کد

اصول SOLID

پنج اصل ساده که کمک می‌کنند کد شیءگرا با تغییر نشکند. هر کلاس یک دلیل برای تغییر داشته باشد، رفتار جدید با کلاس جدید اضافه شود و بخش‌های مهم به جزئیات وابسته نباشند. هدف، کد ساده‌تر برای تغییر است، نه کلاس‌های بیشتر.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال سفارش و پرداخت در فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: کدی که با هر تغییر می‌شکند

فروشگاه اینترنتی ما یک کلاس OrderService دارد. اول کوچک بود. حالا قیمت را حساب می‌کند، کالا را رزرو می‌کند، پول را می‌گیرد و ایمیل تبلیغاتی می‌فرستد.

هر ماه یک اتفاق تکراری می‌افتد:

  1. تیم بازاریابی متن ایمیل را عوض می‌کند. یک برنامه‌نویس OrderService را باز می‌کند.
  2. یک خط اشتباه در همان فایل، محاسبه قیمت را خراب می‌کند. کسی انتظارش را نداشت.
  3. برای تست ایمیل، باید دیتابیس، درگاه پرداخت و سرور ایمیل را آماده کنی. پس تستی نوشته نمی‌شود.

اصول SOLID پنج قانون ساده برای همین مشکل است. هر حرف یک اصل است:

حرف اسم کامل یک جمله ساده
S Single Responsibility هر کلاس فقط یک دلیل برای تغییر داشته باشد.
O Open/Closed رفتار جدید را با کد جدید اضافه کن، نه با عوض کردن کد قدیمی.
L Liskov Substitution هر پیاده‌سازی باید بتواند بدون غافلگیری جای نوع پایه بنشیند.
I Interface Segregation کسی را مجبور نکن به متدی وابسته باشد که لازم ندارد.
D Dependency Inversion بخش‌های مهم به اینترفیس وابسته باشند، نه به جزئیات.

اصل اول (S): یک دلیل برای تغییر

«یک مسئولیت» یعنی چه؟ «فقط یک کار» تعریف مبهمی است. تعریف بهتر این است: کلاس فقط باید به یک گروه از آدم‌ها جواب بدهد. هر گروه دلیل خودش را برای تغییر دارد.

تیم مالیتیم انباربازاریابیقبل: سه دلیل برای تغییرOrderServiceCalculateTotal()ReserveStock()SendPromoEmail()جدا کنبعد: هر کلاس، یک دلیلPriceCalculatorStockReservationPromoEmailSender
سه تیم، سه دلیل برای تغییر یک فایل. بعد از جدا کردن، هر تیم فقط به کلاس خودش دست می‌زند.

چرا این مهم است؟

  1. تغییر یک تیم، کار تیم دیگر را خراب نمی‌کند. متن ایمیل در کلاسی است که هیچ ربطی به قیمت ندارد.
  2. کلاس‌ها کوچک می‌شوند. هر کدام فقط وابستگی‌های خودش را دارد.
  3. تست ساده می‌شود. برای تست قیمت، فقط به کلاس قیمت نیاز داری.

بعد از جدا کردن، 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;
    }
}
نشانه‌های شکستن این اصل: سازنده کلاس هفت یا هشت وابستگی دارد. اسم کلاس کلمه‌هایی مثل Manager یا Service بدون توضیح دارد. یا برای گفتن کار کلاس، چند بار از کلمه «و» استفاده می‌کنی.

اصل دوم (O): باز برای اضافه شدن، بسته برای تغییر

تیم فروش هر چند هفته یک تخفیف تازه می‌خواهد: تخفیف مشتری ویژه، تخفیف نوروز، تخفیف اولین خرید. اگر همه در یک switch باشند، هر تخفیف جدید یعنی باز کردن و تغییر یک کد تست‌شده.

هر تخفیف جدید، یک بار باز کردن این کلاسPriceCalculatorswitch (discount.Type)case Vip: ...case Nowruz: ...case FirstOrder: ...case ???: ...کد قدیمی و تست‌شده دوباره عوض می‌شودریسک خراب شدن تخفیف‌های قبلیتخفیف جدید، کلاس جدیدPriceCalculatorIDiscountRuleVipDiscountNowruzDiscountFirstOrderDiscountکلاس‌های قبلی دست نمی‌خورندفقط یک کلاس و یک خط ثبت اضافه می‌شود

راه بهتر این است: یک اینترفیس برای «قانون تخفیف» بساز. هر تخفیف یک کلاس جدا است.

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 است که در درس الگوهای طراحی می‌بینی.

از همان روز اول لازم نیست. اگر فقط دو حالت ثابت داری و قرار نیست زیاد شود، یک if ساده کافی است. وقتی دیدی یک جای کد بارها برای یک نوع تغییر باز می‌شود، آن وقت این اصل را به کار ببر.

اصل سوم (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 را صدا می‌زند. با کارت بانکی کار می‌کند. با کارت هدیه، در زمان اجرا خطا می‌دهد. کامپایلر هیچ هشداری نداد.

یک پیاده‌سازی خوب این سه قانون را نگه می‌دارد:

  1. ورودی سخت‌گیرانه‌تر نخواهد. اگر اینترفیس هر سفارشی را قبول می‌کند، پیاده‌سازی نگوید «فقط سفارش بالای یک میلیون».
  2. خروجی ضعیف‌تر ندهد. اگر قول داده همیشه نتیجه برگرداند، گاهی null برنگرداند.
  3. خطای غافلگیرکننده ندهد. خطایی مثل NotSupportedException برای متدی که قول داده شده، نشانه شکستن این اصل است.
نمونه در خود .NET: آرایه در .NET نسخه جنریک اینترفیس IList را پیاده‌سازی می‌کند، ولی متد Add آن خطای NotSupportedException می‌دهد. برای همین، کد باید اول ویژگی IsReadOnly را چک کند. این یک مصالحه قدیمی است، نه الگویی برای کد ما.

اصل چهارم (I): اینترفیس کوچک

مشکل کارت هدیه از کجا آمد؟ از یک اینترفیس بزرگ. اینترفیس همه روش‌های پرداخت را مجبور کرد برگشت پول را هم داشته باشند. اصل چهارم می‌گوید: کسی را مجبور نکن به متدی وابسته باشد که لازم ندارد.

یک اینترفیس بزرگIPaymentMethodPay()Refund()CardPaymentRefund() → OKGiftCardPaymentRefund() → throwکارت هدیه قول اینترفیس را می‌شکند.کدی که برگشت پول را صدا می‌زند، در زمان اجرا خطا می‌گیرد.دو اینترفیس کوچکIPaymentMethodPay()IRefundableRefund()GiftCardPaymentفقط پرداختCardPaymentپرداخت و برگشت پولهر کلاس فقط قولی را می‌دهد که نگه می‌دارد.کامپایلر جلوی برگشت پول کارت هدیه را می‌گیرد.
با جدا کردن اینترفیس، مشکل اصل سوم هم حل شد. حالا کامپایلر اجازه نمی‌دهد برای کارت هدیه برگشت پول صدا زده شود.
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): وارونگی وابستگی

سرویس ثبت سفارش بعد از ثبت، به مشتری خبر می‌دهد. نسخه اول، کلاس ایمیل را مستقیم می‌سازد. حالا اگر بخواهیم پیامک بفرستیم، باید منطق سفارش را عوض کنیم. یعنی بخش مهم (سیاست کسب‌وکار) به بخش کم‌اهمیت (جزئیات فنی) وابسته است.

قبل: سیاست به جزئیات وابسته استPlaceOrderServicenewSmtpEmailSenderعوض کردن ایمیل با پیامک یعنی تغییر سرویس سفارشبعد: هر دو به یک قرارداد وابسته‌اندبخش سفارش (سطح بالا)PlaceOrderServiceIOrderNotifierEmailOrderNotifierجهت وابستگی برعکس شدجزئیات به سیاست وابسته است، نه برعکس

راه حل سه قدم دارد:

  1. بخش سفارش یک اینترفیس تعریف می‌کند. این اینترفیس به زبان سفارش است: «به مشتری خبر بده که سفارش ثبت شد.» کلمه ایمیل در آن نیست.
  2. سرویس سفارش فقط این اینترفیس را می‌شناسد. آن را در سازنده می‌گیرد.
  3. کلاس ایمیل این اینترفیس را پیاده‌سازی می‌کند. حالا جزئیات به سیاست وابسته است.
// 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 کافی است.

وارونگی وابستگی با تزریق وابستگی یکی نیست. تزریق وابستگی (DI) یک روش فنی است: وابستگی از بیرون، از سازنده، داده می‌شود. وارونگی وابستگی یک تصمیم طراحی است: چه کسی صاحب اینترفیس است. اگر اینترفیس کنار کلاس ایمیل باشد و به زبان ایمیل حرف بزند، با اینکه DI داری، وابستگی برعکس نشده است. همین ایده پایه معماری تمیز (Clean Architecture) است.

اصل‌ها با هم کار می‌کنند

این پنج اصل جدا از هم نیستند. در مثال ما:

  • با جدا کردن مسئولیت‌ها (S)، کلاس‌های کوچکی داریم که هر کدام یک کار دارند.
  • با اینترفیس کوچک (I)، هر کلاس فقط قولی را می‌دهد که نگه می‌دارد. پس جایگزینی (L) امن است.
  • با وارونگی وابستگی (D)، کلاس جدید بدون تغییر کد قدیمی وصل می‌شود. پس کد برای اضافه شدن باز است (O).

اشتباه‌های رایج

اشتباه چرا بد است؟ راه درست
یک اینترفیس برای هر کلاس، همیشه فایل‌ها دو برابر می‌شوند، بدون هیچ سودی. اینترفیس وقتی که دو پیاده‌سازی، یا مرز تست واقعی، وجود دارد.
تکه‌تکه کردن تا کلاس‌های یک‌متدی برای فهمیدن یک کار ساده باید ده فایل را باز کنی. جدا کردن بر اساس «دلیل تغییر»، نه تعداد خط.
پیاده‌سازی که خطای NotSupported می‌دهد قول اینترفیس شکسته شده و خطا در زمان اجرا پیدا می‌شود. اینترفیس را کوچک‌تر کن.
اینترفیس به زبان فنی، کنار کد فنی با اینکه DI داری، منطق کسب‌وکار هنوز به جزئیات وابسته است. اینترفیس به زبان کسب‌وکار و مال بخش استفاده‌کننده.
ساختن ساختار برای تغییری که شاید هیچ وقت نیاید پیچیدگی امروز، برای سودی که شاید هرگز نیاید (YAGNI). اول ساده بنویس. وقتی تغییر واقعاً تکرار شد، ساختار بده.

چه وقت سخت‌گیر باشیم؟

ارزشش را دارد

  • منطق کسب‌وکار مهم که مدام تغییر می‌کند، مثل قیمت و تخفیف.
  • جایی که چند پیاده‌سازی واقعی داریم، مثل روش‌های پرداخت.
  • مرز با دنیای بیرون: دیتابیس، ایمیل، درگاه بانک.
  • کدی که چند تیم روی آن کار می‌کنند.

ارزشش را ندارد

  • یک اسکریپت کوچک یا ابزار یک‌باره.
  • صفحه‌های ساده CRUD بدون قانون خاص.
  • کلاسی که فقط یک پیاده‌سازی دارد و هیچ وقت در تست عوض نمی‌شود.
  • وقتی هنوز نمی‌دانی چه چیزی قرار است تغییر کند.
یک سؤال برای هر تصمیم: «این طراحی، تغییر بعدی را ارزان‌تر می‌کند یا فقط کد را بزرگ‌تر؟» اصول SOLID ابزار هستند، نه هدف.

خلاصه در شش خط

  1. هر کلاس فقط به یک گروه از آدم‌ها جواب بدهد و یک دلیل برای تغییر داشته باشد.
  2. رفتار تازه را با یک کلاس تازه اضافه کن، نه با باز کردن کد تست‌شده.
  3. هر پیاده‌سازی قول اینترفیس را کامل نگه دارد. خطای NotSupported نشانه خطر است.
  4. اینترفیس را کوچک و بر اساس نیاز استفاده‌کننده بساز.
  5. منطق مهم صاحب اینترفیس است. جزئیات فنی آن را پیاده‌سازی می‌کنند.
  6. این اصل‌ها را وقتی به کار ببر که تغییر واقعی می‌بینی، نه برای «شاید روزی».