Loading content...
Transient lifetime solves the isolation problem but creates many instances. Singleton lifetime maximizes reuse but introduces shared state. Scoped lifetime occupies the middle ground—providing instance sharing within a defined boundary while maintaining isolation across boundaries.
In web applications, the most common scope is the HTTP request. A scoped service creates one instance per request. Every component within that request shares the same instance, but each new request gets a fresh one. This pattern elegantly addresses common needs:
Scoped lifetime is arguably the most nuanced of the three—understanding when boundaries begin and end, what "scope" means in different contexts, and how scoped services interact with services of other lifetimes.
By the end of this page, you will understand scoped lifetime deeply: how scope boundaries are defined and managed, why request-scoped services are essential for database operations, how scope relates to async/await, and the subtle bugs that occur when scoped services are captured by longer-lived components.
A scoped service is one that the container creates once per scope. All requests for the service within the same scope return the same instance. When the scope ends, the instance is disposed (if applicable) and future scopes receive new instances.
The key concepts are:
1234567891011121314151617181920
// In .NET Core / .NET 5+public void ConfigureServices(IServiceCollection services){ // One instance per HTTP request (scope) services.AddScoped<IUserContext, HttpUserContext>(); // One DbContext per request - essential for transaction coherence services.AddScoped<ApplicationDbContext>(); // One IOrderService per request - shares DbContext with repository services.AddScoped<IOrderService, OrderService>(); // Factory-based scoped registration services.AddScoped<IRequestLogger>(provider => { var httpContext = provider.GetRequiredService<IHttpContextAccessor>(); var requestId = httpContext.HttpContext?.TraceIdentifier ?? Guid.NewGuid().ToString(); return new RequestLogger(requestId); });}1234567891011121314151617181920212223242526272829303132
// In Spring, scoped beans have several built-in scopes@Configurationpublic class AppConfig { // One instance per HTTP request @Bean @Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public UserContext userContext(HttpServletRequest request) { return new HttpUserContext(request); } // One instance per HTTP session @Bean @Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) public ShoppingCart shoppingCart() { return new ShoppingCart(); }} // Using component annotation@Component@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)public class RequestAuditContext { private final List<AuditEntry> entries = new ArrayList<>(); private final Instant createdAt = Instant.now(); public void recordAction(String action) { entries.add(new AuditEntry(action, Instant.now())); }}12345678910111213141516171819202122232425262728293031323334353637
// In NestJS, REQUEST scope provides per-request instancesimport { Injectable, Scope, Inject } from '@nestjs/common';import { REQUEST } from '@nestjs/core';import { Request } from 'express'; @Injectable({ scope: Scope.REQUEST })export class UserContext { private readonly userId: string; private readonly requestId: string; constructor(@Inject(REQUEST) private readonly request: Request) { this.userId = request.headers['x-user-id'] as string; this.requestId = request.headers['x-request-id'] as string; console.log(`UserContext created for request ${this.requestId}`); } getCurrentUserId(): string { return this.userId; }} // Controller using scoped service@Controller('orders')export class OrdersController { // Each request gets a fresh controller instance (request-scoped) // and fresh UserContext instance constructor( private readonly userContext: UserContext, private readonly orderService: OrderService, ) {} @Get() findMyOrders() { const userId = this.userContext.getCurrentUserId(); return this.orderService.findByUser(userId); }}The defining characteristic of scoped lifetime is the boundary. What constitutes a scope depends on the context:
| Context | Scope Boundary | Created By |
|---|---|---|
| ASP.NET Core | HTTP Request | Framework automatically |
| Background Service | Manual scope | CreateScope() call |
| Message Handler | Message being processed | Middleware/framework |
| Unit of Work | Logical transaction | Application code |
| Parallel Work Items | Each work item | Task parallelism |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Scenario 1: Automatic scope (Web Request)// In ASP.NET Core, middleware creates scope for each request automatically public class OrdersController : ControllerBase{ private readonly ApplicationDbContext _db; // Scoped private readonly IOrderService _orderService; // Scoped // Both are automatically scoped to this HTTP request // When request ends, both are disposed public OrdersController(ApplicationDbContext db, IOrderService orderService) { _db = db; _orderService = orderService; // Both instances are the SAME throughout this request // If OrderService internally uses the same ApplicationDbContext, // it receives the SAME instance - sharing the DbContext connection }} // Scenario 2: Manual scope (Background Service)public class OrderProcessorBackgroundService : BackgroundService{ private readonly IServiceProvider _serviceProvider; public OrderProcessorBackgroundService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; // Note: Cannot inject scoped services directly here! // Background services are singletons - scoped would be captured } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var pendingOrders = await GetPendingOrders(); foreach (var order in pendingOrders) { // Create explicit scope for each order processing using (var scope = _serviceProvider.CreateScope()) { // Resolve scoped services from this scope var processor = scope.ServiceProvider .GetRequiredService<IOrderProcessor>(); var db = scope.ServiceProvider .GetRequiredService<ApplicationDbContext>(); await processor.ProcessAsync(order); await db.SaveChangesAsync(); } // Scope ends - DbContext disposed, connections returned } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } }}A common misconception is that scope equals thread. In async code, a single scope may execute across multiple threads (when continuations resume on different thread pool threads). The scope is a logical container, not a thread-local. This means scoped services must still be thread-safe if the scope performs concurrent operations within itself.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Scopes survive async boundaries - same instance, possibly different threadspublic class OrderController : ControllerBase{ private readonly ApplicationDbContext _db; private readonly IEmailSender _emailSender; public OrderController(ApplicationDbContext db, IEmailSender emailSender) { _db = db; _emailSender = emailSender; Console.WriteLine($"Thread at construction: {Environment.CurrentManagedThreadId}"); } [HttpPost] public async Task<IActionResult> CreateOrder(OrderRequest request) { Console.WriteLine($"Thread before await: {Environment.CurrentManagedThreadId}"); var order = new Order(request); _db.Orders.Add(order); await _db.SaveChangesAsync(); // May resume on different thread Console.WriteLine($"Thread after first await: {Environment.CurrentManagedThreadId}"); await _emailSender.SendOrderConfirmationAsync(order); // May change again Console.WriteLine($"Thread after second await: {Environment.CurrentManagedThreadId}"); // Despite potentially executing on 3-4 different threads, // _db is the SAME instance throughout - the scope didn't change return Ok(order.Id); }} /* Possible output:Thread at construction: 4Thread before await: 4Thread after first await: 7 <-- Different thread!Thread after second await: 12 <-- Different thread again! But _db is the same instance throughout - scope transcends threads */The most important and ubiquitous use of scoped lifetime is the database context. Understanding why DbContext must be scoped illuminates the core value proposition of scoped lifetime.
Why DbContext Must Be Scoped (Not Transient):
Change tracking coherence — DbContext tracks entity changes. If Service A and Service B get different DbContext instances, changes tracked by A are invisible to B.
Transaction coherence — Operations within a request should share a transaction. Separate DbContext instances mean separate transactions—and data inconsistency.
Connection efficiency — Each DbContext opens a connection. Transient DbContext means one connection per service—expensive and potentially exhausting the pool.
Why DbContext Must Be Scoped (Not Singleton):
Connection management — Singleton DbContext holds a connection forever, defeating connection pooling.
Change tracker bloat — The change tracker would accumulate entities across all requests, growing without bound.
Thread safety — DbContext is not thread-safe. Concurrent requests would corrupt internal state.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Correct: DbContext shared within request scopeservices.AddScoped<ApplicationDbContext>();services.AddScoped<IOrderRepository, OrderRepository>();services.AddScoped<ICustomerRepository, CustomerRepository>();services.AddScoped<IOrderService, OrderService>(); public class OrderService : IOrderService{ private readonly ApplicationDbContext _db; private readonly IOrderRepository _orderRepo; private readonly ICustomerRepository _customerRepo; public OrderService( ApplicationDbContext db, IOrderRepository orderRepo, ICustomerRepository customerRepo) { _db = db; _orderRepo = orderRepo; _customerRepo = customerRepo; // All three receive the SAME DbContext instance // because all are scoped to the same request } public async Task<Order> CreateOrderAsync(OrderRequest request) { // 1. Load customer (through repository, using shared DbContext) var customer = await _customerRepo.GetByIdAsync(request.CustomerId); // 2. Create order entity var order = new Order { Customer = customer, // Entity already tracked by DbContext Items = request.Items.Select(i => new OrderItem(i)).ToList() }; // 3. Persist order (through repository, using shared DbContext) await _orderRepo.AddAsync(order); // 4. Update customer statistics customer.TotalOrders++; customer.LastOrderDate = DateTime.UtcNow; // No explicit save - customer is tracked by same DbContext // 5. Single SaveChanges commits everything atomically await _db.SaveChangesAsync(); // Both order CREATE and customer UPDATE in one transaction return order; } // If DbContext were transient: // - customerRepo.GetByIdAsync uses DbContext #1 // - orderRepo.AddAsync uses DbContext #2 // - customer update would be on DbContext #1 (orphaned) // - SaveChangesAsync on _db (#3) saves nothing useful! // = Data corruption}Scoped DbContext naturally implements the Unit of Work pattern. All changes within the scope accumulate in the DbContext's change tracker. A single SaveChangesAsync at the end commits everything atomically. This alignment between scope and unit of work is why scoped DbContext is the universally recommended pattern.
123456789101112131415161718192021222324252627
HTTP Request Arrives│├── Request Scope Created│ ││ ├── Resolve OrderController│ │ ├── Resolve ApplicationDbContext → Creates DbContext #1│ │ ├── Resolve IOrderService│ │ │ ├── Resolve ApplicationDbContext → Returns DbContext #1 (SAME!)│ │ │ ├── Resolve IOrderRepository│ │ │ │ └── Resolve ApplicationDbContext → Returns DbContext #1 (SAME!)│ │ │ └── Resolve ICustomerRepository│ │ │ └── Resolve ApplicationDbContext → Returns DbContext #1 (SAME!)│ │ └── OrderService ready│ ││ ├── Execute Controller Action│ │ └── All operations use DbContext #1│ │ ├── Queries tracked│ │ ├── Inserts tracked│ │ ├── Updates tracked│ │ └── SaveChangesAsync → Single atomic transaction│ ││ └── Request Scope Ends│ └── DbContext #1.Dispose() called → Connection returned to pool│└── HTTP Response Sent Next Request → DbContext #2 created (fresh scope, fresh instance)The power of scoped lifetime comes from deterministic instance sharing. Understanding exactly when instances are shared—and when they're not—prevents subtle bugs:
| Scenario | Same Instance? | Explanation |
|---|---|---|
| Same scope, same service type | Yes ✓ | Fundamental scoped behavior |
| Same scope, different consumers | Yes ✓ | All consumers in scope share |
| Different scopes, same service type | No ✗ | Each scope gets its own |
| Parent scope, child scope | Depends | Child inherits parent's instances by default |
| Parallel scopes (concurrent requests) | No ✗ | Each request has isolated scope |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Service that reports its identitypublic class ScopedCounter : IScopedCounter{ private static int _globalInstanceCount = 0; private int _callCount = 0; public int InstanceId { get; } public ScopedCounter() { InstanceId = Interlocked.Increment(ref _globalInstanceCount); Console.WriteLine($"ScopedCounter #{InstanceId} created"); } public int Increment() => ++_callCount;} services.AddScoped<IScopedCounter, ScopedCounter>(); // Controller demonstrating scoped sharingpublic class TestController : ControllerBase{ private readonly IScopedCounter _counter1; private readonly IScopedCounter _counter2; private readonly INestedService _nested; public TestController( IScopedCounter counter1, IScopedCounter counter2, INestedService nested) { _counter1 = counter1; _counter2 = counter2; _nested = nested; } [HttpGet("test-scoped")] public IActionResult TestScoped() { // counter1 and counter2 are THE SAME INSTANCE Console.WriteLine($"counter1 ID: {_counter1.InstanceId}"); Console.WriteLine($"counter2 ID: {_counter2.InstanceId}"); Console.WriteLine($"Same instance: {ReferenceEquals(_counter1, _counter2)}"); _counter1.Increment(); _counter1.Increment(); // counter2 sees the increments because it's the same instance! var count = _counter2.Increment(); // Returns 3 // NestedService also received same instance var nestedCount = _nested.GetCounterValue(); // Also sees 3 return Ok(new { InstanceId = _counter1.InstanceId, Count = count }); }} public class NestedService : INestedService{ private readonly IScopedCounter _counter; public NestedService(IScopedCounter counter) { _counter = counter; // Same instance as controller received } public int GetCounterValue() => _counter.Increment();} /* Output for first request:ScopedCounter #1 createdcounter1 ID: 1counter2 ID: 1Same instance: True Output for second request:ScopedCounter #2 created <-- New instance for new scope!counter1 ID: 2counter2 ID: 2Same instance: True*/While web frameworks automatically create request scopes, non-web applications must create scopes explicitly. This is essential for:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// Pattern 1: Console app processing batchespublic class BatchProcessor{ private readonly IServiceProvider _rootProvider; public BatchProcessor(IServiceProvider rootProvider) { _rootProvider = rootProvider; } public async Task ProcessBatchAsync(IEnumerable<WorkItem> items) { foreach (var item in items) { // Each work item gets its own scope await using (var scope = _rootProvider.CreateAsyncScope()) { var handler = scope.ServiceProvider .GetRequiredService<IWorkItemHandler>(); var db = scope.ServiceProvider .GetRequiredService<ApplicationDbContext>(); try { await handler.HandleAsync(item); await db.SaveChangesAsync(); } catch (Exception ex) { // Rollback is automatic - scope disposal discards DbContext _logger.LogError(ex, "Failed to process {ItemId}", item.Id); } } // Scope disposes - DbContext disposed, changes discarded if not saved } }} // Pattern 2: Message queue consumerpublic class MessageConsumerService : BackgroundService{ private readonly IServiceProvider _services; private readonly IMessageQueue _queue; protected override async Task ExecuteAsync(CancellationToken ct) { await foreach (var message in _queue.ConsumeAsync(ct)) { // Each message = one scope = one "unit of work" await using var scope = _services.CreateAsyncScope(); var messageType = message.GetType(); var handlerType = typeof(IMessageHandler<>).MakeGenericType(messageType); var handler = scope.ServiceProvider.GetRequiredService(handlerType); await ((dynamic)handler).HandleAsync((dynamic)message, ct); } }} // Pattern 3: Parallel processing with scope isolationpublic async Task ProcessParallelAsync(IEnumerable<Order> orders){ await Parallel.ForEachAsync(orders, async (order, ct) => { // Each parallel execution gets isolated scope await using var scope = _services.CreateAsyncScope(); var processor = scope.ServiceProvider .GetRequiredService<IOrderProcessor>(); // Safe: each parallel branch has own DbContext await processor.ProcessAsync(order, ct); });} // Pattern 4: Desktop app with explicit transactionspublic class OrderFormViewModel{ private readonly IServiceProvider _services; public async Task SubmitOrderAsync() { // UI operation = one scope await using var scope = _services.CreateAsyncScope(); var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>(); var result = await orderService.CreateOrderAsync(this.OrderData); if (result.IsSuccess) { MessageBox.Show("Order submitted!"); } } // Scope ends - resources released even if UI stays open}Use CreateAsyncScope() when the scope contains async IAsyncDisposable services. It returns AsyncServiceScope which can be disposed with 'await using'. For sync-only code, CreateScope() with regular 'using' suffices. Modern .NET applications should prefer CreateAsyncScope() as default.
The most dangerous pitfall of scoped lifetime is the captive dependency—when a longer-lived service captures a shorter-lived dependency, holding it beyond its intended lifetime.
The classic case: a singleton service capturing a scoped dependency.
1234567891011121314151617181920212223242526272829303132333435363738
// ❌ CRITICAL BUG: Singleton captures scoped dependencyservices.AddScoped<ApplicationDbContext>();services.AddSingleton<ICacheService, CacheService>(); // PROBLEM public class CacheService : ICacheService{ private readonly ApplicationDbContext _db; // ⚠️ CAPTURED! public CacheService(ApplicationDbContext db) { _db = db; // This DbContext was created when CacheService was first resolved // It will live for the ENTIRE application lifetime // Not just one request! } public async Task<User> GetUserAsync(int id) { // First request: DbContext is fresh, this works // Second request: DbContext is stale, tracking entities from request #1 // 100th request: DbContext has 100 requests worth of tracked entities // Eventually: Memory exhaustion, stale data, connection timeout return await _db.Users.FindAsync(id); }} /* What happens at runtime:1. First HTTP request arrives2. CacheService (singleton) needs to be created3. CacheService requests ApplicationDbContext (scoped)4. Container creates DbContext #1 for this scope5. CacheService stores reference to DbContext #16. Request completes - scope SHOULD dispose DbContext #17. But CacheService (singleton) still holds reference!8. DbContext #1 lives forever, accumulating tracked entities9. Second request arrives - new scope, new DbContext #210. But CacheService still uses DbContext #1!11. = Stale data, cross-request contamination, eventual crash */Captive dependencies often don't crash immediately. The captured DbContext may work for hours or days—slowly accumulating issues. Entity tracking becomes corrupted. Connections may time out but be held. Memory grows linearly. This makes the bug extremely hard to diagnose in production.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Solution 1: Make the consumer scoped too (if possible)services.AddScoped<ICacheService, CacheService>();// Now CacheService and DbContext have same lifetime - no capture // Solution 2: Inject IServiceProvider and resolve per-operationservices.AddSingleton<ICacheService, SafeCacheService>(); public class SafeCacheService : ICacheService{ private readonly IServiceProvider _services; public SafeCacheService(IServiceProvider services) { _services = services; } public async Task<User> GetUserAsync(int id) { // Create scope for each operation await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); return await db.Users.FindAsync(id); } // Scope ends, DbContext disposed - no capture} // Solution 3: Use IServiceScopeFactory (preferred for singletons)services.AddSingleton<ICacheService, BetterCacheService>(); public class BetterCacheService : ICacheService{ private readonly IServiceScopeFactory _scopeFactory; public BetterCacheService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } public async Task<User> GetUserAsync(int id) { await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); return await db.Users.FindAsync(id); }} // Solution 4: Inject factory delegateservices.AddSingleton<ICacheService, FactoryCacheService>();services.AddScoped<ApplicationDbContext>();services.AddSingleton<Func<ApplicationDbContext>>(sp =>{ return () => sp.CreateScope().ServiceProvider .GetRequiredService<ApplicationDbContext>();}); // ✅ Modern .NET validation: Enable scope validation// In Program.cs, this catches captive dependencies at startup!builder.Host.UseDefaultServiceProvider(options =>{ options.ValidateScopes = true; // Throws if scoped resolved from root options.ValidateOnBuild = true; // Validates DI graph at startup});| Consumer Lifetime | Can Depend On | Cannot Depend On |
|---|---|---|
| Transient | Transient, Scoped, Singleton | (all valid) |
| Scoped | Scoped, Singleton | Transient (wasteful but not dangerous) |
| Singleton | Singleton only | Scoped, Transient (captive dependency!) |
123456789101112131415161718192021222324252627282930313233343536
// Pattern: Ambient request contextservices.AddScoped<IRequestContext, HttpRequestContext>(); public class HttpRequestContext : IRequestContext{ public string RequestId { get; } public string UserId { get; } public DateTime StartedAt { get; } public HttpRequestContext(IHttpContextAccessor accessor) { var httpContext = accessor.HttpContext!; RequestId = httpContext.TraceIdentifier; UserId = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; StartedAt = DateTime.UtcNow; }} // Now any service can inject IRequestContext and get per-request valuespublic class AuditService : IAuditService{ private readonly IRequestContext _context; private readonly ApplicationDbContext _db; public async Task LogActionAsync(string action) { _db.AuditLogs.Add(new AuditLog { RequestId = _context.RequestId, UserId = _context.UserId, Action = action, Timestamp = DateTime.UtcNow }); // SaveChanges handled by Unit of Work pattern at request end }}Scoped lifetime provides the best of both worlds: instance sharing within a boundary, isolation across boundaries. It's the natural fit for database contexts, request identity, and unit of work patterns.
What's next:
Scoped and transient cover most scenarios, but some services truly need to exist once for the entire application lifetime. The next page explores Singleton Lifetime—the simplest concept but with the most threading considerations.
You now understand scoped lifetime comprehensively: scope boundaries, the DbContext pattern, instance sharing semantics, non-web usage, and the critical captive dependency problem. Next, we'll explore singleton lifetime—single instance for the application lifetime.