Skip to content

Mastering Dependency Injection in C# for Better Software Architecture

Published: at 09:00 AM

Mastering DI in C# for Better Software Architecture

Dependency Injection (DI) is a critical design pattern in modern software development. In C#, it plays a crucial role in promoting loose coupling, enabling better scalability, maintainability, and easier unit testing.

In this article, we will explore the essentials of DI, how it integrates with ASP.NET Core, and the different ways to implement it in your C# projects.

Table of Contents

Open Table of Contents

What is Dependency Injection (DI)?

Dependency Injection (DI) is a design pattern that allows the inversion of control (IoC) of object creation. Instead of having classes directly create their dependencies, external entities provide those dependencies. This pattern enhances modularity and testability by decoupling the code from its dependencies.

Why Use DI?

  1. Loose Coupling: DI removes the tight coupling between components, making your system more modular and easier to maintain.
  2. Improved Testability: With DI, you can easily replace real dependencies with mock versions for unit testing.
  3. Scalability: It simplifies the integration of new services or components without requiring major code refactoring.

Inversion of Control (IoC)

DI is a subset of the broader Inversion of Control (IoC) principle, where the control of creating objects shifts from the consumer to the framework. Other IoC techniques include Service Locator and Factory Pattern, but DI is the most commonly used approach, especially in ASP.NET Core.

Types of Dependency Injection

In C#, there are three primary types of DI:

  1. Constructor Injection: Dependencies are passed via a class constructor, promoting immutability.

    Example:

    public class MyController
    {
        private readonly IMyService _service;
    
        public MyController(IMyService service)
        {
            _service = service; // Constructor Injection
        }
    }
  2. Property Injection: Dependencies are assigned through public properties, providing more flexibility.

    Example:

    public class MyController
    {
        public IMyService MyService { get; set; } // Property Injection
    }
  3. Method Injection: Dependencies are passed as method parameters, allowing them to be injected only when needed.

    Example:

    public class MyController
    {
        public void SetService(IMyService service) // Method Injection
        {
            // Use service here
        }
    }

Setting Up DI in ASP.NET Core

The Role of Program.cs in ASP.NET Core

Starting from .NET 5, Microsoft simplified ASP.NET Core project templates by removing the Startup.cs file and merging its content into Program.cs. In this new setup, the configuration of services and middleware happens in Program.cs using the top-level statements approach. Here’s an example of what the new structure looks like:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container
builder.Services.AddSingleton<IMyService, MyService>();

var app = builder.Build();

// Configure the HTTP request pipeline
app.UseRouting();

app.MapControllers();

app.Run();

Why Use Startup.cs in Large Projects?

While the new structure in Program.cs works well for smaller projects, many developers find that using a Startup.cs file is still beneficial for large, enterprise-level applications. Here’s why:

To implement a Startup.cs file in a .NET 5+ application, follow this pattern:

Adding a Startup.cs in .NET 5+ Projects

  1. Create a Startup.cs file:
public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // Configure services
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IMyService, MyService>();
    }

    // Configure middleware
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}
  1. Modify Program.cs to Use Startup.cs:
var builder = WebApplication.CreateBuilder(args);

// Use Startup.cs for configuration
builder.Services.AddControllers();

var startup = new Startup(builder.Configuration);
startup.ConfigureServices(builder.Services);

var app = builder.Build();

startup.Configure(app, app.Environment);

app.Run();

Which Approach to Choose?

Service Lifetimes in ASP.NET Core

When you register services in ASP.NET Core’s DI container, it’s important to understand the service lifetimes available. The service lifetime determines how long a service will live in your application and how instances of the service will be managed. ASP.NET Core provides three main lifetimes:

  1. Singleton:

    • Lifetime: A single instance of the service is created and shared across the entire application. The service is instantiated once when the application starts, and that same instance is reused throughout the application’s lifetime.
    • Use Case: Use Singleton for services that do not maintain any state specific to a single user or request, such as logging, caching, or configuration services.

    Example:

    services.AddSingleton<IMyService, MyService>();

    In this example, MyService is registered as a Singleton, meaning only one instance will be created and shared across all components.

  2. Scoped:

    • Lifetime: A new instance of the service is created for each HTTP request. All components that receive a Scoped service within the same request share the same instance, but a new instance is created for each new request.
    • Use Case: Use Scoped for services that need to maintain some per-request state, such as database context objects (e.g., DbContext in Entity Framework).

    Example:

    services.AddScoped<IMyService, MyService>();

    Here, MyService is scoped, meaning each HTTP request will get its own instance of the service, which will be shared across the components handling that request.

  3. Transient:

    • Lifetime: A new instance of the service is created each time it is requested. This is suitable for lightweight, stateless services where a fresh instance is needed for each operation.
    • Use Case: Use Transient for short-lived services that do not hold any state and are inexpensive to create. Avoid using Transient for services that are resource-intensive to instantiate.

    Example:

    services.AddTransient<IMyService, MyService>();

    In this case, MyService is registered as Transient, so a new instance will be created every time it is requested from the DI container.

Choosing the Right Service Lifetime

When deciding on the appropriate lifetime for your services, consider the following:

Creating and Registering Services

To inject dependencies into your components, you first need to create services and register them with the DI container.

Example: Creating a Simple Service

public interface IMyService
{
    string GetMessage();
}

public class MyService : IMyService
{
    public string GetMessage()
    {
        return "Hello from MyService!";
    }
}

Registering the Service in Startup.cs or Program.cs

// If using Program.cs directly
builder.Services.AddSingleton<IMyService, MyService>();

// Or in Startup.cs, in the ConfigureServices method
services.AddSingleton<IMyService, MyService>();

Injecting Services in Controllers

Once a service is registered, it can be injected into any class, like a controller, through constructor injection.

Example: Injecting a Service into a Controller

public class MyController : ControllerBase
{
    private readonly IMyService _myService;

    public MyController(IMyService myService)
    {
        _myService = myService;
    }

    [HttpGet]
    public IActionResult GetMessage()
    {
        var message = _myService.GetMessage();
        return Ok(message);
    }
}

In this example, MyController relies on IMyService, and since it’s registered in the DI container, ASP.NET Core automatically injects it when the controller is instantiated.

Conclusion

Dependency Injection (DI) is an essential pattern in C# for building scalable, maintainable, and testable applications. Whether you use the new Program.cs structure or the Startup.cs approach for larger projects, ASP.NET Core’s built-in DI container makes it easy to manage service lifetimes, inject dependencies into components, and decouple your code.

By implementing DI, you can guarantee that your applications are more modular and easier to maintain, no matter the project’s complexity.