Dependency Injection in .NET

Whether you're an aspiring junior developer embarking on your .NET journey or a seasoned senior engineer seeking to reinforce your fundamental knowledge, one skill that undoubtedly stands out is dependency injection. In the ever-evolving world of software development, the ability to effectively implement and utilize dependency injection has become a crucial asset. By mastering this technique, you can enhance your code's modularity, maintainability, and testability, making it an essential skill for developers at all levels.

What is Dependency Injection?

To begin our exploration, you might ponder the question: What exactly is dependency injection? While you may have encountered this term in numerous discussions and perhaps even implemented it in your projects, can you confidently articulate its meaning?

At its core, dependency injection is a powerful design pattern that offers several key benefits for modern software development, particularly in large-scale enterprise applications. By employing dependency injection, you can achieve loose coupling, enhanced testability, and ease of code maintenance.

In traditional development, instantiating a class using its constructor can be straightforward. However, as your codebase grows and the class is utilized in numerous places, calling the constructor multiple times becomes cumbersome. Furthermore, when changes or refactoring are required in the constructor or class, manually updating every instantiation throughout the codebase can be time-consuming and error-prone. These manual edits can accumulate and impact crucial development time.

This is precisely where dependency injection proves its value. Dependency injection eliminates the burden of manually instantiating a class and passing it to every dependent component. Instead, it handles the instantiation and injection of dependencies automatically. By leveraging dependency injection frameworks or techniques, you can define how dependencies are resolved and let the framework manage and inject the instances wherever required.

By utilizing dependency injection, you centralize the configuration and management of dependencies, making it easier to introduce changes or updates. When a modification is needed in a constructor or class, you only need to update the configuration in one place rather than searching and modifying every instantiation throughout the codebase. This significantly reduces the potential for errors and saves precious development time.

Service Types

When injecting dependencies into a class, three commonly used service types serve different purposes based on the specific use case. While these service types might be unclear, we will explore them further during the upcoming demonstration, which will help solidify their relevance and usage.

Transient Services

Transient services are widely utilized in dependency injection and are typically straightforward to grasp. A new object is created and provided to the consuming class when a transient service is requested. This distinctive behavior of instantiating a new object upon each request makes transient services particularly suitable for stateless and lightweight dependencies.

Transient services excel in scenarios where a fresh instance is desired whenever the service is needed. Since a new object is generated for each injection, there is no shared state or data between different service usages. Consequently, transient services are well-suited for situations where the behavior of the service should remain independent and unaffected by previous invocations.

Singleton Services

Singleton services represent a different approach compared to transient services. Although they are relatively easy to understand, their usage is distinct. Instead of creating a new object for every request, a singleton service instantiates an object once and then reuses it for subsequent requests.

The concept of a singleton revolves around ensuring that only a single instance of a particular service exists throughout the application's lifetime. This means the same instance is returned whenever the service is requested, promoting consistency and shared state among different components.

Utilizing a singleton service can achieve benefits such as improved performance and shared data. Since the object is instantiated only once, the overhead of creating multiple instances is eliminated, resulting in optimized resource usage. Additionally, the shared instance allows for the preservation of state and the ability to share data across various application parts.

It's worth noting that while singleton services offer these advantages, they should be used judiciously. Care must be taken to ensure that the shared state does not lead to unexpected side effects or concurrency issues. Moreover, dependencies within singleton services should be carefully managed to avoid tight coupling and potential challenges during testing or maintenance.

In summary, singleton services differ from transient services in that they instantiate an object once and reuse it for subsequent requests. They provide benefits such as improved performance and shared state. However, caution must be exercised to mitigate potential issues associated with shared state and tight coupling.

Scoped Services

After familiarizing yourself with the services above, you might have realized that certain situations call for objects that retain state but not throughout the entire application lifecycle. This is where the third and final service type, scoped service, becomes relevant. Scoped services instantiate a single object per client or scope.

Scoped services provide a middle ground between singleton and transient services. They allow you to maintain state and share data within a specific context or scope, such as a web request or an operation, without persisting the object indefinitely. This approach strikes a balance between reducing the number of object instantiations (like singleton services) and enabling efficient garbage collection.

By utilizing scoped services, you can achieve improved performance compared to transient services since you minimize the need for frequent object creation. At the same time, you can leverage the benefits of garbage collection, allowing objects to be disposed of when they are no longer needed within the scope.

Scoped services are beneficial when you require stateful behavior within a specific context, such as handling user sessions in a web application or managing resources during an operation. By limiting the object's lifespan to the scope in which it is created, you can ensure that state is maintained appropriately and resources are managed efficiently.

