مقالات › معماری نرم‌افزار

اصول SOLID در C# با مثال‌های واقعی

پنج اصل SOLID یعنی SRP، OCP، LSP، ISP و DIP را با مثال‌های کاربردی C# و مقایسه‌ی کد قبل و بعد یاد بگیرید؛ به‌همراه اینکه کجا نباید در اجرای آن‌ها زیاده‌روی کرد.

SOLID مجموعه‌ای از پنج اصل طراحی است که کمک می‌کند کد شیءگرایی بنویسید که فهمیدن، تغییر دادن و تست کردنش آسان باشد. این اصول را Robert C. Martin معروف کرد و با اینکه بیش از بیست سال از عمرشان می‌گذرد، هنوز زبان مشترک هر توسعه‌دهنده‌ی جدی دات‌نت در بازبینی کد هستند. در این مقاله هر اصل را با C# کاربردی بررسی می‌کنیم و صادقانه درباره‌ی اینکه کجا نباید اعمالشان کرد هم صحبت می‌کنیم.

S — اصل تک‌مسئولیتی (Single Responsibility)

هر کلاس فقط باید یک دلیل برای تغییر داشته باشد.

«مسئولیت» یعنی دلیلی برای تغییر — معمولاً مربوط به یک ذی‌نفع یا یک دغدغه. این کلاس را ببینید:

// ❌ سه دلیل برای تغییر: قوانین بیزینس، ذخیره‌سازی، قالب ایمیل
public class InvoiceService
{
    public decimal CalculateTotal(Invoice invoice) =>
        invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) * 1.09m;

    public void Save(Invoice invoice)
    {
        using var connection = new SqlConnection("...");
        // INSERT INTO Invoices ...
    }

    public void SendByEmail(Invoice invoice)
    {
        var body = $"<h1>Invoice #{invoice.Number}</h1>...";
        // SmtpClient...
    }
}

تغییر نرخ مالیات، مهاجرت دیتابیس و قالب جدید ایمیل همگی یک فایل را تغییر می‌دهند. آن‌ها را جدا کنید:

public class InvoiceCalculator
{
    private const decimal TaxRate = 0.09m;
    public decimal Total(Invoice invoice) =>
        invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) * (1 + TaxRate);
}

public interface IInvoiceRepository { Task AddAsync(Invoice invoice, CancellationToken ct); }

public interface IInvoiceMailer { Task SendAsync(Invoice invoice, CancellationToken ct); }

هر کلاس کوچک‌تر و تست‌پذیرتر است و دقیقاً به یک دلیل تغییر می‌کند.

O — اصل باز/بسته (Open/Closed)

اجزای نرم‌افزار باید برای توسعه باز و برای تغییر بسته باشند.

باید بتوانید رفتار جدید اضافه کنید، بدون اینکه به کدی که الان درست کار می‌کند دست بزنید.

// ❌ هر روش ارسال جدید یعنی ویرایش این switch
public decimal ShippingCost(Order order) => order.ShippingMethod switch
{
    "post"    => 50_000,
    "express" => 120_000,
    "pickup"  => 0,
    _ => throw new NotSupportedException()
};

به‌جای آن از چندریختی استفاده کنید:

public interface IShippingStrategy
{
    string Key { get; }
    decimal Cost(Order order);
}

public class PostShipping : IShippingStrategy
{
    public string Key => "post";
    public decimal Cost(Order order) => 50_000;
}

public class ExpressShipping : IShippingStrategy
{
    public string Key => "express";
    public decimal Cost(Order order) => order.Weight > 5 ? 180_000 : 120_000;
}

public class ShippingCalculator(IEnumerable<IShippingStrategy> strategies)
{
    public decimal Cost(Order order) =>
        strategies.Single(s => s.Key == order.ShippingMethod).Cost(order);
}

حالا اضافه کردن یک روش جدید یعنی یک کلاس جدید و یک ثبت در DI. کد قبلی و تست‌شده دست‌نخورده می‌ماند.

L — اصل جایگزینی لیسکوف (Liskov Substitution)

زیرنوع‌ها باید قابل جایگزینی با نوع پایه‌ی خود باشند.

اگر کدی با کلاس پایه درست کار می‌کند، باید با هر کلاس مشتق هم بدون غافلگیری کار کند. نمونه‌ی کلاسیک نقض این اصل:

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area => Width * Height;
}

public class Square : Rectangle
{
    public override int Width  { set { base.Width = base.Height = value; } }
    public override int Height { set { base.Width = base.Height = value; } }
}

void Resize(Rectangle r)
{
    r.Width = 5;
    r.Height = 4;
    Debug.Assert(r.Area == 20); // ❌ برای Square شکست می‌خورد (۱۶)
}

مربع از نظر ریاضی یک مستطیل هست، اما از نظر رفتار نه. نشانه‌های نقض LSP:

  • Overrideهایی که NotImplementedException یا NotSupportedException پرتاب می‌کنند؛
  • کلاس‌های مشتقی که پیش‌شرط‌ها را سخت‌تر می‌کنند («فقط وقتی کار می‌کند که...»)؛
  • بررسی‌هایی مثل if (x is SpecialType) در کدی که باید چندریخت باشد.

راه‌حل معمولاً یک انتزاع بهتر است: یک Shape با Area تعریف کنید و Rectangle و Square را هم‌سطح قرار دهید، یا ترکیب (Composition) را به وراثت ترجیح دهید.

