VSLive! Blog

Industry Insights, Information, and Developer News

Blog archive

Wait...What Does AddScoped() Actually Do?

I don't know about you, but from time to time, I'll be coding something -- basically doing something that I've done a 100 zillion times -- and then all of a sudden I think "yah...but why?" or "how the heck does this even work?" For me today, it was thinking about how AddScoped() actually works. And then my self-directed follow-up question was how that hooks into ASP.NET for real. And then does that mean that AddScoped is pointless for a CLI app?

And then that turned into this article.

Transient, Singleton, Scoped, Oh My.
Let's get your brain into the context. If you work with ASP.NET web apps, this is the kind of stuff you've seen and written in a 100 Program.cs files:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IConfigurationReader, ConfigurationReader>();
builder.Services.AddScoped<IPersonRepository, SqlPersonRepository>();
builder.Services.AddScoped<PersonService>();
builder.Services.AddTransient<IEmailFormatter, EmailFormatter>();

var app = builder.Build();

The big thing that this code is doing is setting up the dependency injection configuration -- also known as setting up the service registration. Typically, you're using three methods to register the types and instancing lifetimes. AddSingleton is easy: one instance for the whole app. AddTransient is also easy and it's the mirror image of singleton: you get a new instance every single time. And then there's AddScoped. I use it a lot. You probably use it a lot, too. And if you were asked in an interview to describe what it does, you'd probably just say "it's, uhhhh, per-request" and hope that that interviewer (who probably also barely understands it) just says "yes" and moves on.

Anyway. It's scoped. Got it. But scoped to what, exactly? The DI container doesn't know what an HTTP request is. So what's a "scope"?

What's a 'Scope' in This Case?
I've done enough integration testing using WebApplicationFactory (one of my longtime favorite, underappreciated parts of .NET) to know at least a little about the scope thing. For example, if I'm writing a WAF test and I want to get an instance of the configured IPersonRepository, I know from experience that I need to get at the service provider from WAF and create a scope and THEN get the required service. If you don't create the scope, it just doesn't work. (I'm pretty sure you just get a NahBuddyThatDidntWorkTryAgainException.)

var factory = new WebApplicationFactory<Program>();

using var scope = factory.Services.CreateScope();

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

But knowing how to make this work isn't the same as understanding HOW it works.

My initial thinking was that scope must be some complex .NET Core thing that has some fundamental knowledge of instancing and garbage collection and HttpContext and whatever. That was me overthinking it. In reality, it's super simple: it's just an object.

Look at that code sample again. CreateScope() gives you back an IServiceScope. You create it, you resolve services from it, you dispose it. When you register something with AddScoped(), all you're saying is "one instance per scope object -- and when the scope gets disposed, my instance gets disposed too."

That's the whole feature. There's no magic in there. The DI container has absolutely no idea what an HTTP request is. It's never heard of ASP.NET. It just knows how to hand out instances, and it knows about three lifetimes: one-per-container (singleton), one-per-scope (scoped), and new-every-time (transient).

Which immediately raises the question: if scoped just means "per scope object"...who's creating the scope objects in my web app? Because I sure as heck never wrote CreateScope() in a controller.

Why AddScoped() Pays Off in a Web App
Before we answer the "who's creating the scopes" question, let's talk about why you'd even want this lifetime. Because AddScoped() really pays off and starts creating value when you're in a web app.

From the user's perspective, that thing in the browser looks like The Official App™. In reality, it's something a whole lot closer to a bunch of pretty darned static HTML files coupled to a whole mess of short-lived HTTP requests. The key here is that "whole mess of short-lived HTTP requests."

If you don't care how many instances of a class you have hanging around, AddTransient() is the easy option for type registration. Choose this, don't think about it, change it if it's broken. But if you want to minimize instances of a class (let's say PersonService) because they're memory intensive or you need that class to retain some state that's valuable in that request, that's why you'd choose AddScoped.

Put simply: AddScoped() means you're roughly singleton-ing that service within just the context of that one request.

