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 AppOptions or PriceCalculator. 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.

    So far: stateless and stateful Stateless never changes Singleton one shared copy Stateful changes as it is used Transient a new copy every time

    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.
    Transient versus what we want With transient class 1 class 2 class 3 order order order What we want class 1 class 2 class 3 one order three copies, not shared one copy, shared by all three

    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:

    • CheckoutService works out the total and takes the payment.
    • InventoryService checks 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.

    Transient: each class gets its own order The request Order [Mobile phone] CheckoutService Order da78 [ ] InventoryService Order b2ef [ ] Three orders. Only the first one has the phone.

    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.

    Scoped: one order per request Customer A's request (one scope) CheckoutService InventoryService Order [Mobile phone] Customer B's request (another scope) CheckoutService InventoryService Order [Lipstick]

    Look at the two bubbles:

    • Inside A's bubble, CheckoutService and InventoryService share 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

    Singleton, transient and scoped Singleton one for the whole app the order Transient new every time new order new order new order Scoped one per request A's order B's order everyone shares it nobody shares it shared inside a request
    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.

    1. In your own words, what is a scope? What is one scope in a web API?
    2. 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?
    3. Which lifetime would you give each of these, and why? AppOptions, PriceCalculator, Order.
    4. When we registered Order as transient, CheckoutService saw an empty order. Explain why, as if telling a friend.
    5. Besides the order, name one other thing in an online shop that belongs to just one request. Which lifetime fits it?
    ← Service Lifetimes: TransientJSON →