Dependency Injection Lifetimes and Their Relationships

Dependency Injection

Dependency Injection (DI) is one of the core pillars of ASP.NET Core. Most developers quickly learn the three service lifetimes—Transient, Scoped, and Singleton—but confusion often begins when these services start depending on one another.

Questions like these are common:

  • Can a Singleton use a Scoped service?
  • What happens when a Singleton depends on a Transient service?
  • Why does ASP.NET Core throw an exception for some combinations but allow others?
  • Is a Transient always recreated?

In this article, we’ll explore not only the three lifetimes but also the relationships between them.

The Three Service Lifetimes

ASP.NET Core provides three built-in service lifetimes.

LifetimeCreatedWhySharedReal-world service
TransientEvery time it is requestedStateless and inexpensive to create❌ NoEmailSender, PdfGenerator, PasswordHasher
ScopedOnce per HTTP requestMultiple components in a request should use the same instance✅ Within the same requestAppDbContext, OrderRepository, OrderService
SingletonOnce when the application starts (or first requested)Data and configuration should be shared application-wide✅ Across all requestsMemoryCache, JwtTokenSettings, ExchangeRateCache

Understanding Service Lifetimes Visually

Imagine two HTTP requests hitting your API.

Request 1
    Controller
        ↓
    Service
        ↓
    Repository
        ↓
    DbContext

Request 2
    Controller
        ↓
    Service
        ↓
    Repository
        ↓
    DbContext

Transient

A new object is created every time:

Request 1
    EmailSender A
    EmailSender B

Request 2
    EmailSender C

Scoped

The same object is reused within a request:

Request 1
    OrderService A
    Repository A
    DbContext A

Request 2
    OrderService B
    Repository B
    DbContext B

Singleton

The same object is reused by the entire application:

Application Start
        ↓
    MemoryCache A

Request 1 → MemoryCache A
Request 2 → MemoryCache A
Request 3 → MemoryCache A

Which Service Can Depend on Which?

This is where many developers get confused.

Consumer ↓ / Dependency →SingletonScopedTransient
Singleton✅ Allowed❌ Not allowed✅ Allowed*
Scoped✅ Allowed✅ Allowed✅ Allowed
Transient✅ Allowed✅ Allowed✅ Allowed

The asterisk on Singleton → Transient is important because although ASP.NET Core allows it, the behavior may not be what you expect.


1.A Singleton → Singleton ✅

This is perfectly safe.

builder.Services.AddSingleton<CacheService>();
builder.Services.AddSingleton<ReportService>();

Both services live for the entire application lifetime.


1.B Singleton → Scoped ❌

This is the only combination that ASP.NET Core completely blocks.

builder.Services.AddSingleton<ReportService>();
builder.Services.AddScoped<AppDbContext>();
public class ReportService
{
    public ReportService(AppDbContext dbContext)
    {
    }
}

The application throws:

Cannot consume scoped service 'AppDbContext'
from singleton 'ReportService'.

Why?

Because the singleton lives for the entire application, while the scoped service exists only during a single HTTP request.


How Can a Singleton Use a Scoped Service?

The correct solution is to create a scope manually.

public class ReportService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public ReportService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public async Task GenerateReport()
    {
        using var scope = _scopeFactory.CreateScope();

        var repository =
            scope.ServiceProvider
                 .GetRequiredService<OrderRepository>();

        await repository.GenerateReport();
    }
}

Execution flow:

Singleton Service
        ↓
Create Scope
        ↓
Resolve Scoped Service
        ↓
Use DbContext
        ↓
Dispose Scope

This pattern is commonly used in BackgroundService and scheduled jobs.


1.C Singleton → Transient ✅ (But Be Careful)

Most developers assume that a transient dependency is always recreated.

Not necessarily.

builder.Services.AddTransient<EmailSender>();
builder.Services.AddSingleton<NotificationService>();

public class NotificationService
{
    private readonly EmailSender _emailSender;

    public NotificationService(EmailSender emailSender)
    {
        _emailSender = emailSender;
    }
}

Although EmailSender is registered as transient, it is created only once when the singleton is constructed.

Application Start
        ↓
NotificationService created
        ↓
EmailSender created

Request 1 → EmailSender A
Request 2 → EmailSender A
Request 3 → EmailSender A

In practice, the transient behaves like a singleton.

If you truly need a fresh transient instance each time, use IServiceProvider or a factory.


2.A Scoped → Scoped ✅

This is the most common scenario in ASP.NET Core.

builder.Services.AddScoped<AppDbContext>();
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<OrderService>();

Within a request, all components share the same scope.

Request 1
    OrderService A
        ↓
    Repository A
        ↓
    DbContext A

Request 2
    OrderService B
        ↓
    Repository B
        ↓
    DbContext B

2.B Scoped → Singleton ✅

This is safe because a shorter-lived object can depend on a longer-lived object.

builder.Services.AddSingleton<MemoryCache>();
builder.Services.AddScoped<OrderService>();

Every request gets a new OrderService, but all requests share the same cache.


2.C Scoped → Transient ✅

Also safe.

builder.Services.AddScoped<OrderService>();
builder.Services.AddTransient<EmailSender>();

Each request creates a new OrderService, which receives a fresh EmailSender.


3. Transient → Anything ✅

Transient services can safely depend on Singleton, Scoped, or other Transient services because they have the shortest lifetime.

Transient → Singleton ✅
Transient → Scoped ✅
Transient → Transient ✅

The Golden Rule

A simple way to remember all of this is:

Longer lifetime → Shorter lifetime = Dangerous
Shorter lifetime → Longer lifetime = Safe

ASP.NET Core lifetimes, from longest to shortest:

Singleton
    ↓
Scoped
    ↓
Transient

Summary

Dependency Injection lifetimes are easy to memorize but harder to reason about in real applications.

The key takeaway is this:

  • Singleton lives for the entire application.
  • Scoped lives for a single request.
  • Transient is created whenever needed.
  • Singleton cannot directly depend on Scoped.
  • Singleton can depend on Transient, but the transient may effectively become a singleton.
  • If a singleton needs a scoped service, create a scope manually.

Once you understand the relationship between these lifetimes, many ASP.NET Core concepts—such as DbContext, repositories, middleware, background services, and caching—start to make much more sense.

Share Button

Leave a Reply

Your email address will not be published. Required fields are marked *