Introduction
In large-scale .NET Core applications, managing dependency injection registrations efficiently is crucial for maintainability and scalability. As applications grow, the number of services and their associated dependencies increases, making manual registration cumbersome and error-prone. To address this challenge, developers often leverage conventions, reflection, or third-party libraries to automate registration processes.
In this blog post, we'll explore a clean and elegant approach to streamline dependency injection registration using custom attributes in .NET Core. By leveraging custom attributes, we can annotate our service implementation classes with metadata that dictates their lifetime scope, such as scoped, transient, or singleton. Then, we'll create a utility method that dynamically scans assemblies, identifies classes with these custom attributes, and registers them with the appropriate lifetime scope in the dependency injection container.
The source code can be downloaded from GitHub. I have used .NET 8.0 for developing this sample project.
Leveraging Custom Attributes for Metadata
Custom attributes provide a flexible way to attach metadata to classes, methods, or properties in C#. We'll define three custom attributes to represent the different lifetime scopes:
- ScopedAttribute: Indicates that a service has a scoped lifetime.
- TransientAttribute: Indicates that a service has a transient lifetime.
- SingletonAttribute: Indicates that a service has a singleton lifetime.
These attributes will be applied to our service implementation classes to specify their desired lifetime scope.
namespace CustomDIC.Core.Dependency.Attributes
{
[AttributeUsage(AttributeTargets.Class)]
public class ScopedAttribute : Attribute { }
}
namespace CustomDIC.Core.Dependency.Attributes
{
[AttributeUsage(AttributeTargets.Class)]
public class SingletonAttribute : Attribute { }
}
namespace CustomDIC.Core.Dependency.Attributes
{
[AttributeUsage(AttributeTargets.Class)]
public class TransientAttribute : Attribute { }
}
Dynamic Dependency Injection Registration
Next, we'll create a utility method, “RegisterInterfacesAndImplementations”, that dynamically scans assemblies for service implementation classes, inspects their custom attributes, and registers them with the appropriate lifetime scope in the dependency injection container.
Using reflection, we'll iterate through each type in the assembly, checking for the presence of our custom attributes. If a class has one of these attributes applied, we'll register it with the corresponding lifetime scope (scoped, transient, or singleton). If no attribute is specified, we'll default to transient registration.
using CustomDIC.Core.Dependency.Attributes;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
namespace CustomDIC.Core.Dependency.Extensions
{
public static class ServiceCollectionExtensions
{
public static void RegisterInterfacesAndImplementations(this IServiceCollection services
, string interfaceLibraryName
, string implementationLibraryName)
{
var interfaceAssembly = Assembly.Load(interfaceLibraryName);
var implementationAssembly = Assembly.Load(implementationLibraryName);
var types = implementationAssembly.GetExportedTypes().Where(type => type.IsClass && !type.IsAbstract);
foreach (var type in types)
{
var interfaceType = type.GetInterfaces().FirstOrDefault();
if (interfaceType != null && interfaceType.Assembly == interfaceAssembly)
{
var attributeNames = type.GetCustomAttributes(true).Select(attr => attr.GetType().Name).ToList();
switch (true)
{
case var _ when attributeNames.Contains(nameof(ScopedAttribute)):
services.AddScoped(interfaceType, type);
break;
case var _ when attributeNames.Contains(nameof(TransientAttribute)):
services.AddTransient(interfaceType, type);
break;
case var _ when attributeNames.Contains(nameof(SingletonAttribute)):
services.AddSingleton(interfaceType, type);
break;
default:
// Default to transient if no attribute is specified
services.AddTransient(interfaceType, type);
break;
}
}
}
}
}
}
Implementation Steps
- Define custom attributes (ScopedAttribute, TransientAttribute, SingletonAttribute) to represent lifetime scopes.
- Apply these attributes to service implementation classes to specify their lifetime scope.
- Create a utility method, “RegisterInterfacesAndImplementations”, that dynamically scan assemblies, identifies classes with custom attributes, and registers them with the appropriate lifetime scope.
- Invoke the utility method in your application's startup configuration to automate dependency injection registration.
Usage of Custom Attribute
using CustomDIC.Application.Interfaces.Infrastructure;
using CustomDIC.Core.Dependency.Attributes;
namespace CustomDIC.Infrastructure
{
[Scoped]
public class ExternalService : IExternalService
{
public async Task<string> GetExternalNameAsync()
{
throw new NotImplementedException();
}
}
}
Usage of ServiceCollection Extension method - RegisterInterfacesAndImplementations.
using CustomDIC.Core.Dependency.Extensions;
using Microsoft.Extensions.DependencyInjection;
namespace CustomDIC.Infrastructure
{
public static class InfrastructureDepedencyExtensions
{
public static void AddInfrastructureDependencies(this IServiceCollection services)
{
services.RegisterInterfacesAndImplementations("CustomDIC.Application", "CustomDIC.Infrastructure");
}
}
}
Invoke this servicecollection method in your Program.cs as below.
using CustomDIC.Application;
using CustomDIC.Data;
using CustomDIC.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
//this custom method is used to add all the dependencies
builder.Services.AddDataDependencies();
builder.Services.AddApplicationDependencies();
builder.Services.AddInfrastructureDependencies();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapGet("/Name", async (INameService nameService) =>
{
return await nameService.GetNameAsync();
})
.WithName("GetName")
.WithOpenApi();
app.Run();
Benefits
- Simplicity: By annotating service implementation classes with custom attributes, we eliminate the need for complex conventions or configurations.
- Consistency: Ensures consistent and predictable dependency injection registration across the application.
- Reduced Boilerplate: Automates the registration process, reducing the amount of boilerplate code and manual configuration.
- Maintainability: Simplifies future changes and additions to service registrations, promoting code maintainability and scalability.
Conclusion
By leveraging custom attributes and dynamic registration, we've demonstrated a clean and efficient approach to managing dependency injection in .NET Core applications. This method provides a structured and maintainable way to define and register services with the desired lifetime scope, resulting in more robust and scalable applications.
In this blog post, I've demonstrated how to simplify dependency injection registration in .NET Core using custom attributes. By leveraging custom attributes and dynamic registration, developers can streamline the process of defining and registering services with the desired lifetime scope, resulting in cleaner, more maintainable code.