It's one instance, shared by everything in the request and gone when the request is done.

And the "shared by everything" part is where the real value hides. It's not just about being thrifty with instances -- it's about shared state within the request. Say PersonService and SqlPersonRepository both take a DbContext in their constructors. DbContext is registered as scoped (that's the EF Core default, and now you know why). Because they're all living in the same scope, they get the same DbContext instance during that request. Same change tracker. Same transaction. One SaveChanges() and everything that happened in that request goes out together.

Now imagine DbContext were transient instead. PersonService and SqlPersonRepository would silently get two different contexts. Two change trackers. Two sets of pending changes. That's one of those bugs that works fine until it doesn't -- probably by throwing strangely unpredictable optimistic concurrency exceptions.

So scoped is the "everyone in this request is on the same team" lifetime. Great. But who's creating the team?

The Part ASP.NET Core Does for You
Who creates the scope? It's gotta be super complicated, right? Nope. It's easy. ASP.NET Core creates a scope for you at the start of every HTTP request as part of the middleware pipeline execution. So there's middleware sitting very early in the request pipeline whose job is basically:

  1. Request comes in.
  2. Call CreateScope() on the application's root service provider.
  3. Hang that scope's service provider on the HttpContext (that's what HttpContext.RequestServices is).
  4. Run the rest of the pipeline. Anything that gets resolved during this request -- your controller, constructor dependencies, their dependencies, all the way down -- comes out of this scope.
  5. Response goes out. Dispose the scope. Everything scoped gets cleaned up, Dispose() gets called on anything disposable (peace out, DbContext), and it's all gone.

That's it. That's the magic. "Scoped means per-request" isn't a property of the DI container -- it's a convention of the web host. ASP.NET Core decided that one HTTP request equals one scope, and it does the CreateScope() and Dispose() bookkeeping for you on every single request. The container is just doing what it always does: one instance per scope object.

Remember that WAF test code from earlier? Now it makes sense. In the test, there's no HTTP request in flight, which means there's no middleware, which means nobody created a scope for you. So you have to be the middleware. You call CreateScope(), you play the role of "one request," and now scoped services resolve exactly like they would in production. The incantation was never arbitrary -- you were manually doing the thing ASP.NET Core does automatically.

So Is AddScoped() Pointless in a CLI App?
Back to my self-directed follow-up question. In a console app, nobody's creating scopes for you -- there's no request pipeline, no middleware, nothing. So is scoped pointless there?

Not pointless. Manual.

A scope is just "one unit of work" -- and in a web app, ASP.NET Core decided the unit of work is an HTTP request. In a CLI app or a worker service, you get to decide. Processing messages off a queue? One scope per message. Chewing through a directory of files? One scope per file. Each unit of work gets its own fresh DbContext and its own little world of scoped services, all cleaned up when the scope is disposed:

foreach (var message in messages)
{
    using var scope = host.Services.CreateScope();

    var processor = scope.ServiceProvider
        .GetRequiredService<MessageProcessor>();

    await processor.ProcessAsync(message);
}

That's the same pattern as the WAF test and the ASP.NET middleware. It's scopes all the way down.

Wrapping Up
So here's where I landed after my little "wait, how does this even work" moment. AddScoped() was never really about HTTP requests. It's about units of work. A scope is just a plain old object that says "everything resolved from me is on the same team, and we all get cleaned up together." ASP.NET Core happens to create one per request because that's the natural unit of work for a web app -- but that's the web host's convention, not the container's rule.

Which means the hand-wavy "it's, uh, per-request" answer we've all been giving? It's not wrong. It's just describing the default configuration instead of the actual feature.

Every so often it's worth asking "yah...but why?" about the stuff you've typed a 100 zillion times. This time it was simpler and way more elegant than I thought it would be.

About the Author

Benjamin Day is a consultant, trainer and author specializing in software development, project management and leadership.

Posted by Benjamin Day on 08/13/2026


Keep Up-to-Date with Visual Studio Live!

Email Address*Country*
Please type the letters/numbers you see above.