The Art of Service Registration: Writing Clean and Scalable IServiceCollection Extensions

1. Introduction

IServiceCollection is an interface defined in the Microsoft.Extensions.DependencyInjection namespace. It serves as a collection of ServiceDescriptor objects, each describing how a particular service should be resolved by the DI container. And each ServiceDescriptor includes:

  • The service type (Type)
  • The implementation type or instance (Type or object)
  • The service’s lifetime (Singleton, Scoped, or Transient)

So this interface is primarily used during application startup to configure which services are available and how they should be instantiated.

We typically use the IServiceCollection inside the ConfigureServices of the Startup class (in .NET 5 and earlier) or in the Program.cs file starting from .NET 6.

Do you want to be a good trading in cTrader?   >> TRY IT! <<

In .NET 5 and earlier:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddDbContext<MyDbContext>();
    services.AddTransient<IMyService, MyService>();
}

After .NET 6

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<MyDbContext>();
builder.Services.AddTransient<IMyService, MyService>();

2. Why is IServiceCollection Important?

In both cases, services is an instance of IServiceCollection.

IServiceCollection plays such a central role in ASP.NET Core, because all services are registered through a single point of entry. This makes it easy to manage dependencies and understand what services are available in your application.

By defining extension methods on IServiceCollection, you can encapsulate feature-specific registrations into reusable modules. For example:

services.AddEmailModule();
services.AddPaymentGateway();

Each of these methods might internally register multiple services related to that module.

Most third-party libraries (like Entity Framework Core, Swagger, AutoMapper, etc.) provide extension methods on IServiceCollection. This allows developers to easily integrate complex systems with minimal effort.

Example:

services.AddEntityFrameworkSqlServer();
services.AddSwaggerGen();

We can also conditionally register services based on the environment settings

if (env.IsDevelopment())
{
    services.AddTransient<IDebugService, DebugService>();
}

This flexibility supports different behaviors across environments (development, staging, production).

IServiceCollection integrates seamlessly with the IOptions<T> pattern, which allows you to bind configuration values to strongly-typed objects and inject them into services:

services.Configure<PaymentOptions>(Configuration.GetSection("Payment"));
services.AddSingleton<IPaymentProcessor, PaymentProcessor>();

While ASP.NET Core ships with a built-in lightweight DI container, you can replace it with more advanced containers like Autofac or DryIoc. These custom containers often accept an IServiceCollection to import service registrations before switching over to their own internal mechanisms.

3. Example for using IServiceCollection

1) Create a custom authentication service

public static class AuthenticationExtensions
{
    public static IServiceCollection AddCustomAuthentication(this IServiceCollection services)
    {
        services.AddAuthentication(options =>
        {
            options.DefaultScheme = "JwtBearer";
        })
        .AddJwtBearer("JwtBearer", options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("your-secret-key")),
                ValidateIssuer = false,
                ValidateAudience = false
            };
        });
        return services;
    }
}

and use as below

services.AddCustomAuthentication();

2) Encapsulate the service registration of the infrastructure layer

public static class InfrastructureRegistration
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString("DefaultConnection")));
        services.AddScoped<IEmailSender, EmailSender>();
        services.AddScoped<ISmsService, SmsService>();
        return services;
    }
}

and use as below

services.AddInfrastructure(Configuration);

3) Encapsulate the integration of third — party libraries

public static class MediatorExtensions
{
    public static IServiceCollection AddMediatorHandlers(this IServiceCollection services)
    {
        //ingegrate the MediatR
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
        services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>));
        return services;
    }
}

and use as below

services.AddMediatorHandlers();

4) Create the service with options

public static IServiceCollection AddMyServiceWithConfig(this IServiceCollection services, Action<MyOptions> configure)
{
    var options = new MyOptions();
    configure(options);
    services.AddSingleton(options);
    services.AddTransient<IMyService, MyService>();
    return services;
}

Use as below

