Choosing the Right Service Lifetime

The choice of which lifetime to use depends on factors like resource efficiency, isolation requirements, and concurrency considerations in your specific application scenario. Different lifetimes provide different trade-offs, and it’s essential to choose the one that aligns with your application’s requirements and performance constraints. Intr Ques

Transient

Use Transient services for lightweight, short-lived components and operations that do not maintain state between method calls and are not thread safe

Because each dependent class receives its own instance, methods on the instance are safe to mutate internal state without fear of access by other consumers and threads. Ref

Note

Transient = Any time this service is resolved is the equivalent of assigning your variable new TService

builder.Services.AddTransient<ITaxCalculator, TaxCalculator>();

Good Use Cases

  • Services with no internal / request-specific state
  • Stateless operations like formatters, mappers, or request builders, which are cheap to construct
  • Doesn’t need to be shared (hence thread safe)
  • a class that has a bunch of methods that perform some business logic, but those methods don’t set and class properties or anything on your transient class, they just take an input and return an output, for example if you have a service which read or write data from file
  • Short-lived operations that don’t need disposal tracking

Scoped

Use Scoped services for components that require per-request/ scope state or resources, ensuring that each HTTP request receives its own instance.

An Instance of the scoped service lives for the length of the scope from which it is resolved. Scoped services resolved from scoped container, this container is created when request being received by the framework, as well it’s being disposed once request handling is completed, so this services would be unique per request.

Because the container resolves a new instance of the type per request, it is not generally required to be thread safe. All framework components such as middleware and MVC controllers get the same instance of a scoped service when handling a particular request. Components within the request life cycle are invoked in sequence, so the shared instance is not used concurrently.

Note

Scoped will cache the first initialisation of it for that “scope” (http request in most cases).

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

Good Use Cases

  • Services that track or depend on request-specific data (e.g. current user, correlation ID)
  • Caching layers tied to the request
  • Scoped services are useful if multiple consumers may require the same dependency during a request.
    • An excellent example of such a case is when using Entity Framework Core. By default, the DbContext is registered with the scoped lifetime. Change tracking, therefore, applies across the entire request. Multiple components can make changes to the shared DbContext.

Singleton

Use Singleton services for stateless components and resources that can be shared across the entire application.

flowchart TD
    A["Application"] --> B["Singleton"]

    B --> C["Request A"]
    B --> D["Request B"]
    B --> E["Request C"]
    B --> F["Request D"]

    G["ONE shared instance"] --> B

Because the same instance of a Singleton service can be used by multiple requests concurrently, It must consider thread safety. Any mutable state without suitable locking mechanism could lead to unexpected behaviour. Ref

The instance will remain reachable for the lifetime of the container, so it does not require disposal or garbage collection. Implication of this is Ref

  • It is possible to create memory leaks is the instance holds large amount of memory
    • This can be especially problematic if memory usage can grow during the instance’s lifetime, given that it will never be released for garbage collection.
  • If a service has high memory requirements but is used very infrequently, then the singleton lifetime may not be the most appropriate choice.

Note

Singleton will cache only one instance ever for the lifetime of the application

builder.Services.AddSingleton<IConfigurationCache,
                               ConfigurationCache>();

Good Singleton Example

An immutable application configuration object:

public class CurrencyConfiguration
{
    public IReadOnlyDictionary<string, decimal> Rates { get; }
 
    public CurrencyConfiguration(
        IReadOnlyDictionary<string, decimal> rates)
    {
        Rates = rates;
    }
}

Registration:

builder.Services.AddSingleton<CurrencyConfiguration>();

Things to Watch Out For:

  • Don’t inject Scoped services into Singletons - it will crash at runtime
  • Be cautious with internal state - if it’s mutable and accessed concurrently, you need to handle thread safety
  • Avoid heavy dependencies or disposable resources unless you’re handling cleanup properly

Good Use Cases

  • Data is shared, doesn’t change
  • Object is thread-safe
  • Every request can safely use the same instance
  • Singleton are very well suited to functional-style services, where the methods take an input and return an output, with no shared state being used
  • Configuration providers / objects (that you are binding from you settings file)
  • Caching services / memory cache, where the state must be shared for the cache to function.
  • Logging, time services, and single-source utilities
  • Background workers and hosted services
  • Working with some particular SDK, and you know it’s thread safe

Singleton and Thread Safety

A singleton is potentially accessed by many requests simultaneously.

