Declarative C# Dependency Injection

Aaron Moore
3 min readJul 7, 2021

The dependency injection framework for C# and dotnet core, aspnetcore, .Net 5, etc. is, as provided, configured in an imperative manner. When a service is created that needs to be injected or to change how a class is injected, we then have to go manually update the dependency-injection registration, usually contained imperatively in Startup.cs. Because of this, there’s extra code to maintain and the startup file can get quite lengthy.

What I’m going to show is how to create a system to allow injection to be done through marking up classes with attributes and then scanning for those attributes once. This allows us to configure injection directly from the class we’re working with.

The source for this has been published as an open source, MIT-licensed library freely available on GitHub and Nuget: FrenziedMarmot.DependencyInjection

The first thing we want to do is create our attribute. We’re going to create it with 4 properties in order to support the type of service we’re registering, the lifetime of that injection, and then either the implementation for that service or a factory type to create it. This should support all the major concerns and needs of injection.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)]
public class InjectableAttribute : Attribute
{
public Type TargetType { get; set; }
public ServiceLifetime Lifetime { get; set; }
public Type Implementation { get; set; }
public Type Factory { get; set; }
}

To support the factory, let’s create an interface that will provide the Func<IServiceProvider, object> necessary to act as an implementation factory.

public interface IInjectableFactory
{
object Create(IServiceProvider serviceProvider);
}

The interface supplies the function necessary for it to be passed into the DI framework and an IServiceProvider is passed in so that the injection framework itself can provide any dependencies for your factory method.

Finally, we need to scan to find the usages of the attribute and define our injection as a ServiceDescriptor to the dependency injection framework.

public static class InjectionExtensions
{
public static IServiceCollection ScanForAttributeInjection(this IServiceCollection services, params Type[] representativeTypes)
{
return services.ScanForAttributeInjection(representativeTypes.Select(e => e.Assembly).Distinct().ToArray());
}
public static IServiceCollection ScanForAttributeInjection(this IServiceCollection services, params Assembly[] typeAssemblies)
{
return InjectTypes(services, typeAssemblies.SelectMany(e => e.GetTypes()).Distinct());
}

private static IServiceCollection InjectTypes(IServiceCollection services, IEnumerable<Type> types)
{
foreach (Type type in types)
{
foreach (InjectableAttribute attr in type.GetCustomAttributes<InjectableAttribute>())
{
Type target = attr.TargetType ?? type;
if (attr.Factory != null)
{
if (!typeof(IInjectableFactory).IsAssignableFrom(attr.Factory))
{
throw new ArgumentException(
@"Injectable factory for `{target.Name}` as specified must implement IInjectableFactory");
}

IInjectableFactory factory = (IInjectableFactory) Activator.CreateInstance(attr.Factory);
services.Add(new ServiceDescriptor(target, factory.Create, attr.Lifetime));
}
else
{
services.Add(new ServiceDescriptor(target, attr.Implementation ?? type, attr.Lifetime));
}
}
}

return services;
}
}

Here we’ve created an extension method that will scan any number of assemblies and inject based upon configuration provided by using the [Injectable] attribute.

This extension class:

  1. Scans the assemblies provided for all distinct types
  2. Retrieves all instances of the InjectableAttribute from the types found
  3. Uses Lifetime to define the lifetime of the injection, TargetType to define what we’re injecting, and uses either Factory or Implementation to define how it’s injected. Worth noting here is that we prioritize Factory so that if both are provided it uses the factory instead and we throw if a factory is provided that does not inherit IInjectableFactory.

From here, we can scan our assembly in Startup.cs with services.ScanForAttributeInjection(GetType().Assembly); and we can provide more assemblies as necessary.

The implication of this is that dependency injection can be mapped in a declarative manner instead of imperatively adding every single injection to the Startup file. The cost overhead is limited to a small amount of overhead using reflection on app startup.

See https://github.com/FrenziedMarmot/DependencyInjection for the published implementation of this as a library.

--

--