Skip to content

6. Data Scopes

A web application serves many users at once, and each user makes numerous requests. Data can therefore be shared at three levels, known as its scope:

  • application scope: the data is shared by all users throughout the server’s lifetime (the list of doctors, for example);
  • request scope: the data exists only for the duration of a request (a view’s model);
  • client scope: the data is specific to a user and must persist from one request to the next (the selected language, the logged-in user).

The original course ([ASP.NET MVC], 2013) used a session for this last scope: a memory space, maintained by the server, specific to each browser. [ASP.NET Core] still uses this approach ([AddSession]), but this document, like the case study, does without it: client-scope data is stored by the browser in cookies. The server does not retain any data between requests from the same user: it is said to be stateless ([stateless]). This is what makes it possible, for example, to run multiple identical servers behind a load balancer. The trade-off: a cookie is under the user’s control, and the user can read and modify it. Therefore, only non-sensitive data—or data signed or encrypted by the server, which can then detect any modifications—is stored there: this is the case for the [TempData] cookie (Example 20) and the authentication token (Chapter 8).

On the server side, the application and request scopes correspond to the lifetimes of dependency injection services (see Example 06). [ASP.NET Core] has three of them:

Registration
Lifetime
An instance...
AddSingleton
application
for the entire lifetime of the application, shared by all requests
AddScoped
request
per request HTTP, shared by all who request it during that request
AddTransient
none
For each injection: two requests, two instances

6.1. Example 23: three scopes

The file [Services/CounterService.cs]:

namespace Example23.Services;

// APPLICATION scope (AddSingleton): a single instance, shared by
// ALL the requests of ALL the users
public class CounterService
{
    private int total;

    // several requests can run AT THE SAME TIME: Interlocked
    // guarantees that two simultaneous increments are not lost
    public int Increment() => Interlocked.Increment(ref total);
}
  • Line 7: The service will be a singleton (see [Program.cs]): its field [total] therefore has application scope;
  • line 11: a web server processes multiple requests simultaneously, across multiple threads ([threads]). If two requests execute [total++] at the same time, they may both read the same value, and an increment is lost. [Interlocked.Increment] performs the read, addition, and write in a single, indivisible operation. A singleton service must always be wary of such simultaneous accesses.

The file [Services/RequestService.cs]:

namespace Example23.Services;

// REQUEST scope (AddScoped): a NEW instance for each HTTP request,
// shared by all those who ask for it DURING this request
public class RequestService
{
    private static int createdInstances; // static: a counter shared by all the instances

    public int Number { get; } = Interlocked.Increment(ref createdInstances);
}
  • line 7: A field named [static] belongs to the class, not to an instance; it is shared by all instances. It counts the number of instances created;
  • line 9: each instance is assigned a number upon creation. The service will have request-level scope: the number changes with each request.

The file [Services/ToolService.cs]:

namespace Example23.Services;

// TRANSIENT scope (AddTransient): a NEW instance for EACH injection,
// even during a single request
public class ToolService
{
    private static int createdInstances;

    public int Number { get; } = Interlocked.Increment(ref createdInstances);
}

The same instance counter, for a [transient] service.

The file [Program.cs]:

using System.Text.Encodings.Web;
using System.Text.Unicode;
using Example23.Services;
using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
// the three lifetimes of a service
builder.Services.AddSingleton<CounterService>(); // one instance for the whole application
builder.Services.AddScoped<RequestService>();    // one instance per request
builder.Services.AddTransient<ToolService>();    // one instance per injection

var app = builder.Build();

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "common", "bootstrap"))),
    RequestPath = "/bootstrap",
});

// REQUEST scope: HttpContext.Items is born and dies with the request
app.Use(async (context, next) =>
{
    context.Items["start"] = DateTime.Now.ToString("HH:mm:ss.fff");
    await next(context);
});

app.MapControllers();
app.Run();
  • Lines 10–12: the three lifetimes;
  • Lines 23–26: A middleware stores the request's reception time in ***[HttpContext.Items], a dictionary that is created and destroyed along with the request. Everything that processes the request (filters, controller, view) can read from and write to it. This is the equivalent of [res.locals]* for [Express].

The file [Controllers/ScopesController.cs]:

using Example23.Services;
using Microsoft.AspNetCore.Mvc;

namespace Example23.Controllers;

public record ScopesModel(int Total, int Instance, int Tool, int MyVisits);

public class ScopesController(CounterService counter, RequestService request, ToolService tool) : Controller
{
    [HttpGet("/")]
    public IActionResult Scopes()
    {
        // CLIENT scope: a piece of data kept by the BROWSER (cookie),
        // sent back to the server with each request
        int.TryParse(Request.Cookies["myVisits"], out var myVisits);
        myVisits++;
        Response.Cookies.Append("myVisits", myVisits.ToString(),
            new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Lax });
        return View(new ScopesModel(counter.Increment(), request.Number, tool.Number, myVisits));
    }
}
  • line 8: the three services are injected;
  • lines 15–18: the client scope: a counter stored in a cookie. [Request.Cookies["myVisits"]] reads the cookie sent by the browser ([null] on the first visit). [int.TryParse] converts it to an integer; if the conversion fails, [myVisits] is set to 0. The action increments it and resets the cookie;
  • line 19: the values are passed to the view.