services.AddMyServiceWithConfig(opt =>
{
    opt.Timeout = TimeSpan.FromSeconds(30);
    opt.MaxRetries = 3;
});

5) Create the service with the environmental conditions

public static IServiceCollection AddDevelopmentOnlyServices(this IServiceCollection services, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        services.AddTransient<IDebugService, DebugService>();
    }
    return services;
}

Use as below

services.AddDevelopmentOnlyServices(env);

4. Questions

Q: What are some common mistakes people make when using IServiceCollection?

Answer:

  1. Putting all registration logic directly in ConfigureServices or Program.cs
  • ❌ Leads to bloated, unreadable startup code
  • ✅ Encapsulate registrations into modular extension methods

2. Not understanding service lifetimes

  • ❌ Registering a Scoped service into a Singleton can cause memory leaks or incorrect state sharing
  • ✅ Understand the difference between TransientScoped, and Singleton Lifetimes and use them appropriately

3. Overusing AddSingleton<T>()

  • ❌ Misuse of Singleton can lead to shared mutable state issues
  • ✅ Only use Singleton for truly global, stateless services

4. Manually registering too many concrete types

  • ❌ Writing many services.AddTransient<...> lines become hard to maintain
  • ✅ Use convention-based or reflection-based auto-registration instead

5. Ignoring registration order or conditional logic

  • ❌ Later middleware or components may depend on unregistered services
  • ✅ Be mindful of registration order, especially when integrating third-party libraries

6. Misusing IServiceProvider to manually resolve services (Service Locator Anti-pattern)

  • ❌ Manually calling provider.GetService() is an anti-pattern
  • ✅ Always rely on constructor injection instead

Q: How can you ensure your service registrations are as clean and scalable as possible?

Answer:

  1. We can encapsulate registration logic with extension methods
public static class ApplicationModuleExtensions
{
    public static IServiceCollection AddApplicationCore(this IServiceCollection services)
    {
        services.AddScoped<IMediator, Mediator>();
        services.AddTransient<IValidator<Order>, OrderValidator>();
        return services;
    }
}

Then call it below

services.AddApplicationCore()
        .AddInfrastructure(Configuration)
        .AddEmailServices();

This can keep the startup code clean and readable.

2. Organize registration by feature or layer

Group related services into modules:

services.AddDataAccess()

services.AddApplicationServices()

services.AddExternalIntegrations()

services.AddCustomMiddlewares()

This improves clarity and supports team collaboration.

3. Use configuration options (IOptions) to parameterize services

services.Configure<PaymentSettings>(Configuration.GetSection("Payment"));
services.AddSingleton<IPaymentProcessor, PaymentProcessor>();

This makes your services more configurable and testable.

4. Avoid hardcoded dependencies

  • ❌ Directly instantiating objects or using concrete types in registrations
  • ✅ Favor interfaces over implementations to improve flexibility and testability

5. Automate registration with convention-based approaches

For example, use reflection to register multiple services at once:

var assembly = typeof(MyService).Assembly;

services.Scan(scan => scan
    .FromAssemblies(assembly)
    .AddClasses(classes => classes.InNamespaces("MyApp.Application.Services"))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

6. Design for modularity with Feature Folders or Plugin architecture

  • Each module handles its own service registration
  • The main application simply composes these modules together

4. Conclusion

IServiceCollection is the central mechanism for registering services in ASP.NET Core applications, enabling dependency injection throughout the application.

It allows developers to cleanly encapsulate service registrations using extension methods, promoting modular and reusable code.

When using IServiceCollection, It’s important to understand service lifetimes (Transient, Scoped, Singleton) and to avoid anti-patterns such as registering concrete types unnecessarily or mismanaging dependencies.

It also supports conditional registration based on environment or configuration, making it flexible for different deployment scenarios.

Proper use of IServiceCollection leads to maintainable, testable, and scalable applications.

Loading

Views: 0
Total Views: 152