Dependency Injection
One of the main concepts underlying modern application development is Dependency Injection (DI). For a detailed description and information on its implementation in C#, see here.
To put web programming in context, one of the reasons languages like Java and C# are popular in application development is their support for runtime reflection. In other words, the program can inspect its own code at runtime and, for example, create object instances without being explicitly programmed to do so.
This aspect has facilitated the implementation of dependency injection in these languages, which consists of instantiating components at runtime, from the simplest to the most complex. These component instances are then provided as parameters for instantiating other components.
In the example below, you can see how a component is declared. The parameters provided to the constructor are injected by the framework when this component is requested. Note that the parameters are usually interfaces. Typically, interfaces are injected, not concrete implementations. This is because an interface can have multiple implementations that can be swapped as needed. For example, for testing purposes, the production implementation can be replaced with a test implementation to intercept service method calls.
public class InitializerWorker : BackgroundService
{
private readonly ILogger<InitializerWorker> _logger;
private readonly IServiceProvider _serviceProvider;
public InitializerWorker(ILogger<InitializerWorker> logger, IServiceProvider serviceProvider)
{
_logger = logger; // Here, the logger instance is injected.
_serviceProvider = serviceProvider; // Here, the service provider is injected to request other components at runtime on demand.
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope(); // Here, a new scope is created, useful for obtaining new instances for the duration of the request.
var userService = scope.ServiceProvider.GetService<IUserService>(); // Here, an instance for a service is requested,
// it may fail if creating the component throws an exception.
if (userService == null) // Dacă nu a fost declarată o implementare pentru acea interfață poate sa nu returneze un serviciu.
{
_logger.LogInformation("Couldn't create the user service!");
return;
}
var count = await userService.GetUserCount(cancellationToken);
if (count.Result == 0)
{
_logger.LogInformation("No user found, adding default user!");
await userService.AddUser(new()
{
Email = "admin@default.com",
Name = "Admin",
Role = UserRoleEnum.Admin,
Password = PasswordUtils.HashPassword("default")
}, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while initializing database!");
}
}
}
Any component to be used in the backend application will be instantiated through DI and must be declared in a WebApplicationBuilder object. The declaration will be done as follows, specifying the component's lifetime:
builder.Services
.AddTransient<IUserService, UserService>()
.AddTransient<ILoginService, LoginService>()
.AddTransient<IFileRepository, FileRepository>()
.AddScoped<IUserFileService, UserFileService>()
.AddSingleton<IMailService, MailService>();
There are 3 types of lifetimes for component instances:
- Singleton – Throughout the entire program, only one instance of that component is instantiated. Each time the component is requested, the same instance is returned. An example would be ILogger, which is instantiated once for each generic parameter.
- Transient – Each time a component is requested, a new instance is returned. Examples of transient components include controllers; for each HTTP request, a new controller instance is created to handle the request.
- Scoped – The instances returned are unique for each scope; within the same request, the same object reference with this lifetime will be injected. An example would be the database context.
It is worth mentioning that each injected service is an interface to which an implementation is associated at the time of declaration for DI.
Services
Typically, application logic is implemented in specialized components called services. The reason is to segregate components by functionality. A component with many responsibilities will be difficult to maintain and understand for other developers. Therefore, it is crucial that each service's responsibilities are well-defined and cohesive. For example, specific logic for users (add/modify/delete) can make up a distinct service separate from others, such as one responsible for file access.
Services are declared as implementations of interfaces to expose only what is necessary to other components using these services and to abstract part of the application logic. There is no strict rule regarding the appearance of the interface or implementation; it is up to the developer how to implement the specifications, but there must be consistency in implementation, and the logic in services should be segregated by well-defined functionalities. If a service is needed to manage user data, there should be a user service that does not perform other actions. Similarly, a notification service should handle only notifications, and other services can at most use the interface exposed by that service to reuse its functionalities.
As mentioned, services are instantiated through DI and have a defined lifetime. In most cases, you will use transient or scoped objects. Thanks to DI facilities, any declared service can be used in any other component simply by declaring it as a parameter in the constructor. This will help because once you have implemented logic somewhere, you can call it as needed in any other part of the code through injected services. In certain situations, such as for background services, you can use an IServiceProvider object injected like any other service to request an instance, as shown in the previous example, but this is a more exceptional case.
Bonus - Email Client
Within the lab code, there is also a mail service that can be configured in the appsettings.json file. If you want to test it for use, you can create an account on MailTrap and configure the credentials in the appsettings.json file. The email body can be formatted as HTML for a nicer appearance; try creating your own customized email templates. This service is a simple example to see how it is injected, where it is used, and how it can be customized for your own projects.