blog
Ottorino Bruni  

How to Implement Dependency Injection in .NET: C# Console App Guide Using Visual Studio Code

Introduction

Dependency Injection (DI) is a fundamental concept in .NET development, enabling developers to create flexible, maintainable, and testable applications. By decoupling dependencies, DI allows for better code organization and promotes adherence to SOLID principles, particularly the Dependency Inversion Principle (DIP). Previously, I discussed SOLID principles in C# and delved into the Dependency Inversion Principle.

.NET supports the Dependency Injection (DI) software design pattern, which is a technique for achieving Inversion of Control (IoC) between classes and their dependencies. Dependency Injection in .NET is a built-in part of the framework, seamlessly integrated with other key features such as configuration, logging, and the options pattern.

In this guide, we’ll explore how to implement Dependency Injection in a .NET C# console application using Visual Studio Code, providing practical examples to help you integrate DI into your projects effectively.

Understanding Service Lifetimes in Dependency Injection: Transient, Scoped, and Singleton

When using Dependency Injection (DI) in .NET, understanding the different service lifetimes is crucial for building efficient and maintainable applications. Services can be registered with one of three lifetimes: Transient, Scoped, and Singleton. Each has a specific use case and should be chosen based on how the service will be used in your application.

In my years of experience as a developer, I’ve often encountered codebases where developers have copied and pasted DI examples from the internet without fully understanding the implications of the different service lifetimes.

1. Transient

  • Definition: Transient services are created each time they are requested. This means that every time you ask the DI container for a service, a new instance is provided.
  • Usage: Use AddTransient to register services with this lifetime.
  • Example:
    • codeservices.AddTransient<IMessageService, MessageService>();
  • Scenario: This lifetime is ideal for lightweight, stateless services, such as services that handle formatting or simple calculations. Each operation needing a fresh state is a good candidate for transient lifetime.

2. Scoped

  • Definition: Scoped services are created once per client request (or connection) in web applications. This means the same instance is used throughout a single request but a new instance is created for each new request.
  • Usage: Use AddScoped to register services with this lifetime.
  • Example:
    • codeservices.AddScoped<IOrderService, OrderService>();
  • Scenario: Scoped lifetime is perfect for services that need to maintain state across a single request, like database context (DbContext). For example, in a web application, all database operations within a single HTTP request will share the same DbContext instance.Important Note: Be cautious when injecting scoped services into singleton services, as this can lead to unintended shared state issues. If a scoped service is accidentally injected into a singleton, it can cause the service to incorrectly share state across multiple requests, leading to bugs that are hard to trace.

3. Singleton

  • Definition: Singleton services are created the first time they are requested, and that single instance is reused for all subsequent requests.
  • Usage: Use AddSingleton to register services with this lifetime.
  • Example:
    • codeservices.AddSingleton<ILogger, Logger>();
  • Scenario: Singleton lifetime is suitable for services that maintain state that should be shared across the entire application, like logging services or caching mechanisms. Singleton services must be thread-safe since they are shared across multiple requests and threads.
LifetimeDescriptionCommon UsageExample ScenarioImportant Considerations
TransientA new instance is created every time the service is requested.– Stateless services
– Lightweight operations
A service that formats strings or performs calculations– No state is shared between requests.
– Per-request allocations.
ScopedA single instance is created per request or client connection (in web apps).– Database contexts
– User session services
A service managing user data for the duration of a web request– Be cautious when injecting into singletons.
– Ideal for request-specific data.
SingletonA single instance is created and shared across the entire application lifetime.– Logging
– Caching
– Configuration management
A logging service that needs to write to the same file throughout the app’s lifecycle– Must be thread-safe.
– Memory is retained until app shutdown.

Example: How to use Dependency Injection in .NET

Disclaimer: This example is intended for educational purposes. While it provides a good starting point for learning, there are always better ways to write and optimize code and applications. Use this as a base and continually strive to follow best practices and improve your implementations.

Prerequisites

Before starting, make sure you have the following installed:

  • .NET SDK: Download and install the .NET SDK if you haven’t already.
  • Visual Studio Code (VSCode): Install Visual Studio Code for a lightweight code editor.
  • C# Extension for VSCode: Install the C# extension for VSCode to enable C# support.

Step 1: Create the Solution and Project

  • Open VS Code: Launch Visual Studio Code.
  • Create a New Solution:
    • dotnet new sln -n DependencyInjectionDemo
  • Create a New Console Project:
    • dotnet new console -n DependencyInjectionDemo
  • Add the Project to the Solution
    • dotnet sln add DependencyInjectionDemo/DependencyInjectionDemo.csproj

Step 2: Install the Required Packages

To use CreateHostBuilder and the IHostBuilder pattern in your .NET console application, you need to install the Microsoft.Extensions.Hosting package, along with the Microsoft.Extensions.DependencyInjection package.

  • Install Microsoft.Extensions.Hosting Package:
  • This package provides the hosting abstractions used for building and running your application, including support for Dependency Injection, configuration, and logging.
    • dotnet add package Microsoft.Extensions.Hosting
  • Install Microsoft.Extensions.DependencyInjection Package:
  • This package provides the classes and interfaces required for Dependency Injection in .NET.
    • dotnet add package Microsoft.Extensions.DependencyInjection
Install the Required Packages

Step 3: Configure the DI Container

Modify Program.cs:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;

public interface IMessageService
{
    void SendMessage(string message);
}

public class MessageService : IMessageService
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"Message: {message}");
    }
}

class Program
{
    static void Main(string[] args)
    {
        var host = CreateHost(args);
    }

    static IHost CreateHost(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices(ConfigureServices)
            .Build();

    static void ConfigureServices(IServiceCollection services)
    {
        services.AddTransient<IMessageService, MessageService>();
    }
}

Explanation:

  • Service Registration: The AddTransient<IMessageService, ConsoleMessageService>() method registers ConsoleMessageService as the implementation of the IMessageService interface with a transient lifetime, meaning a new instance is created each time it is requested.
  • Service Resolution: The host.Services.GetRequiredService<IMessageService>() method resolves the service from the DI container.
Configure the DI Container

Step 4: Inject Dependencies

Modify the Program.cs to demonstrate constructor injection

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;

public interface IMessageService
{
    void SendMessage(string message);
}

public class MessageService : IMessageService
{
    public void SendMessage(string message)
    {
        Console.WriteLine($"Message: {message}");
    }
}

public class MessageProcessor
{
    private readonly IMessageService _messageService;

    public MessageProcessor(IMessageService messageService)
    {
        this._messageService = messageService;
    }

    public void ProcessMessage(string message)
    {
        this._messageService.SendMessage(message);
    }
}

class Program
{
    static void Main(string[] args)
    {
        var host = CreateHost(args);

        var processor = host.Services.GetRequiredService<MessageProcessor>();
        processor.ProcessMessage("Hello, Dependency Injection with Constructor Injection!");
    }

    static IHost CreateHost(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices(ConfigureServices)
            .Build();

    static void ConfigureServices(IServiceCollection services)
    {
        services.AddTransient<IMessageService, MessageService>();
        services.AddTransient<MessageProcessor>();
    }
}
Inject Dependencies

Step 5: Run the Application

In the terminal, run the following command to build and run the application:

dotnet run
Run the DependencyInjection Demo

Conclusion

Dependency Injection (DI) is a fundamental design pattern that plays a crucial role in the development of modern, maintainable, and testable applications. By decoupling the creation and management of dependencies from the business logic, DI promotes loose coupling between classes, making your code easier to manage, test, and extend.

In the .NET ecosystem, DI is deeply integrated into the framework, providing developers with powerful tools to manage dependencies without the need for external libraries. This built-in support simplifies the process of setting up DI, allowing you to focus on building robust and scalable applications.

Understanding the different service lifetimes—Transient, Scoped, and Singleton—is essential for using DI effectively. Choosing the correct lifetime ensures that your services behave as expected and that your application is optimized for performance and resource management.

Whether you’re building a small console application or a large enterprise system, leveraging DI in .NET can significantly improve your code’s structure and testability. By properly implementing DI, you lay the foundation for a codebase that is flexible, maintainable, and ready to meet the challenges of modern software development.

If you think your friends or network would find this article useful, please consider sharing it with them. Your support is greatly appreciated.

Thanks for reading!

Leave A Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.