flowchart TD
    A["Singleton Cache"] --> B["Request A"]
    A --> C["Request B"]
    A --> D["Request C"]
    A --> E["Request D"]

    F["Concurrent access"] --> A

Therefore:

Singleton Rule

A Singleton must be thread-safe.

Do not put ordinary mutable collections into a singleton and assume they are safe for concurrent access.

For example, consider:

private Dictionary<string, string> _cache = new();

If multiple requests modify it concurrently, synchronization/thread-safe collections may be required.

Possible option:

private readonly ConcurrentDictionary<string, string> _cache = new();

Avoiding Captive Dependencies

When registering dependencies, it is crucial to ensure that the chosen lifetime is appropriate considering any dependencies that the service has of its own. This is necessary to avoid something called captive dependencies, which is where a service may live longer than is intended. Ref

Thumb rule

A service should not depend on a service with a lifetime shorter than its own.

For example, a service registered with the singleton lifetime should not depend on a transient service. Doing so would result in the transient service being captured by the singleton service, with the instance unintentionally being referenced for the life of the application.

This can lead to problematic and sometime hard to track down runtime bugs and behaviours, such as accidentally sharing non-thread safe services between threads or allowing objects to live past their intended lifetime.

Correct Dependency Direction

To visualize this, let us consider which lifetimes can safely depend on services using another lifetime.

flowchart TD
    A["Singleton"] --> B["Singleton"]
    C["Scoped"] --> A
    C --> D["Scoped"]
    E["Transient"] --> A
    E --> D
  • Since it will be a short-lived service, a transient service can safely depend on service that have transient, scoped or singleton lifetime
  • Scope services are little tricky. If they depend on a transient service, then a single instance of that transient service will live for the life of the scope across the whole request. You may or may not want this behaviour.
    • To be absolutely safe, you might choose not to depend on transient services from scoped services, but it is safe for a scoped service to depend on other scoped or singleton services.
  • A singleton service is most restrictive in terms of its dependencies, and it should not depend on transient or scoped services, but can depend upon other singleton services.
    • Capture of scope services by singletons is one of the more dangerous possibilities. Because scoped services could be disposed of when the scope ends, It is possible that the singleton may try and access them after their disposal. This could lead to runtime exceptions in production, a really bad situation.

Note

A scoped service can depend on a singleton if the singleton is thread-safe and doesn’t depend on scoped state.

Why DbContext Is Scoped

EF Core’s DbContext is normally registered as scoped.

builder.Services.AddDbContext<AppDbContext>();

Think of the request as a unit of work:

flowchart TD
    A["HTTP Request"] --> B["Scoped DbContext"]

    B --> C["Read Order"]
    B --> D["Update Customer"]
    B --> E["Create Payment"]
    B --> F["SaveChangesAsync"]

    F --> G["Database"]

The same DbContext can track changes during the request.

Why NOT Singleton?

DbContext:

  • is not thread-safe
  • maintains change-tracking state
  • represents a unit of work
  • should not be shared concurrently across requests

Bad:

Request A ─┐
           ├── Singleton DbContext ❌
Request B ─┘

Good:

Request A → DbContext A
Request B → DbContext B
Request C → DbContext C

Interview Rule

Never make DbContext a Singleton.

The normal ASP.NET Core registration is Scoped.


Singleton Across ECS Tasks

This is a production-level AWS question.

Suppose your ASP.NET Core application runs on ECS/Fargate:

flowchart TD
    A["ALB"] --> B["ECS Task 1"]
    A --> C["ECS Task 2"]
    A --> D["ECS Task 3"]

    B --> E["Singleton A"]
    C --> F["Singleton B"]
    D --> G["Singleton C"]

Important:

Singleton ≠ Distributed Singleton

A .NET Singleton exists inside a particular application process/container.

If you have three ECS tasks, you effectively have three Singleton instances.

Therefore this is not a distributed cache:

ECS Task 1 → Singleton Cache A
ECS Task 2 → Singleton Cache B
ECS Task 3 → Singleton Cache C

For shared state:

flowchart LR
    A["ECS Task 1"] --> D["Redis / ElastiCache"]
    B["ECS Task 2"] --> D
    C["ECS Task 3"] --> D

Interview keyword

Process-local · Distributed state · ElastiCache · Redis


Singleton in AWS Lambda

Be careful with this interview question.

Lambda execution environments can be reused:

flowchart TD
    A["Lambda Environment A"] --> B["Invocation 1"]
    A --> C["Invocation 2"]
    A --> D["Invocation 3"]

    E["Lambda Environment B"] --> F["Invocation 4"]
    E --> G["Invocation 5"]

A Singleton may therefore survive between invocations within the same execution environment.

But:

Lambda Environment A
    → Singleton A
 
Lambda Environment B
    → Singleton B
 
Lambda Environment C
    → Singleton C

So:

Attention

A Singleton is not global across all Lambda invocations.

Never use an in-memory singleton as distributed state.


Real-World Decision Matrix

ScenarioRecommendedWhy
Stateless calculatorTransientCheap and stateless
DTO mapperTransientLightweight
ValidatorUsually TransientTypically stateless
DbContextScopedUnit of work/request state
Repository using DbContextScopedShares request DbContext
Business service using DbContextScopedRequest-level operation
Request-specific stateScopedIsolated per request
Immutable configurationSingletonShared and safe
Thread-safe application cacheSingletonShared within process
Redis client/connection abstractionUsually Singleton/managedExpensive shared resource
Mutable request dataScopedMust not leak between requests

Don't memorize the table blindly

Ask:

  1. Does it contain request-specific state? → Scoped
  2. Is it stateless and cheap? → Transient
  3. Should it be shared across requests? → Consider Singleton
  4. Is Singleton state mutable? → Check thread safety
  5. Does it depend on Scoped state? → Lifetime mismatch

Production ASP.NET Core Example

A realistic Order API might look like:

flowchart TD
    A["HTTP Request"] --> B["OrderController"]
    B --> C["OrderService<br/>Scoped"]

    C --> D["OrderRepository<br/>Scoped"]
    D --> E["AppDbContext<br/>Scoped"]

    C --> F["PaymentClient"]
    F --> G["HttpClient"]

    C --> H["TaxCalculator<br/>Transient"]

    C --> I["ApplicationCache<br/>Singleton"]

    E --> J["PostgreSQL / SQL Server"]
    F --> K["Payment Service"]

Registration:

builder.Services.AddScoped<IOrderService, OrderService>();
 
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
 
builder.Services.AddDbContext<AppDbContext>();
 
builder.Services.AddTransient<ITaxCalculator, TaxCalculator>();
 
builder.Services.AddSingleton<IApplicationCache, ApplicationCache>();
 
builder.Services.AddHttpClient<IPaymentClient, PaymentClient>();

Why?

DependencyLifetimeReason
OrderServiceScopedRequest-level business operation
OrderRepositoryScopedWorks with request’s DbContext
DbContextScopedUnit of work/change tracking
TaxCalculatorTransientStateless/lightweight
ApplicationCacheSingletonShared application-wide state
HttpClientManaged by IHttpClientFactoryConnection/resource management

References

TODO


Q. AddSingleton(), AddScoped(), AddTransient() are these extension methods ? if yes, of which class ?

Yes. AddSingleton(), AddScoped(), and AddTransient() are extension methods.

They are extension methods on IServiceCollection, defined in the Microsoft.Extensions.DependencyInjection namespace.

Example

builder.Services.AddScoped<IOrderService, OrderService>();

Here:

builder.Services


IServiceCollection

      ├── AddScoped()
      ├── AddSingleton()
      └── AddTransient()

The methods are provided by the ServiceCollectionServiceExtensions class:

namespace Microsoft.Extensions.DependencyInjection;
 
public static class ServiceCollectionServiceExtensions
{
    public static IServiceCollection AddScoped<TService, TImplementation>(
        this IServiceCollection services)
        where TImplementation : class, TService
    {
        // registration...
    }
}

So the interview answer is:

AddSingleton, AddScoped, and AddTransient are extension methods defined in the ServiceCollectionServiceExtensions static class, in the Microsoft.Extensions.DependencyInjection namespace. They extend IServiceCollection.”

Why does this work?

Because builder.Services is an IServiceCollection:

IServiceCollection services = builder.Services;
 
services.AddScoped<IOrderService, OrderService>();

The this keyword makes it an extension method:

public static IServiceCollection AddScoped<TService, TImplementation>(
    this IServiceCollection services)

🔥 Interview distinction

Don’t say:

AddScoped() is a method of IServiceCollection.”

More precisely:

AddScoped() is an extension method that extends IServiceCollection.”

IServiceCollection itself is essentially the collection used to register services and their lifetimes for the .NET DI container.