Overview

This note outlines how modern .NET (ASP.NET Core) handles Dependency Injection (DI) and configuration management, contrasting it with Java’s Spring ecosystem, and detailing production-grade patterns like Assembly Scanning and the Options Pattern.


Spring vs. .NET: Core Design Philosophies

The two frameworks approach Inversion of Control (IoC) from opposite design paradigms. DI Philosophy

graph TD
    subgraph Spring Ecosystem ["Spring Ecosystem"]
        A1[Classpath Scanning & Annotations] --> A2[Runtime Reflection & Magic]
        A2 --> A3[Late Failure / Boot-time Errors]
    end

    subgraph .NET Ecosystem [".NET Ecosystem"]
        B1[Explicit Code Registration] --> B2[Compile-Time Safety & Predictability]
        B2 --> B3[Early Failure / Immediate Startup Validation]
    end

DimensionSpring Ecosystem.NET Ecosystem
Core TenetConvention over ConfigurationExplicitness and Predictability
MechanismRuntime Reflection & Classpath Scanning (@ComponentScan)Imperative C# Code Registration (IServiceCollection)
Error DiscoveryOften at runtimeAt compile-time or immediate startup validation

Spring’s @Configuration Class Service Extension Methods / Startup Setup

In Spring, a class annotated with @Configuration tells the container that it contains bean definition methods. In .NET, you typically configure services directly inside Program.cs or organize them into custom extension methods on IServiceCollection.

Spring:

@Configuration
public class AppConfig {
    @Bean
    public MyService myService() {
        return new MyServiceImpl();
    }
}

.NET Equivalent (Program.cs):

var builder = WebApplication.CreateBuilder(args);
 
// Registering dependencies directly in the built-in DI container
builder.Services.AddTransient<IMyService, MyServiceImpl>();
 
var app = builder.Build();

Syntax to Create Container and Register Services

In modern ASP.NET Core (Program.cs), the container is created automatically by the WebApplication builder, and services are registered directly onto builder.Services.

C#

var builder = WebApplication.CreateBuilder(args);

// --- 1. Registering Built-In Services ---
builder.Services.AddControllers();

// --- 2. Registering Custom Services with Lifetimes ---
builder.Services.AddTransient<IMyService, MyServiceImpl>();
builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

// --- 3. Using Custom Extension Methods (passing configuration) ---
builder.Services.AddInfrastructureServices(builder.Configuration);

var app = builder.Build();

// Configure HTTP pipeline...
app.Run();

Spring’s @Bean Annotation Service Lifetime Registration

In Spring, @Bean tells the container to instantiate, configure, and manage an object. In .NET, you achieve this by registering interfaces and their concrete implementations with specific lifetimes using IServiceCollection.

.NET provides three built-in lifetimes that mirror Spring’s bean scopes:

Summary of Differences

  • Spring relies heavily on reflection and classpath scanning via annotations (@Configuration, @Bean, @Component, @Service) at runtime or startup.

  • ASP.NET Core relies on explicit code configuration via IServiceCollection. While .NET does support third-party libraries that allow attribute-based scanning (like Scrutor or Autofac), the native framework intentionally favors explicit C# code registration for better performance, compile-time safety, and predictability.


Organizing DI: Production Extension Methods

Writing hundreds of lines in Program.cs is an anti-patten. To keep your Program.cs clean, production apps group bean registrations into dedicated configuration classes (just like @Configuration), .NET developers usually write static extension methods extending IServiceCollection.

Info

It organizing Beans Like Spring @Configuration

Architecture Pattern

graph LR
    P[Program.cs] --> E1[Infrastructure Extensions]
    P --> E2[Application Extensions]
    E1 --> D1[(Database / Repositories)]
    E2 --> D2[(Validators / Services)]

Code Example

// 1. Create a static configuration class
public static class DependencyInjectionExtensions
{
    public static IServiceCollection AddApplicationServices(this IServiceCollection services)
    {
        // Equivalent to defining multiple @Bean methods
        services.AddTransient<IMyService, MyServiceImpl>();
        services.AddSingleton<ICacheService, RedisCacheService>();
        services.AddScoped<IUserRepository, SqlUserRepository>();
 
        return services;
    }
}
 
// 2. Call it in Program.cs
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddApplicationServices(); // Keeps Program.cs clean
 
var app = builder.Build();
 
// Infrastructure Layer Registration
public static class InfrastructureServiceExtensions
{
    public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddScoped<IUserRepository, SqlUserRepository>();
        return services;
    }
}

Multiple Implementations & @Primary Equivalent

If IMyService has multiple implementations (e.g., SqlService and MongoService), how does .NET handle it compared to Spring’s @Primary or @Qualifier?

The Default Behavior

If you register multiple implementations of the same interface:

services.AddTransient<IMyService, SqlService>();
services.AddTransient<IMyService, MongoService>();

.NET does allow this, and the last one registered wins if you inject a single IMyService. If you inject IEnumerable<IMyService>, .NET will inject both instances into a collection.

Equivalents to @Primary and @Qualifier

Spring Concept.NET Equivalent PatternCode Example
@PrimaryOrdering / Last RegistrationRegister the fallback/default implementation last so it overrides previous registrations.
@QualifierNamed Factory / Keyed ServicesModern .NET (since .NET 8) supports Keyed Services natively.

Modern .NET Keyed Services (Equivalent to @Qualifier)

// 1. Register with keys
services.AddKeyedTransient<IMyService, SqlService>("sql");
services.AddKeyedTransient<IMyService, MongoService>("mongo");
 
// 2. Consume using [FromKeyedServices] attribute
public class MyController : ControllerBase
{
    public MyController([FromKeyedServices("sql")] IMyService sqlService)
    {
        // Injects specifically the SqlService implementation
    }
}

Assembly Scanning

Instead of registering dozens of individual classes manually, Assembly Scanning uses reflection at startup to locate and register components automatically.

Benefit

Adding a new validator or handler requires zero updates to your dependency injection setup file.

// Automatically finds and registers every class implementing IValidator in the assembly
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
 

Assembly Scanning in .NET serves the exact same conceptual purpose as Spring’s Component Scanning (@ComponentScan, @Service, @Component).

  • Spring: Scans the classpath at runtime for classes annotated with specific stereotypes (@Service, @Repository) and registers them automatically.
  • .NET: Uses explicit library methods (like FluentValidation’s AddValidatorsFromAssembly) to inspect a compiled assembly via reflection and register matching types without manual listing.

Design Philosophy Difference

While Spring scans everything by default based on annotations, .NET usually keeps scanning explicit and opt-in per library (e.g., MediatR, FluentValidation) to avoid hidden magic and keep startup blazing fast.

IConfiguration

IConfiguration is .NET’s built-in interface for reading application settings, environment variables, connection strings, and secrets.

In Spring, you might be used to @Value("${my.property}") or application.properties/application.yml.

Info

In .NET, IConfiguration is the abstraction that represents those configuration sources (typically loaded from appsettings.json).

How it works in practice:

  1. Your appsettings.json file:

    {
      "ConnectionStrings": {
        "DefaultConnection": "Server=myServerAddress;Database=myDataBase;"
      },
      "LoggingSettings": {
        "LogLevel": "Information"
      }
    }
  2. Accessing it via IConfiguration: You inject IConfiguration into your classes (or your DI extension methods) to read these values:

    public class SqlUserRepository
    {
        private readonly string _connectionString;
     
        // IConfiguration is injected automatically by .NET
        public SqlUserRepository(IConfiguration configuration)
        {
            _connectionString = configuration.GetConnectionString("DefaultConnection");
        }
    }

Extension Method Parameters (this IServiceCollection)

Extension methods can have as many parameters as you need after the first this parameter.

  • The first parameter (prefixed with this) tells C# which type is being extended (IServiceCollection), allowing you to call it using dot notation (builder.Services.AddInfrastructureServices(...)).

  • All subsequent parameters are dependencies or configurations your extension method requires to successfully set up those services.

How to know which signature to use:

  • Use no extra parameters when your registrations are entirely self-contained (e.g., just wiring up business logic services that don’t read external files).

  • Add IConfiguration configuration when your registrations need to read connection strings, API keys, or custom configuration sections from appsettings.json in order to wire up services properly (e.g., configuring an Entity Framework DbContext with a database connection string).

The Options Pattern

Production best practice is to use Strongly Typed Configuration by reading settings securely and efficiently using strongly-typed classes instead of raw string dictionaries.

Step-by-Step Flow

graph TD
    J[appsettings.json] -->|Binds to| C[C# Class Options]
    C -->|Registered via| S[IServiceCollection.Configure]
    S -->|Injected via| I[IOptions<T> in Constructor]

Implementation

While you can grab raw strings using configuration["Key"], production .NET apps use the Options Pattern. This maps JSON sections directly to strongly-typed C# classes:

// 1. Create a class that matches your JSON section
public class JwtSettings
{
    public string Secret { get; set; }
    public int ExpiryMinutes { get; set; }
}
 
// 2. Bind it in Program.cs using IConfiguration
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));

This gives you compile-time safety and clean injection via IOptions<JwtSettings> instead of passing raw configuration dictionaries around.

  1. appsettings.json
{
  "EmailSettings": {
    "ApiKey": "secret_12345",
    "MaxRetries": 3
  }
}
  1. C# Class
public class EmailSettings
{
    public string ApiKey { get; set; }
    public int MaxRetries { get; set; }
}
 
  1. Registration (Program.cs)
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings")
);
  1. Consumption
public class EmailService
{
    private readonly EmailSettings _settings;
 
    public EmailService(IOptions<EmailSettings> settings)
    {
        _settings = settings.Value; // Strongly-typed, clean access
    }
}

Recommended Resources

Official Microsoft Documentation :

Community Guides