It's important to note that the exact definition and lifecycle of scoped services may vary depending on the dependency injection framework or container being used. Understanding the framework's specific behavior and configuration options will allow you to leverage scoped services effectively.

In summary, scoped services offer a valuable solution when you need objects to retain state within a specific scope or client. By balancing singleton and transient benefits, scoped services enable efficient memory usage and manage resources within defined contexts or operations.

Why Should You Use Dependency Injection?

Loose coupling refers to reducing the interdependencies between different components or modules of an application. In traditional tightly coupled designs, classes often directly instantiate their dependencies, leading to code that is difficult to change or update. However, with dependency injection, the dependencies of a class are provided from external sources, making the class less reliant on specific implementations. This loose coupling allows for greater flexibility and modularity in the application architecture.

Another significant advantage of dependency injection is improved testability. You can easily substitute actual dependencies with mock objects or test doubles during unit testing by injecting dependencies into classes. This decoupling allows for isolated testing of individual components, facilitating more comprehensive and reliable testing practices. As a result, you can confidently test the behavior of your code without being hindered by complex and interconnected dependencies.

As applications grow in size and complexity, the ability to update or modify specific components becomes vital. With dependency injection, changes can be localized to individual classes or modules, as dependencies can be swapped or reconfigured without impacting the entire system. This flexibility reduces the risk of introducing bugs or unintended side effects, making maintenance and future updates more manageable. Furthermore, dependency injection dramatically enhances the maintainability of your codebase.

Dependency injection promotes the reuse of components and dependencies. You can easily reuse existing implementations in different contexts or scenarios by injecting dependencies. This encourages code reuse, reduces duplication, and fosters a more modular and scalable architecture. It also promotes the separation of concerns and the creation of smaller, focused components that can be independently developed, tested, and maintained.

Dependency injection allows for flexible and interchangeable components. You can easily switch implementations or introduce new ones without modifying the dependent classes by abstracting dependencies behind interfaces or abstractions. This flexibility enables you to adapt your application to changing requirements, integrate new features, or leverage different implementations of dependencies, such as using separate databases or external services.

Dependency Injection Demo

I'll provide a step-by-step demonstration to help you understand the concept. You can skim through the demo if you prefer a more concise overview. The demo will be divided into two parts. First, I'll be able to guide you through injecting a dependency into a class. After that, I'll showcase the distinctions between transient, singleton, and scoped services.

ASP.NET Core has built-in support for dependency injection, making it seamless to use in your applications. By default, ASP.NET Core includes a dependency injection container that can be readily utilized without additional configuration. Without setup or configuration steps, you can leverage dependency injection in your ASP.NET Core projects.

However, it's worth noting that you'll need to set up and configure a dependency injection container for other frameworks which do not have built-in dependency injection support WPF or WinForms. In these scenarios, you'll typically need to choose a third-party dependency injection container, such as Autofac, Ninject, or Unity, and configure it according to your application's needs.

These third-party containers provide the necessary infrastructure and configuration options to enable dependency injection in WPF or WinForms applications. You'll need to register your dependencies, define their lifetimes, and specify how they should be resolved.

Part 1

To get started, let's create a new Blazor Server project. The project details are as follows:

Project Name: DemoDependencyInjection Project Type: Blazor Server App .NET Version: 7 Authentication: No Authentication HTTPS Configuration: Configure for HTTPS

Once you have created the project with these settings, there are a few additional steps to prepare it for the demo:

Step 1: Delete the Data Folder In the project structure, locate the "Data" folder and delete it. This step is necessary because we won't be able to use it for this demo.

Step 2: Delete the FetchData Page. Locate the "Pages" folder in the project structure and delete the "FetchData.razor" file. This file won't be required for the demo, so that we can remove it.

Step 3: Remove or Comment Out Lines in program.cs Open the program.cs file in your project. Locate lines 3 and 10, which may contain code related to the "FetchData" page. Either remove these lines or comment them out by adding "//" at the beginning of each line. This ensures the project doesn't reference the deleted "FetchData" page.

After completing these steps, your Blazor Server project (DemoDependencyInjection) will be ready for the upcoming demonstration.

Now as you can see in the screenshot above, you can observe that dependency injection is already being used in this application. The configuration for dependency injection can be seen in lines 5 to 11. For example, line 8, builder.Services.AddSingleton<WeatherForecastService>(); is responsible for injecting the WeatherForecastService class into the application. However, since we have removed the WeatherForecastService class, we must also remove this line.

Create a new empty class, and name it demo. Create two properties, an int named Rand and a DateTime named Timestamp, both of which have private setters. Finally, in the constructor, set the value of Rand to a random number between 1, and 1001 and the value of Timestamp to the current time. In the end, it should look like the code below.

