ASP.NET Core 10 APIs · Lesson 3

Dependency injection lifetimes and scopes

Observe scoped identity without capturing it in a singleton.

External .NET 10 SDK 10.0.403; ASP.NET Core server on your own desktop. Kestrel does not run in the SharpRune browser console.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

A service lifetime defines an ownership boundary

Dependency injection lifetimes describe when a container creates and owns service instances. A scoped service normally belongs to one ASP.NET request scope, a transient is created for each resolution, and a singleton belongs to the root application lifetime. These rules are not a substitute for a concurrency design or an instruction to make every class globally shared. The portable companion demonstrates ordinary object identity under two owners. The external project uses the real service provider inside real requests, so observed resolution behavior comes from ASP.NET rather than a custom teaching container with borrowed framework names.

Compare identity within a request and across requests

The console borrows the first owner's object and compares it with that same owner's property, printing True. A new owner creates a new object, so the second comparison prints False. On the external server, two scoped parameters resolve to one request-owned marker, while the two transient parameters resolve to different markers. The response therefore reports sameScoped: true and sameTransient: false. A singleton counter advances from one to two across two sequential requests. Its increment is atomic so concurrent requests cannot lose increments, although that does not make every possible shared collection or operation automatically safe.

A request scope is broader than one parameter resolution

The scoped marker can be resolved by multiple collaborators during the same request and still represent the same scope-owned instance. A second request receives its own scope and its own marker. Do not assume that a singleton can safely retain that request's marker after the scope ends. Such a capture creates an ownership mismatch and can be rejected by scope validation. For background work that genuinely needs scoped services, create and dispose an appropriate explicit scope through the framework and avoid borrowing a request's HttpContext or disposed dependency. This lesson starts no background worker and claims no such integration.

Let the correct owner end the service lifetime

The built-in provider disposes services it owns at the appropriate scope boundary. A consumer receiving an injected service should not casually dispose that borrowed dependency while other collaborators still need it. Conversely, objects created outside registration factories may have different ownership obligations. The sample's marker classes are deliberately empty, allowing identity to be observed without inventing disposal side effects. When introducing a real database context, stream or subscription, inspect both its registration lifetime and its own API contract. Registration labels cannot make an unsafe dependency usable concurrently or keep a disposed request resource valid after the response.

Practice: change one registration lifetime

Change only ScopedMarker from scoped registration to transient registration, keeping the two endpoint parameters unchanged. Predict which boolean changes and which counter behavior remains the same. Restore the baseline, then register TransientMarker as scoped and predict the opposite observation. Use a freshly started server for independently specified counter expectations. The hint is to compare resolutions within one request rather than deriving identity from class names. Naming a class ScopedMarker has no effect on the provider; the actual registration and resolution scope determine its ownership behavior.

Practice: expose a scope-capture error

In a separate project copy, add a singleton that directly requires a scoped marker in its constructor. Resolve that singleton with scope validation enabled and observe the failure rather than disabling validation to suppress it. Then redesign the work so the request-scoped collaborator is used within its intended scope. Describe what would need to change for a genuine background job to own its own scope, without implementing a fake provider. Keep this experiment separate from the successful identity endpoint so an expected startup or resolution failure does not hide a regression in the baseline request behavior.

Review lifetime and thread safety independently

A useful design review asks who owns each instance, how long references are retained, whether callers can overlap, and who disposes any resources. The singleton request counter in this example has a deliberately narrow atomic operation; a larger stateful service needs a correspondingly explicit policy. Preserve within-request identity checks when changing registrations, and add cross-request behavior tests when introducing shared state. The console remains valuable for ordinary reference ownership, but only the real external project exercises the framework's request scope. Keep that distinction in the lesson's claims and in the names of its executable checks.

  • Do not capture request-scoped dependencies in root-owned singletons.
  • Test lifetime identity separately from concurrent-state correctness and resource disposal.
External .NET 10 SDK 10.0.403; ASP.NET Core server on your own desktop. Kestrel does not run in the SharpRune browser console.

Dependency injection lifetimes and scopes — real ASP.NET Core 10 project

Set up the SDK project

  1. Save all files into one new empty directory, preserving their relative paths. Install the official .NET 10 SDK if it is not already available.
  2. From that directory run: dotnet restore WebLab.csproj --source https://api.nuget.org/v3/index.json
  3. Start only on loopback: ASPNETCORE_ENVIRONMENT=Development dotnet run --project WebLab.csproj --no-launch-profile --urls http://127.0.0.1:5080
  4. Use another terminal for the requests below, then press Ctrl+C in this project’s terminal to stop its server.
  5. Call curl http://127.0.0.1:5080/lifetimes twice against the same running process.

Download complete SDK project ↗

Project files

WebLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

  </PropertyGroup>
</Project>
global.jsonDownload file ↗
{
  "sdk": {
    "version": "10.0.403",
    "rollForward": "latestPatch"
  }
}
Program.csDownload file ↗
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ScopedMarker>(); builder.Services.AddTransient<TransientMarker>(); builder.Services.AddSingleton<RequestCounter>();
var app = builder.Build();
app.MapGet("/lifetimes", (ScopedMarker a, ScopedMarker b, TransientMarker x, TransientMarker y, RequestCounter counter) => new { sameScoped = ReferenceEquals(a, b), sameTransient = ReferenceEquals(x, y), request = counter.Next() });
app.Run();
public sealed class ScopedMarker { }
public sealed class TransientMarker { }
public sealed class RequestCounter { private int count; public int Next() => Interlocked.Increment(ref count); }

Expected result

Two sequential GET /lifetimes calls each report sameScoped=true and sameTransient=false; request advances from 1 to 2 for this fresh server.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

var first = new Owner(); object borrowed = first.Value; Console.WriteLine(ReferenceEquals(first.Value, borrowed));
var second = new Owner(); Console.WriteLine(ReferenceEquals(first.Value, second.Value));
sealed class Owner { public object Value { get; } = new object(); }
Open C# practice

C# practice output

True
False
Try this
Identity within a scope differs from identity across scopes; singleton state also needs a concurrency policy.

Further reading