الگوهای طراحی (Design Patterns)
الگوی طراحی یک راه حل شناختهشده برای یک مشکل تکراری است. در این درس چهار الگوی پرکاربرد در .NET را با یک فروشگاه اینترنتی میبینی - Strategy، Decorator، Factory و Mediator. مهمتر از اسم الگو، شناختن مشکلی است که حل میکند.
نویسنده: bezzad
الگو یعنی چه؟
یک برنامهنویس با تجربه، مشکلها را میشناسد. میگوید «این همان مشکل همیشگی است. راه حلش این است.» الگوی طراحی همین تجربه است که یک اسم گرفته است.
اسم مشترک خیلی مفید است. در code review میگویی «اینجا یک Decorator بگذار.» همتیمیات در یک ثانیه میفهمد منظورت چه ساختاری است.
معروفترین فهرست الگوها در کتاب Design Patterns آمده است. چهار نویسنده آن را در سال ۱۹۹۴ نوشتند و به آنها «گروه چهار نفره» (Gang of Four) میگویند. کتاب ۲۳ الگو را در سه دسته آورده است:
| دسته | سؤال اصلی | نمونه در این درس |
|---|---|---|
| ساختن (Creational) | شیء را چه کسی و چطور بسازد؟ | Factory |
| ساختار (Structural) | کلاسها را چطور کنار هم بگذاریم؟ | Decorator |
| رفتار (Behavioral) | کلاسها چطور با هم کار کنند؟ | Strategy، Mediator |
الگوی Strategy: یک کار، چند روش
فروشگاه ما سه درگاه پرداخت دارد: بانک A، بانک B و کیف پول. کد اول این شکل را دارد:
switch (provider)
{
case "bank-a": /* get a token, then pay */ break;
case "bank-b": /* pay in Rials, not Tomans */ break;
case "wallet": /* charge the customer wallet */ break;
}
تیم محصول میگوید چند درگاه دیگر هم در راه است. با این کد چه اتفاقی میافتد؟
- هر درگاه جدید یعنی تغییر همین کلاس. کد تستشده دوباره باز میشود.
- کلاس مدام بزرگتر میشود. هر درگاه یک وابستگی جدید به سازنده اضافه میکند.
- تغییر یک درگاه، درگاه دیگر را خراب میکند. همه در یک فایل هستند.
- همین switch در جاهای دیگر تکرار میشود. مثلاً در برگشت پول و استعلام وضعیت.
الگوی Strategy میگوید: هر روش را در یک کلاس جدا بگذار. همه یک اینترفیس مشترک دارند. استفادهکننده فقط اینترفیس را میشناسد.
public interface IPaymentProvider
{
string Key { get; }
Task<PaymentResult> PayAsync(Order order, CancellationToken ct);
}
public sealed class BankBProvider(BankBClient client) : IPaymentProvider
{
public string Key => "bank-b";
// Bank B works with Rials, so the difference stays inside this class.
public Task<PaymentResult> PayAsync(Order order, CancellationToken ct) =>
client.PayAsync(order.Id, order.AmountInTomans * 10, ct);
}
public sealed class PaymentService(IEnumerable<IPaymentProvider> providers)
{
private readonly Dictionary<string, IPaymentProvider> _byKey =
providers.ToDictionary(p => p.Key);
public Task<PaymentResult> PayAsync(Order order, string key, CancellationToken ct) =>
_byKey.TryGetValue(key, out var provider)
? provider.PayAsync(order, ct)
: throw new NotSupportedException($"Unknown payment provider: {key}");
}
همه درگاهها در DI ثبت میشوند. سرویس پرداخت همه را با هم، به شکل یک لیست، میگیرد:
builder.Services.AddScoped<IPaymentProvider, BankAProvider>();
builder.Services.AddScoped<IPaymentProvider, BankBProvider>();
builder.Services.AddScoped<IPaymentProvider, WalletProvider>();
builder.Services.AddScoped<PaymentService>();
- درگاه جدید: یک کلاس و یک خط ثبت. هیچ کد قبلی عوض نمیشود. این همان اصل باز-بسته در درس SOLID است.
- تست: هر درگاه جدا و فقط با وابستگی خودش تست میشود.
- تفاوتها: تبدیل تومان به ریال فقط داخل کلاس بانک B است.
الگوی Factory: ساختن را به یک جا بسپار
در Strategy، درگاه را با کلید انتخاب کردیم. ولی اگر ساختن یک شیء کمی پیچیده باشد، یا بخواهیم کد بقیه برنامه اصلاً با DI درگیر نشود، از Factory استفاده میکنیم. Factory یک کلاس است که تصمیم میگیرد چه شیئی ساخته شود و آن را برمیگرداند.
از .NET 8 به بعد، DI خودش ثبت با کلید (Keyed Services) را دارد. یک Factory ساده روی آن:
builder.Services.AddKeyedScoped<IPaymentProvider, BankAProvider>("bank-a");
builder.Services.AddKeyedScoped<IPaymentProvider, BankBProvider>("bank-b");
builder.Services.AddScoped<PaymentProviderFactory>();
public sealed class PaymentProviderFactory(IServiceProvider services)
{
public IPaymentProvider Get(string key) =>
services.GetKeyedService<IPaymentProvider>(key)
?? throw new NotSupportedException($"Unknown payment provider: {key}");
}
- بقیه کد فقط با PaymentProviderFactory کار میکند. نمیداند پشت آن DI است یا یک دیکشنری.
- کلید از ورودی کاربر میآید. پس حالت «کلید ناشناخته» را حتماً با یک خطای روشن مدیریت کن.
الگوی Decorator: رفتار اضافه، بدون تغییر کلاس اصلی
کاتالوگ فروشگاه یک IProductRepository دارد که با EF Core کار میکند. حالا دو چیز لازم داریم: کش و لاگ. راه ساده این است که هر دو را داخل همان کلاس بنویسیم. ولی نتیجه این است:
- هر متد سه کار دارد. دسترسی به داده، کش و لاگ. این خلاف اصل تکمسئولیتی است.
- منطق کش در همه متدها تکرار میشود. تغییر کلید کش یعنی تغییر ده متد.
- تست دسترسی به داده بدون کش ممکن نیست.
الگوی Decorator میگوید: یک کلاس جدید بساز که همان اینترفیس را دارد و کلاس اصلی را در خودش نگه میدارد. کار اضافه را انجام میدهد و بعد کار را به کلاس داخلی میدهد.
public sealed class CachedProductRepository(
IProductRepository inner, HybridCache cache) : IProductRepository
{
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"product:{id}",
async token => await inner.GetByIdAsync(id, token),
cancellationToken: ct);
}
کلاس EfProductRepository حالا فقط با دیتابیس کار میکند. کلاس کش فقط کش را میشناسد. لاگ هم یک Decorator جدا است.
ثبت در DI با کتابخانه Scrutor ساده است:
builder.Services.AddHybridCache();
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
builder.Services.Decorate<IProductRepository, LoggingProductRepository>();
در Scrutor، آخرین Decorate بیرونیترین لایه است. پس درخواست اول به لاگ میرسد، بعد کش، بعد EF Core.
ترتیب لایهها مهم است
هر لایه فقط درخواستی را میبیند که به آن برسد. اگر کش جواب بدهد، لایههای داخلی هیچ چیز نمیبینند. در مثال زیر، یک محصول را دو بار درخواست کن. بعد ترتیب را عوض کن و دوباره امتحان کن:
- لاگ بیرون، کش داخل. همه درخواستها لاگ میشوند. زمانی که کاربر واقعاً میبیند را اندازه میگیری.
- کش بیرون، لاگ داخل. فقط درخواستهایی لاگ میشوند که از کش رد شدهاند. برای دیدن بار روی دیتابیس مفید است.
- قاعده کلی. اول تصمیم بگیر هر لایه چه چیزی را باید ببیند. بعد ترتیب را بچین.
الگوی Mediator: فرستنده، گیرنده را نشناسد
کنترلر سفارشها کمکم شش یا هفت سرویس در سازنده گرفته است. هر action فقط به دو تا از آنها نیاز دارد، ولی برای ساختن کنترلر همه ساخته میشوند.
الگوی Mediator یک میانجی وسط میگذارد. کنترلر فقط یک پیام میفرستد: «سفارش را ثبت کن». میانجی پیام را به کلاسی میدهد که مسئول همان کار است. به این کلاس Handler میگوییم.
public sealed record PlaceOrder(Guid CartId) : IRequest<Guid>;
app.MapPost("/orders", async (PlaceOrder command, IMediator mediator, CancellationToken ct) =>
TypedResults.Ok(await mediator.Send(command, ct)));
- سود: هر Handler کوچک است و فقط وابستگیهای خودش را دارد. کارهای مشترک، مثل اعتبارسنجی و لاگ، یک بار دور همه Handler ها نوشته میشوند.
- هزینه: یک لایه غیرمستقیم اضافه میشود. با «رفتن به تعریف» در IDE به Handler نمیرسی. وابستگیها هم پشت IMediator پنهان میشوند.
چند الگوی دیگر که هر روز میبینی
| الگو | مشکل | نمونه در فروشگاه یا .NET |
|---|---|---|
| Adapter | یک کتابخانه بیرونی اینترفیس دیگری دارد. | کلاسی که SDK بانک را به IPaymentProvider ما تبدیل میکند. |
| Observer | چند بخش باید از یک اتفاق باخبر شوند. | رویداد «سفارش ثبت شد» و گیرندههایش. درس DDD را ببین. |
| Builder | ساختن یک شیء چند قدم و تنظیم دارد. | ساختن برنامه با WebApplication و متد CreateBuilder. |
| Singleton | فقط یک نمونه از یک کلاس لازم است. | در .NET به جای کلاس static، با ثبت Singleton در DI. |
اشتباههای رایج
| اشتباه | چرا بد است؟ | راه درست |
|---|---|---|
| الگو برای مشکلی که وجود ندارد | کد سنگین و سختفهم میشود، بدون سود. | اول مشکل را ببین، بعد الگو را انتخاب کن. |
| Strategy برای دو حالت ثابت | چند کلاس و اینترفیس، به جای یک if ساده. | تا وقتی حالتها زیاد نشدهاند، همان if یا switch. |
| کش و لاگ داخل کلاس اصلی | چند مسئولیت در یک کلاس و تکرار در همه متدها. | هر کار اضافه در یک Decorator جدا. |
| ترتیب Decorator ها بدون فکر | لاگ یا retry چیزی را میبیند یا نمیبیند که انتظار نداری. | برای هر لایه بپرس: چه درخواستی باید به من برسد؟ |
| Handler هایی که همدیگر را با Send صدا میزنند | جریان کد گم میشود و دنبال کردنش سخت است. | منطق مشترک را در یک کلاس عادی بگذار، نه در Handler دیگر. |
| ساختن Singleton با کلاس static | تست سخت میشود و وابستگی پنهان است. | ثبت با طول عمر Singleton در DI. |
چه وقت الگو؟
مناسب
- یک تغییر از یک نوع، بارها تکرار شده است.
- کلاس به خاطر چند کار مختلف بزرگ شده است.
- تیم اسم الگو را میشناسد و با آن راحتتر حرف میزند.
- خود .NET همان الگو را دارد و میتوانی از آن استفاده کنی.
نامناسب
- «شاید روزی لازم شود.»
- فقط یک پیادهسازی وجود دارد و قرار نیست بیشتر شود.
- همتیمیها برای فهمیدن یک کار ساده باید پنج فایل را باز کنند.
- الگو فقط برای این است که کد «حرفهای» به نظر برسد.
خلاصه در شش خط
- الگوی طراحی، اسم یک راه حل شناختهشده برای یک مشکل تکراری است.
- با Strategy، هر روش در یک کلاس جدا است و روش جدید کد قبلی را عوض نمیکند.
- با Factory، تصمیم «کدام شیء ساخته شود» یک جا جمع میشود.
- با Decorator، کار اضافه مثل کش و لاگ دور کلاس اصلی پیچیده میشود. ترتیب لایهها مهم است.
- با Mediator، فرستنده گیرنده را نمیشناسد. ولی این الگو جای طراحی خوب را نمیگیرد.
- اول مشکل را ببین. اگر مشکل نیست، الگو هم لازم نیست.