I — اصل تفکیک اینترفیس (Interface Segregation)

کلاینت‌ها نباید مجبور به وابستگی به متدهایی شوند که استفاده نمی‌کنند.

// ❌ یک اینترفیس «چاق»
public interface IRepository<T>
{
    Task<T?> GetAsync(int id);
    Task<List<T>> ListAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(int id);
    Task<byte[]> ExportToExcelAsync();
    Task BulkImportAsync(Stream csv);
}

یک کلاس گزارش‌گیری فقط‌خواندنی حالا به متدهای حذف و Import وابسته است و هر پیاده‌سازی مجبور است همه‌ی آن‌ها را بنویسد. بر اساس توانایی جدا کنید:

public interface IReadRepository<T> { Task<T?> GetAsync(int id); Task<List<T>> ListAsync(); }
public interface IWriteRepository<T> { Task AddAsync(T entity); Task DeleteAsync(int id); }
public interface IExcelExporter<T> { Task<byte[]> ExportAsync(); }

اینترفیس‌های کوچک و متمرکز راحت‌تر پیاده‌سازی، Mock و فهمیده می‌شوند.

D — اصل وارونگی وابستگی (Dependency Inversion)

ماژول‌های سطح بالا نباید به ماژول‌های سطح پایین وابسته باشند؛ هر دو باید به انتزاع وابسته باشند.

// ❌ منطق بیزینس مستقیماً به زیرساخت وابسته است
public class RegistrationService
{
    private readonly SqlUserStore _store = new();
    private readonly KavenegarSmsClient _sms = new("api-key");
}
// ✅ منطق بیزینس مالک انتزاع‌هاست و زیرساخت آن‌ها را پیاده می‌کند
public interface IUserStore { Task AddAsync(User user, CancellationToken ct); }
public interface ISmsSender { Task SendAsync(string phone, string text, CancellationToken ct); }

public class RegistrationService(IUserStore store, ISmsSender sms)
{
    public async Task RegisterAsync(User user, CancellationToken ct)
    {
        await store.AddAsync(user, ct);
        await sms.SendAsync(user.Phone, "Welcome!", ct);
    }
}

در ASP.NET Core، Container تزریق وابستگی پیاده‌سازی‌های واقعی را به این انتزاع‌ها وصل می‌کند. این همان ایده‌ی اصلی Clean Architecture است: لایه‌های Domain و Application اینترفیس‌ها را تعریف می‌کنند و لایه‌ی Infrastructure آن‌ها را پیاده‌سازی می‌کند.

اصول SOLID چطور کنار هم کار می‌کنند

اصل فایده‌ی اصلی
SRP کلاس‌های کوچک و متمرکز با یک دلیل برای تغییر
OCP ویژگی جدید بدون ویرایش کد تست‌شده
LSP چندریختی قابل اعتماد، بدون زیرکلاس غافلگیرکننده
ISP قراردادهای کوچک و Mock آسان
DIP زیرساخت قابل تعویض و منطق بیزینس تست‌پذیر

کجا در SOLID زیاده‌روی نکنیم

SOLID ابزار است، نه هدف. زیاده‌روی در آن ده‌ها اینترفیس تک‌متدی، انتزاع‌هایی با فقط یک پیاده‌سازی «محض احتیاط» و کدی تولید می‌کند که پیمایش آن از خود مسئله سخت‌تر است.

راهنمای عملی:

  • وقتی انتزاع بسازید که دلیل واقعی دارید: پیاده‌سازی دوم، نیاز تست یا یک سیستم خارجی.
  • یک کلاس ۴۰ خطی را از روز اول به پنج فایل تقسیم نکنید؛ وقتی چند دلیل برای تغییر پیدا کرد، Refactor کنید.
  • کد خوانا را به خلوص کتابی ترجیح دهید. هدف کدی است که تغییر دادنش ارزان باشد.

جمع‌بندی

SOLID کمک می‌کند کلاس‌هایی طراحی کنید که متمرکز (SRP)، قابل توسعه (OCP)، با چندریختی امن (LSP)، با قراردادهای کوچک (ISP) و جدا از زیرساخت (DIP) باشند. اگر با قضاوت درست به کار بروند، این اصول کد دات‌نت را تست‌پذیرتر و قابل تکامل‌تر می‌کنند؛ و هدف نهایی طراحی نرم‌افزار همین است.

پرسش‌های متداول

آیا SOLID فقط برای زبان‌های شیءگراست؟

با اصطلاحات OOP تعریف شده، اما ایده‌هایش — هدف واحد، نقاط توسعه، قراردادهای کوچک و وابستگی به انتزاع — در کدهای تابعی و ماژولار هم کاربرد دارند.

آیا هر کلاس باید اینترفیس داشته باشد؟

نه. برای مرزها اینترفیس بسازید: زیرساخت، سرویس‌های خارجی و جاهایی که در تست به جایگزینی نیاز دارید. کلاس‌های ساده‌ی دامنه و DTOها معمولاً به اینترفیس نیازی ندارند.

در عمل کدام اصل مهم‌تر است؟

SRP و DIP معمولاً بیشترین بازده را دارند: کلاس‌های کوچک‌تر و منطق بیزینسی که به دیتابیس یا فریم‌ورک وابسته نیست.