Most books teaching C# start with a 'Hello World' application. This simple program is used to explain concepts like namespaces, classes, Main and Console.WriteLine. When every line of the code has been dissected, it's clear how it works.
It's less obvious for an ASP.NET Core application. We are no longer invoking our code; instead, the ASP.NET Core framework is doing that for us. In this blog post, we'll look at a simple ASP.NET Core application and explain how ASP.NET Core makes it tick.
This is the code:
class Program { public static void Main(string[] args) => BuildWebHost(args).Run(); public static IWebHost BuildWebHost(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup() .Build(); } class Startup { private IConfiguration _configuration; public Startup(IConfiguration configuration) => _configuration = configuration; public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddDbContext( options => options.UseSqlServer(_configuration.GetConnectionString("Default"))); } public void Configure(IApplicationBuilder app) { app.UseMvc(); app.Run(context => context.Response.WriteAsync("Hello World!")); } } public class ToDoItem { public long Id { get; set; } public string Description { get; set; } public bool IsDone { get; set; } } public class ToDoDbContext : DbContext { public ToDoDbContext(DbContextOptions options) : base(options) {} public DbSet ToDoItems { get; set; } } [Route("/api/todo")] public class ToDoController { private readonly ToDoDbContext _dbContext; public ToDoController(ToDoDbContext context) => _dbContext = context; [HttpGet] public IEnumerable GetAll() => _dbContext.ToDoItems.ToList(); }
This application returns a list of ToDoItems from a SQL server database at the URL '/api/todo'. At other URLs, "Hello World!" is returned.
You probably recognize the patterns you copied when doing your first ASP.NET Core tutorial.
In Program.Main, we hand over control to ASP.NET Core. We can see how Main finds the Startup class via BuildWebHost. However, there doesn't seem to be a code-path leading to the ToDoController. ASP.NET Core finds this class by making use of reflection. Reflection is the ability to find information about the type in loaded assemblies at runtime.
Often, attributes are used to identify certain types or methods. In this case, our ToDoController is found by a naming convention: public classes ending with "Controller" are considered MVC Controllers. Similar, Startup’s Configure and ConfigureServices are found by their name. The Route and HttpGet attributes are used to determine the URL and HTTP method handled by ToDoController.GetAll. This reflection is done by ASP.NET Core’s MVC middleware, which is the default approach for building web APIs and applications. We’ve enabled this middleware in our application via the AddMvc and UseMvc calls.
The Configure and ConfigureServices methods serve two important, distinct goals. ConfigureServices is used to configure dependency injection (DI). And Configure is used to set up the request-handling pipeline. Let's go a bit deeper into what that means.
Dependency injection is a pattern that enables loose coupling between high-level components and the bits they need to do their work. For example, our ToDoController needs a data source for the to-do items. Instead of making a connection to the database inside the controller, we take it as a constructor argument. As a result, this important dependency is no longer hidden in the implementation but surfaces to the public API. It also allows other data sources to be easily used with the same controller. This is especially useful for testing. Instead of using a real database, we can use an in-memory object store for example.
Dependency injection in ASP.NET Core is based on the type system. In DI, a container is the name of the class responsible for providing dependencies. When asked to provide a dependency of a certain type, the container will look at the public constructors of that type. The container will try to find a constructor for which it knows how to create the argument types. For example, when we ask the container for a type of ToDoController, based on the public constructor, it will first try to figure out how to create a ToDoDbContext instance. This is where the Startup.ConfigureServices comes into play. In this method, we register additional types with the DI container. The IServiceCollection represents the types known to the container. By calling services.AddDbContext the container learns about the DbContextOptions and ToDoDbContext types needed to build the ToDoController.
Types can be registered with 3 scopes: singleton, scoped or transient. Singleton means the same instance is used for the application lifetime. Scoped means the same instance is used per HTTP request and transient means a new instance is created each time. So, when two constructor argument types depend on the same type, they will get the same instance when scoped and a unique instance when transient. AddDbContext registers DbContextOptions and ToDoDbContext with scoped lifetime. When a type is registered, it’s possible to tell the container it should create instances of a more specialized type. For example, we can register requests for ISomeFeature should instantiate ActualFeature. This enables us to change the implementation used by our dependent classes via the DI container (e.g. use ActualFeatureV2) without having to changes those classes (they depend on ISomeFeature). IServiceCollection provides a simple interface for registering types. Most methods called in ConfigureServices (like AddDbContext) are extension methods that register a number of types. When we look at the name "ConfigureServices", "Configure" reflects that we are in the process of setup and configuration and "Services" refers to the types we are registering with DI.
Notice the Startup class is using constructor injection to obtain the application configuration. The IConfiguration is added to the DI container as part of WebHost.CreateDefaultBuilder. The default builder will populate the configuration from appsettings.json, appsettings.{environment}.json and environment variables. We use the configuration to retrieve the SQL server connection string, which means it can be set via the environment variable "ConnectionStrings__Default" or as a json string property "Default" in a "ConnectionStrings" object in one of the appsettings json files. Note that the settings lookup is case-insensitive, "CONNECTIONSTRINGS__DEFAULT" works too.
Now we'll have a look at the Startup.Configure method. This method configures how our application is handling requests. Request handling is set up as a sequence of handlers (pipeline). The order we are adding things in this method is important. If we'd reverse the app.Run and app.UseMvc in ConfigureServices, each request would be replied with "Hello World!" and the '/api/todo' handler becomes inaccessible. Each handler is in control of invoking the handlers behind it (next). That means that a handler can decide for itself if it wants to invoke its own logic first and then call next; or it can invoke next first, which is typically done to wrap its behavior (e.g. catching an exception and returning an error page). UseMvc tries to find an appropriate handler and if it can't, it delegates the request to next. app.Run ignores anyone behind it and always returns "Hello World!". Most methods called in Configure are extension methods that add a request handler (middleware) to the pipeline.
That concludes our blog post. We’ve learned about the basic structure of an ASP.NET Core application and how reflection and naming conventions are used to tie things together. We’ve also learned about the role of the Startup class and its Configure and ConfigureServices methods. To learn more, check out the official ASP.NET Core documentation.
For more information about .NET Core on Red Hat platforms, visit RedHatLoves.NET.
Last updated: February 6, 2024