The file [Views/Scopes/Scopes.cshtml]:

@model ScopesModel
@* @inject: the view also asks for the services, during the SAME request *@
@inject RequestService RequestOfTheView
@inject ToolService ToolOfTheView
<h1 class="h3">The scopes of data</h1>
<ul>
  <li>application: @Model.Total visit(s) to the site, all browsers combined</li>
  <li>request: instance no. @Model.Instance of RequestService (no. @RequestOfTheView.Number in the view),
    request received at @Context.Items["start"]</li>
  <li>transient: instance no. @Model.Tool of ToolService (no. @ToolOfTheView.Number in the view)</li>
  <li>client: @Model.MyVisits visit(s) from this browser</li>
</ul>
  • lines 3–4: the [@inject Type Name] directive requests a service from the dependency injector and stores it in a view property. The view is executed during the same request as the action: we will be able to compare the instances received by the controller and by the view;
  • Line 9: The request-scope value, set by the middleware.

A page, at URL http://localhost:5000/:

Image

Let’s simulate two browsers using [curl] and two cookie files, [a.txt] and [b.txt]. Browser A makes two requests, then browser B makes one, and finally A makes one more:

1
2
3
4
curl -c a.txt -b a.txt http://localhost:5000/
curl -c a.txt -b a.txt http://localhost:5000/
curl -c b.txt -b b.txt http://localhost:5000/
curl -c a.txt -b a.txt http://localhost:5000/
browser A:
application: 1 visit(s) to the site, all browsers combined
request: instance no. 1 of RequestService (no. 1 in the view), request received at 12:19:46.574
transient: instance no. 1 of ToolService (no. 2 in the view)
client: 1 visit(s) from this browser

browser A:
application: 2 visit(s) to the site, all browsers combined
request: instance no. 2 of RequestService (no. 2 in the view), request received at 12:19:46.671
transient: instance no. 3 of ToolService (no. 4 in the view)
client: 2 visit(s) from this browser

browser B:
application: 3 visit(s) to the site, all browsers combined
request: instance no. 3 of RequestService (no. 3 in the view), request received at 12:19:46.687
transient: instance no. 5 of ToolService (no. 6 in the view)
client: 1 visit(s) from this browser

browser A:
application: 4 visit(s) to the site, all browsers combined
request: instance no. 4 of RequestService (no. 4 in the view), request received at 12:19:46.696
transient: instance no. 7 of ToolService (no. 8 in the view)
client: 3 visit(s) from this browser

(only the items in the list are shown). We observe that:

  • the application counter counts all visits: 1, 2, 3, 4;
  • each request has its own instance of [RequestService]: 1, 2, 3, 4. During a request, the controller and the view receive the same instance;
  • each injection of [ToolService] creates an instance: the controller and the view receive two different ones, and each request creates two new ones;
  • the client counter is specific to each browser: A has 3 visits, B has 1.

The cookie is controlled by the client, which can send whatever value it wants:

curl -i -b "myVisits=41" http://localhost:5000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Set-Cookie: myVisits=42; path=/; samesite=lax; httponly

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Scopes</title>
  <link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">The scopes of data</h1>
<ul>
  <li>application: 6 visit(s) to the site, all browsers combined</li>
  <li>request: instance no. 6 of RequestService (no. 6 in the view),
    request received at 12:19:47.374</li>
  <li>transient: instance no. 11 of ToolService (no. 12 in the view)</li>
  <li>client: 42 visit(s) from this browser</li>
</ul>

  </div>
</body>
</html>

For a visit counter, this is irrelevant. For sensitive data (the user’s identity, their role), the server must be able to detect tampering: Chapter 8 shows how.

A rule follows from these lifetimes: a service must not depend on a service with a shorter lifetime. A singleton that receives a service such as [Scoped] would retain it indefinitely, well beyond the request for which it was created. [ASP.NET Core] checks for compliance with this rule at startup, in development mode, and refuses to start if the rule is violated. In the case study:

Scope
Data
Where?
application
lists of doctors and clients, dictionaries, pending CAPTCHAs, attempt counters
Singleton services ([ListCache], [Translator], [CaptchaService]...)
Query
database access ([DbContext]), repositories, business layer, common view model; logged-in user
[Scoped] services ([RdvMedecinsDbContext], [BusinessService], [ApplicationModel]...) ; [HttpContext.User], [HttpContext.Items]
client
authentication token, language, flash message
cookies