public class Demo
{
    public int Rand { get; private set; }
    public DateTime TimeStamp { get; private set; }

    public Demo()
    {
        Rand = Random.Shared.Next(1,1001);
        TimeStamp = DateTime.Now;
    }
}

First, we'll look at using the class without dependency injection, and then we'll use the DI to simplify things. In the index.razor file, remove everything except the @page "/" & <PageTitle> tag. At the top of the file, add a using statement to import the class we created earlier. Add a code block at the bottom of the file and instantiate a new demo object. In the end, it should look like the code below. Finally, add two <h2> tags that contain the two fields.

@page "/"

<PageTitle>Index</PageTitle>

<h4>TimeStamp: @demo.TimeStamp</p>
<h4>Random: @demo.Rand</p>

@code{
    Demo demo = new();
}

Now you can just run your application. After a moment, it should open the Blazor page in your browser. You should be able to see both fields, similar to the screenshot below. If you refresh or change the page, you should see the values change.

The implementation above represents the traditional approach. It can make testing and maintaining the class difficult, especially with larger codebases. Now we'll take a look at the dependency injection approach. To begin, you need to create an interface for the demo class. Technically, this is optional, but it's the approach you'll see most of the time.

To quickly create an interface, follow the following steps:

  • Head back to the demo class

  • Right-click on the class name,

  • Press "Quick Actions and Refactoring"

  • Click "Extract Interface".

  • Note: Make sure Demo is implementing IDemo

After you have an interface, return to the program.cs file; add the following line builder.Services.AddTransient<IDemo, Demo>(); . It's as easy as that to add a dependency. Remember how we discussed services earlier? You can change which service you use by which method you call. We'll look into that further later.

You must return to the Index.razor to use the dependency. Remove the code block at the bottom of the file. Add the following line at the top of the file, @inject IDemo demo;. It should resemble the code below.

@page "/"
@inject IDemo demo;

<PageTitle>Index</PageTitle>

<h4>TimeStamp: @demo.TimeStamp</h4>
<h4>Random: @demo.Rand</h4>

If you rerun your code, you'll see it looks the same. That's because, in many ways, it operates identically. The benefits come in testing and maintainability. For instance, in the future, you can change out the 'Demo' class for any other class as long as it implements IDemo. You'll only need to change it in program.cs and will see the change in any class that depends on it.

Part 2

We'll now take a look at how the different services operate. First, we'll set up the demo; then, I'll connect the examples to their service counterparts. To begin, I'll want you to make three copies of the IDemo interface. Change the names to IDemoTransient, IDemoScoped, and IDemoSingleton accordingly. Make sure that Demo implements each of them. Note: It's important to note that these interfaces aren't best practices, but for this demo, they'll work.

After you finish the interfaces, return to program.cs and inject each interface with their perspective service. Once finished, it should look like the code below.

builder.Services.AddTransient<IDemoTransient, Demo>();
builder.Services.AddScoped<IDemoScoped, Demo>();
builder.Services.AddSingleton<IDemoSingleton, Demo>();

Go to Index.razor and retrieve and display these dependencies. You can look at the code snippet below if you need clarification.

@page "/"
@inject IDemoTransient transient;
@inject IDemoScoped scoped;
@inject IDemoSingleton singleton;


<PageTitle>Index</PageTitle>
<h2>Transient</h2>
<h4>TimeStamp: @transient.TimeStamp</h4>
<h4>Random: @transient.Rand</h4>

<h2>Scoped</h2>
<h4>TimeStamp: @scoped.TimeStamp</h4>
<h4>Random: @scoped.Rand</h4>

<h2>Singleton</h2>
<h4>TimeStamp: @singleton.TimeStamp</h4>
<h4>Random: @singleton.Rand</h4>

Now, you can just run your application and return to your browser. Look at the timestamps and random numbers. Click on the counter button, and then return to the home page. You'll notice transient changes, but the other two remain the same. You'll need to open another tab and copy the address to see the difference between singleton and scoped. When the page loads up, you'll notice that both transient and scoped are different while singleton remains the same. I'll illustrate this in the screenshot below.

As you can see, singleton remains the same between clients. Scoped remains the same for each client, and the transient is new with each request. It's important to note that refreshing the browser is a new client. However, switching the page does not.

Conclusion

In conclusion, dependency injection is a powerful technique that enhances your codebase's modularity, testability, and maintainability. By leveraging dependency injection, you can reduce coupling, improve testability, promote code reuse, and facilitate the management of dependencies. Understanding the different service types, such as transient, singleton, and scoped, allows you to choose the appropriate lifetime for your dependencies and optimize resource usage.

I hope this demonstration has given you a solid understanding of dependency injection and its benefits. Happy coding!