Tutorials / Build a REST API with C# and .NET · Lesson 8 of 15
On this page
Service Lifetimes: Scoped
This lesson completes the set of three lifetimes. It builds on the last few lessons, so we start with a quick recap.
Recap: stateless and stateful
- A stateless class never changes after it is built, like
AppOptionsorPriceCalculator. It is safe to share, so it is a good fit for singleton. - A stateful class holds member variables that change, like a shopping cart. Sharing one between customers merges their data, so it is not a good fit for singleton.
Recap: transient
Stateful objects can be transient: a brand-new instance every time something asks. ReceiptBuilder from the transient lesson is the example. It collects a customer's lines in a list, so each customer needs a receipt builder of their own, and transient gives them one.
The gap
Transient solves the sharing problem, but two things can go wrong.
- It can be wasteful. If ten classes working on one customer's request each ask for the same kind of object, they get ten copies, when one would have done.
- It can even be wrong. Sometimes those classes need to see the same object, so that what one puts in, another can read. Separate copies cannot do that.
We cannot use singleton, because the object is stateful. A new copy for every class is not what we want either. What we need is a third lifetime, scoped. First, let us see the problem with a real example.
The problem: one request, several classes
Picture the moment a customer presses Place order. That is one request reaching our API, and several classes work on it, one after the other:
CheckoutServiceworks out the total and takes the payment.InventoryServicechecks that the books are in stock and deducts them.
Both need to know what the customer is buying. That is an Order, which holds the items for this request:
public class Order
{
public Guid Id { get; } = Guid.NewGuid();
private readonly List<string> _items = new();
public void AddItem(string item) => _items.Add(item);
public IReadOnlyList<string> Items => _items;
}
Order is stateful: _items is filled in when the request arrives. (Id is only there so we can see which order is which.)
The two services ask for an Order through the constructor, as always:
public class CheckoutService
{
private readonly Order _order;
public CheckoutService(Order order)
{
_order = order;
}
public void Process()
{
// checkout logic here: work out the total, then charge the payment
Console.WriteLine($"CheckoutService sees order {_order.Id.ToString()[..4]}: [{string.Join(", ", _order.Items)}]");
}
}
public class InventoryService
{
private readonly Order _order;
public InventoryService(Order order)
{
_order = order;
}
public void Deduct()
{
// inventory logic here: check the stock in the database, then deduct it
Console.WriteLine($"InventoryService sees order {_order.Id.ToString()[..4]}: [{string.Join(", ", _order.Items)}]");
}
}
The comments stand in for the real work. The Console.WriteLine is only there so we can see which order each class received. (The real stock count lives in the database, so InventoryService itself holds nothing that changes.)
For the shop to work, both classes must get the same order. If CheckoutService charges for a mobile phone, InventoryService must deduct that same mobile phone.
So which lifetime should Order have? We know two already. Let us see how each one fails.
Try singleton: the orders merge
To compare the lifetimes, imagine two customers placing an order one after the other. Customer A buys a mobile phone, then Customer B buys a lipstick. For each request, the order is filled in, then CheckoutService and InventoryService run, and each prints the order it received.
With AddSingleton<Order>(), there is one order for the whole app:
--- Customer A's request ---
CheckoutService sees order 164d: [Mobile phone]
InventoryService sees order 164d: [Mobile phone]
--- Customer B's request ---
CheckoutService sees order 164d: [Mobile phone, Lipstick]
InventoryService sees order 164d: [Mobile phone, Lipstick]
Inside one request the two classes agree, which is good. But Customer B's request is using order 164d, the same one as Customer A. B would be charged for A's mobile phone, and the inventory would deduct it a second time.
Try transient: the order goes missing
Now the other lifetime we know. With AddTransient<Order>(), every class that asks gets a brand-new order:
--- Customer A's request ---
CheckoutService sees order da78: []
InventoryService sees order b2ef: []
--- Customer B's request ---
CheckoutService sees order 098e: []
InventoryService sees order 9bb7: []
Customers no longer share anything, which is good. But the request filled in one order, and each service was handed a different one, da78 and b2ef, both empty. Checkout charges for nothing, and the inventory deducts nothing.
Neither lifetime works:
- Singleton shares too much. One order for every customer.
- Transient shares too little. A different order for every class, even inside one request.
What we want is in the middle: one order per request, shared by everything working on that request.
The answer: scoped
A scope is a bubble around one piece of work. The rule is simple:
- Inside one scope, everyone who asks for a scoped object gets the same instance.
- A new scope gets a new instance.
In a web API, one scope is one HTTP request. When a customer's request arrives, a new scope opens. Everything that runs for that request shares the scope's order. When the response is sent, the scope closes and the order is thrown away.
Look at the two bubbles:
- Inside A's bubble,
CheckoutServiceandInventoryServiceshare one order, so both see the mobile phone. - B's bubble is a separate scope with its own order. A's phone can never leak into it.
That is exactly what we wanted: shared inside a request, separate between requests.
Registering it
The only change is one word in the registration:
services.AddScoped<Order>();
services.AddTransient<CheckoutService>();
services.AddTransient<InventoryService>();
AddScoped is the third lifetime. (CheckoutService and InventoryService have no state of their own, so any lifetime works for them. Transient is fine.)
What you get
Run the same two requests with AddScoped<Order>() and the classes agree:
--- Customer A's request ---
CheckoutService sees order 32fb: [Mobile phone]
InventoryService sees order 32fb: [Mobile phone]
--- Customer B's request ---
CheckoutService sees order 79e8: [Lipstick]
InventoryService sees order 79e8: [Lipstick]
(Your order ids will be different. What matters is whether they match.)
Check it against the goal:
- In A's request, both classes show order
32fb. Same order, and both see the phone. - In B's request, both show order
79e8. A new order, with only the lipstick. - The two requests never share an order.
In a real web API
You do not open scopes yourself. ASP.NET Core, the framework that runs your API, opens a scope for every incoming request and closes it when the response is sent. All you write is the registration:
builder.Services.AddScoped<Order>();
After that, every class involved in a request that asks for an Order gets that request's order.
The three lifetimes side by side
| Ask yourself | Lifetime |
|---|---|
| Does this hold settings that never change while the app runs? | Singleton |
| Does this hold state that must never be shared, not even between the classes of one request? | Transient |
| Does this hold state that belongs to one request, and the classes inside that request must share it? | Scoped |
The order being processed, the logged-in user, and the delivery address typed in during checkout are all things that belong to one request. Those are the usual scoped objects.
Where scoped is used
In practice, scoped is a web feature. It works because the framework opens a scope around every request, and that is what makes "one per request" possible.
A console app or a desktop app (such as WinForms) has no requests, and nobody opens scopes for you. If you ask for a scoped object there, you do not get an error. You quietly get one shared copy for the whole app, which is how a singleton behaves, and that brings back the merged-orders problem.
So the practical advice:
- In a web API: use scoped for things that belong to one request.
- In a console or desktop app: use singleton or transient.
Recap
- Singleton shares too much for state that changes. Transient can be wasteful, and wrong when classes in one request must share the same object.
- A scope is a bubble around one piece of work. In a web API, one HTTP request is one scope.
- Scoped means: one instance per scope. Everything inside the same scope gets the same instance, and a new scope gets a new one.
- ASP.NET Core opens and closes the scope for each request for you. You only write
AddScoped. - Scoped belongs to web apps. In a console or desktop app nobody opens scopes, so a scoped object quietly behaves like a singleton.
Questions to think about
Before moving on, try answering these in your own words. There are no answers given here on purpose.
- In your own words, what is a scope? What is one scope in a web API?
- Two different classes in the same request each ask for a scoped
Order. Do they get the same order or different ones? What about a second customer's request? - Which lifetime would you give each of these, and why?
AppOptions,PriceCalculator,Order. - When we registered
Orderas transient,CheckoutServicesaw an empty order. Explain why, as if telling a friend. - Besides the order, name one other thing in an online shop that belongs to just one request. Which lifetime fits it?