Skip to content

[API Proposal]: Simplifying Dependency Injection with Injectable Attributes and AddInjectables Extension Method #115092

Closed as not planned
@XmmShp

Description

@XmmShp

Background and motivation

Dependency Injection (DI) is a core feature of modern .NET applications, but manually registering services can be repetitive and error-prone. This proposal introduces a set of attributes (InjectableSingleton, InjectableScoped, InjectableTransient) and an extension method (AddInjectables) to automate service registration while maintaining compatibility with existing DI patterns.

By using these attributes, developers can declaratively specify how a class should be registered in the DI container, reducing boilerplate code and improving readability.

API Proposal

1. Base Attribute: InjectableAttribute

We define a base attribute InjectableAttribute that encapsulates common behavior for all injectable types.

[AttributeUsage(AttributeTargets.Class)]
public abstract class InjectableAttribute : Attribute
{
    /// <summary>
    /// Specifies the service lifetime.
    /// </summary>
    public ServiceLifetime Lifetime { get; }

    /// <summary>
    /// Optional array of service types for explicit registration.
    /// </summary>
    public Type[]? RegisterTypes { get; set; }

    protected InjectableAttribute(ServiceLifetime lifetime)
    {
        Lifetime = lifetime;
    }
}

2. Derived Attributes: InjectableSingleton, InjectableScoped, InjectableTransient

These attributes inherit from InjectableAttribute and provide specific lifetimes. They allow developers to use familiar naming conventions while leveraging the flexibility of the base attribute.

public class InjectableSingletonAttribute : InjectableAttribute
{
    public InjectableSingletonAttribute() : base(ServiceLifetime.Singleton) { }
}

public class InjectableScopedAttribute : InjectableAttribute
{
    public InjectableScopedAttribute() : base(ServiceLifetime.Scoped) { }
}

public class InjectableTransientAttribute : InjectableAttribute
{
    public InjectableTransientAttribute() : base(ServiceLifetime.Transient) { }
}

3. Extension Method: AddInjectables

This method scans assemblies for classes decorated with InjectableAttribute and registers them in the DI container based on their specified metadata.

public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Automatically registers services marked with [Injectable*] attributes from the calling assembly.
    /// </summary>
    public static IServiceCollection AddInjectables(this IServiceCollection services) =>
        AddInjectables(services, Assembly.GetCallingAssembly());

    /// <summary>
    /// Automatically registers services marked with [Injectable*] attributes from a specified assembly.
    /// </summary>
    public static IServiceCollection AddInjectables(this IServiceCollection services, Assembly assembly)
    {
        var types = assembly.GetTypes();
        foreach (var type in types)
        {
            var attr = type.GetCustomAttribute<InjectableAttribute>();
            if (attr is null || !type.IsClass || type.IsAbstract)
            {
                continue;
            }

            var lifetime = attr.Lifetime;
            var registerTypes = attr.RegisterTypes;

            // Default to self-registration if no RegisterTypes are specified
            if (registerTypes is null || registerTypes.Length == 0)
            {
                registerTypes = new[] { type };
            }

            if (registerTypes.Length == 1)
            {
                services.Add(new ServiceDescriptor(registerTypes[0], type, lifetime));
            }
            else
            {
                // Register the implementation type first
                services.Add(new ServiceDescriptor(type, type, lifetime));

                // Register each interface or self-type
                foreach (var regType in registerTypes)
                {
                    if (regType != type)
                    {
                        services.Add(new ServiceDescriptor(regType, sp => sp.GetRequiredService(type), lifetime));
                    }
                }
            }
        }
        return services;
    }
}

Notice that we have services.Add(new ServiceDescriptor(type, type, lifetime)); where we register the service itself. This is likely not what we want in most cases. However, if the registered type implements multiple interfaces, we have no choice but to do so.

API Usage

Marking Classes for Registration

// Registered as Singleton, defaulting to self-registration
[InjectableSingleton]
public class MySingletonService { }

// Registered as Scoped, explicitly specifying interfaces
[InjectableScoped(RegisterTypes = new[] { typeof(IServiceA), typeof(IServiceB) })]
public class MyScopedService : IServiceA, IServiceB { }

// Registered as Transient, defaulting to self-registration
[InjectableTransient]
public class MyTransientService { }

Using in Startup.cs

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Automatically register all services marked with [Injectable*] in the current assembly
        services.AddInjectables();

        // Or register services from a specific assembly
        services.AddInjectables(typeof(SomeOtherService).Assembly);
    }
}

Alternative Designs

No response

Risks

Performance Impact: Scanning large assemblies may affect startup time.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions