مقالات › ASP.NET Core

تزریق وابستگی در ASP.NET Core؛ تفاوت Transient، Scoped و Singleton

Dependency Injection در ASP.NET Core را عمیق بفهمید: طول عمر سرویس‌ها، باگ Captive Dependency، Keyed Services، الگوی Options و نقش DI در تست‌پذیری.

تزریق وابستگی (Dependency Injection یا DI) در قلب ASP.NET Core قرار دارد. کنترلرها، میان‌افزارها، سرویس‌های پس‌زمینه و حتی خود فریم‌ورک وابستگی‌هایشان را از Container داخلی دریافت می‌کنند. فهمیدن نحوه‌ی کار آن — مخصوصاً طول عمر سرویس‌ها — جلوی برخی از بدترین باگ‌های برنامه‌های دات‌نت را می‌گیرد.

DI چه مشکلی را حل می‌کند؟

بدون DI، کلاس‌ها وابستگی‌هایشان را خودشان می‌سازند:

public class OrderService
{
    private readonly EmailSender _email = new EmailSender("smtp.example.com");

    public void PlaceOrder(Order order)
    {
        // ...
        _email.Send(order.CustomerEmail, "Order confirmed");
    }
}

این کد وابستگی شدید (Tight Coupling) دارد: نمی‌توانید ارسال‌کننده‌ی ایمیل را عوض کنید، نمی‌توانید PlaceOrder را بدون ارسال ایمیل واقعی تست کنید و تنظیمات هم داخل کد ثابت شده است.

با DI، کلاس فقط اعلام می‌کند به چه چیزی نیاز دارد و کس دیگری آن را فراهم می‌کند:

public interface IEmailSender
{
    Task SendAsync(string to, string subject, CancellationToken ct = default);
}

public class OrderService(IEmailSender email, ILogger<OrderService> logger)
{
    public async Task PlaceOrderAsync(Order order, CancellationToken ct)
    {
        // ...
        await email.SendAsync(order.CustomerEmail, "Order confirmed", ct);
        logger.LogInformation("Order {OrderId} placed", order.Id);
    }
}

این همان اصل وارونگی وابستگی (Dependency Inversion) است: کد سطح بالا به انتزاع وابسته است، نه به پیاده‌سازی مشخص.

ثبت سرویس‌ها

سرویس‌ها در Program.cs ثبت می‌شوند:

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();

ASP.NET Core سپس آن‌ها را هر جا درخواست شوند تزریق می‌کند: سازنده‌ی کنترلرها، صفحات Razor، میان‌افزارها، سرویس‌های پس‌زمینه یا Handlerهای Minimal API.

app.MapPost("/orders", async (CreateOrder cmd, IOrderService orders, CancellationToken ct) =>
    Results.Ok(await orders.CreateAsync(cmd, ct)));

سه طول عمر سرویس

Transient

هر بار که سرویس درخواست شود، یک نمونه‌ی جدید ساخته می‌شود.

  • مناسب سرویس‌های سبک و بدون State.
  • اگر در یک درخواست سه بار درخواست شود، سه نمونه ساخته می‌شود.

Scoped

یک نمونه برای هر Scope — در ASP.NET Core یعنی یک نمونه برای هر درخواست HTTP.

  • مناسب هر چیزی که State مخصوص درخواست دارد و مهم‌تر از همه DbContext.
  • همه‌ی کلاس‌های داخل یک درخواست نمونه‌ی مشترک دارند و در نتیجه Unit of Work مشترکی خواهند داشت.

Singleton

یک نمونه برای کل عمر برنامه.

  • مناسب سرویس‌های بدون State یا Thread-Safe که ساختنشان پرهزینه است: کش، تنظیمات، Handlerهای HttpClient، ساعت سیستم.
  • باید Thread-Safe باشد، چون همه‌ی درخواست‌ها هم‌زمان از آن استفاده می‌کنند.
طول عمر تعداد نمونه مثال‌های رایج
Transient هر بار درخواست Validatorها، Mapperها، کلاس‌های کمکی کوچک
Scoped هر درخواست DbContext، Repositoryها، Unit of Work، سرویس کاربر فعلی
Singleton کل برنامه IMemoryCache، تنظیمات، IHttpClientFactory، رجیستری‌های داخل حافظه

باگ Captive Dependency

یک سرویس هرگز نباید به سرویسی با طول عمر کوتاه‌تر وابسته باشد. اشتباه کلاسیک:

builder.Services.AddSingleton<ReportCache>();       // تا ابد زنده است
builder.Services.AddScoped<AppDbContext>();          // باید فقط یک درخواست زنده بماند

public class ReportCache(AppDbContext db) { ... }    // ❌ وابستگی اسیر

Singleton اولین DbContextی را که دریافت کند برای همیشه نگه می‌دارد. آن Context بین همه‌ی درخواست‌ها و Threadها مشترک می‌شود؛ نتیجه: خطاهای هم‌زمانی، داده‌ی کهنه و رشد مصرف حافظه.

ASP.NET Core در محیط توسعه با Scope Validation این مشکل را تشخیص می‌دهد و هنگام شروع برنامه خطای «Cannot consume scoped service from singleton» می‌دهد. آن را غیرفعال نکنید.

اگر یک Singleton واقعاً به سرویس Scoped نیاز دارد، Scope را صریح بسازید:

