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
| Dimension | Spring Ecosystem | .NET Ecosystem |
|---|---|---|
| Core Tenet | Convention over Configuration | Explicitness and Predictability |
| Mechanism | Runtime Reflection & Classpath Scanning (@ComponentScan) | Imperative C# Code Registration (IServiceCollection) |
| Error Discovery | Often at runtime | At 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:
Transient (
AddTransient)A new instance is created every time it is requested. (Similar to Spring’s
prototypescope).
Scoped (
AddScoped)A single instance is created per client request (HTTP request). This is the default for most web applications, roughly analogous to Spring’s
requestor HTTP-bound scopes.
Singleton (
AddSingleton)A single instance is created the first time it is requested and lives for the entire lifetime of the application. (Identical to Spring’s default
singletonscope).
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 Pattern | Code Example |
|---|---|---|
@Primary | Ordering / Last Registration | Register the fallback/default implementation last so it overrides previous registrations. |
@Qualifier | Named Factory / Keyed Services | Modern .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,
IConfigurationis the abstraction that represents those configuration sources (typically loaded fromappsettings.json).
How it works in practice:
-
Your
appsettings.jsonfile:{ "ConnectionStrings": { "DefaultConnection": "Server=myServerAddress;Database=myDataBase;" }, "LoggingSettings": { "LogLevel": "Information" } } -
Accessing it via
IConfiguration: You injectIConfigurationinto 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 configurationwhen your registrations need to read connection strings, API keys, or custom configuration sections fromappsettings.jsonin 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.
appsettings.json
{
"EmailSettings": {
"ApiKey": "secret_12345",
"MaxRetries": 3
}
}- C# Class
public class EmailSettings
{
public string ApiKey { get; set; }
public int MaxRetries { get; set; }
}
- Registration (
Program.cs)
builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection("EmailSettings")
);- 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 :
- Dependency Injection in ASP.NET Core - The definitive guide on lifetimes and container usage.
- Options Pattern in .NET - Deep dive into strongly-typed configuration binding and validation.
- Dependency injection in .NET (General Concepts)
Community Guides