public class ReportCache(IServiceScopeFactory scopeFactory)
{
    public async Task RefreshAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // db فقط داخل همین Scope استفاده شود
    }
}

این دقیقاً همان الگویی است که داخل BackgroundService استفاده می‌شود.

روش‌های دیگر ثبت سرویس

// ثبت یک کلاس بدون اینترفیس
builder.Services.AddScoped<InvoiceGenerator>();

// ثبت با Factory و دسترسی به سرویس‌های دیگر
builder.Services.AddSingleton<IStorage>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new BlobStorage(config["Storage:ConnectionString"]!);
});

// اگر قبلاً ثبت شده، دوباره ثبت نکن (مناسب کتابخانه‌ها)
builder.Services.TryAddSingleton<IClock, SystemClock>();

// چند پیاده‌سازی → تزریق IEnumerable<T>
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>();

public class Alerts(IEnumerable<INotifier> notifiers)
{
    public Task NotifyAllAsync(string msg) =>
        Task.WhenAll(notifiers.Select(n => n.NotifyAsync(msg)));
}

سرویس‌های کلیددار (Keyed Services) از ‎.NET 8

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

builder.Services.AddKeyedSingleton<IPaymentGateway, ZarinpalGateway>("zarinpal");
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");

public class CheckoutService([FromKeyedServices("zarinpal")] IPaymentGateway gateway) { }

مرتب کردن ثبت‌ها با Extension Method

با بزرگ شدن پروژه، Program.cs شلوغ می‌شود. ثبت‌ها را بر اساس لایه گروه‌بندی کنید:

public static class InfrastructureServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(o => o.UseSqlServer(config.GetConnectionString("Default")));
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddTransient<IEmailSender, SmtpEmailSender>();
        return services;
    }
}

// Program.cs
builder.Services.AddApplication().AddInfrastructure(builder.Configuration);

DI و الگوی Options

به‌جای رشته‌های IConfiguration، تنظیمات Strongly-Typed تزریق کنید:

public class SmtpOptions
{
    public string Host { get; set; } = "";
    public int Port { get; set; } = 587;
}

builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));

public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender { ... }

ضدالگوها (Anti-patterns)

  1. Service Locator — تزریق IServiceProvider در همه‌جا و صدا زدن GetService وابستگی‌ها را پنهان می‌کند و هدف DI را از بین می‌برد. فقط در Factoryها و کدهای زیرساختی از آن استفاده کنید.
  2. سازنده‌ی پرازدحام — سازنده‌ای با ده وابستگی نشانه‌ی این است که کلاس کارهای زیادی انجام می‌دهد. آن را تقسیم کنید.
  3. Dispose کردن دستی سرویس‌های تزریق‌شده — Container مالک اشیایی است که می‌سازد و در پایان طول عمرشان آن‌ها را Dispose می‌کند.
  4. State استاتیک به‌جای Singleton — فیلدهای Static در تست قابل جایگزینی نیستند و در گراف وابستگی‌ها دیده نمی‌شوند.

DI تست‌نویسی را ساده می‌کند

چون وابستگی‌ها اینترفیس هستند، در تست می‌توان نسخه‌ی جعلی (Fake) جایگزین کرد:

[Fact]
public async Task PlaceOrder_sends_confirmation_email()
{
    var email = new FakeEmailSender();
    var service = new OrderService(email, NullLogger<OrderService>.Instance);

    await service.PlaceOrderAsync(new Order { CustomerEmail = "a@b.com" }, default);

    Assert.Single(email.Sent);
}

جمع‌بندی

DI در ASP.NET Core به کلاس‌ها اجازه می‌دهد نیازهایشان را اعلام کنند و آن‌ها را از یک Container مرکزی بگیرند. طول عمرها را آگاهانه انتخاب کنید: Transient برای کلاس‌های کمکی سبک و بدون State، Scoped برای State مخصوص درخواست و DbContext، و Singleton برای سرویس‌های مشترک و Thread-Safe. هرگز اجازه ندهید سرویس بلندعمر یک سرویس کوتاه‌عمر را اسیر کند و Scope Validation را روشن نگه دارید. با رعایت این اصول، کد شما ماژولار، تست‌پذیر و بسیار راحت‌تر قابل تغییر خواهد بود.

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

به‌طور پیش‌فرض کدام طول عمر را انتخاب کنم؟

در برنامه‌های وب، Scoped انتخاب امنی برای سرویس‌های اپلیکیشن است. Singleton را فقط برای سرویس‌های Thread-Safe و بدون State و Transient را برای کلاس‌های کمکی خیلی سبک استفاده کنید.

آیا به Containerهایی مثل Autofac نیاز دارم؟

به‌ندرت. Container داخلی بیشتر نیازها، از جمله Keyed Services از ‎.NET 8، را پوشش می‌دهد. Containerهای جانبی امکاناتی مثل Decorator و اسکن خودکار اسمبلی اضافه می‌کنند.

آیا HttpClient باید Singleton باشد؟

HttpClient را برای هر درخواست دستی نسازید و بدون دقت هم برای همیشه نگه ندارید. از IHttpClientFactory (با AddHttpClient) استفاده کنید که طول عمر Handlerها را درست مدیریت می‌کند.