The Built-in DI Container
🎯 What You'll Learn
- Understanding ASP.NET Core's built-in DI container
- The IServiceCollection interface
- The IServiceProvider interface
- How service registration works
- How service resolution works
- Service descriptors
- When to use third-party containers
- Container capabilities and limitations
What is the Built-in Container?
ASP.NET Core includes a built-in DI container that manages service registration and resolution. It's lightweight, fast, and sufficient for most applications.
IServiceCollection: Used to register services
IServiceProvider: Used to resolve (retrieve) services
IServiceCollection: Registering Services
IServiceCollection is where you register your services. You access it via
builder.Services in Program.cs.
var builder = WebApplication.CreateBuilder(args);
// builder.Services is an IServiceCollection
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddSingleton<ICacheService, CacheService>();
var app = builder.Build();
Registration Methods
| Method | Lifetime | When to Use |
|---|---|---|
AddTransient<T>() |
Transient | Lightweight, stateless services |
AddScoped<T>() |
Scoped | Per-request services (most common) |
AddSingleton<T>() |
Singleton | Shared, thread-safe services |
IServiceProvider: Resolving Services
IServiceProvider is used to retrieve services from the container. ASP.NET Core
does this automatically via constructor injection, but you can also resolve manually.
Automatic Resolution (Preferred)
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
// ASP.NET Core automatically resolves IProductService
public ProductsController(IProductService productService)
{
_productService = productService;
}
}
Manual Resolution (Rare)
// In Program.cs, after building the app
var app = builder.Build();
// Get the service provider
var serviceProvider = app.Services;
// Resolve a service manually
var cacheService = serviceProvider.GetRequiredService<ICacheService>();
// Or use GetService (returns null if not found)
var optionalService = serviceProvider.GetService<IOptionalService>();
Don't inject IServiceProvider into your classes to manually resolve services.
This is the Service Locator anti-pattern. Use constructor injection instead!
How Registration Works
When you register a service, you're creating a ServiceDescriptor that tells the container:
- Service Type: The interface or abstract class (e.g.,
IProductService) - Implementation Type: The concrete class (e.g.,
ProductService) - Lifetime: How long the instance lives (Transient, Scoped, Singleton)
// This registration...
builder.Services.AddScoped<IProductService, ProductService>();
// ...creates a ServiceDescriptor like this:
new ServiceDescriptor(
serviceType: typeof(IProductService),
implementationType: typeof(ProductService),
lifetime: ServiceLifetime.Scoped
);
How Resolution Works
When ASP.NET Core needs to create an object (like a controller), it:
- Looks at the constructor parameters
- Finds the registered services for those types
- Creates instances (recursively resolving their dependencies)
- Injects them into the constructor
// 1. Registrations
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
// 2. ProductService depends on IProductRepository
public class ProductService : IProductService
{
public ProductService(IProductRepository repository) { }
}
// 3. Controller depends on IProductService
public class ProductsController : ControllerBase
{
public ProductsController(IProductService service) { }
}
// 4. When a request comes in, the container:
// a. Sees ProductsController needs IProductService
// b. Creates ProductService
// c. Sees ProductService needs IProductRepository
// d. Creates ProductRepository
// e. Injects ProductRepository into ProductService
// f. Injects ProductService into ProductsController
Registration Variations
1. Register Interface and Implementation
builder.Services.AddScoped<IProductService, ProductService>();
Request IProductService, get ProductService instance.
2. Register Concrete Type Only
builder.Services.AddScoped<ProductService>();
Request ProductService directly (no interface).
3. Register with Factory
builder.Services.AddScoped<IProductService>(serviceProvider =>
{
var repository = serviceProvider.GetRequiredService<IProductRepository>();
var logger = serviceProvider.GetRequiredService<ILogger<ProductService>>();
return new ProductService(repository, logger);
});
Use when you need custom initialization logic.
4. Register Instance
var cacheService = new CacheService();
builder.Services.AddSingleton<ICacheService>(cacheService);
Register an already-created instance (always Singleton).
Multiple Implementations
You can register multiple implementations for the same interface:
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
builder.Services.AddScoped<INotificationService, PushNotificationService>();
Injecting All Implementations
public class NotificationManager
{
private readonly IEnumerable<INotificationService> _notificationServices;
public NotificationManager(IEnumerable<INotificationService> notificationServices)
{
_notificationServices = notificationServices;
}
public async Task NotifyAllAsync(string message)
{
foreach (var service in _notificationServices)
{
await service.SendAsync(message);
}
}
}
Injecting Last Registration
public class SomeService
{
// Gets the LAST registered INotificationService (PushNotificationService)
public SomeService(INotificationService notificationService) { }
}
Service Replacement
Later registrations replace earlier ones:
builder.Services.AddScoped<IEmailService, EmailService>();
if (builder.Environment.IsDevelopment())
{
// Replace with console version in development
builder.Services.AddScoped<IEmailService, ConsoleEmailService>();
}
TryAdd Methods
TryAdd methods only register if the service isn't already registered:
builder.Services.TryAddScoped<IEmailService, EmailService>();
builder.Services.TryAddScoped<IEmailService, ConsoleEmailService>();
// Result: EmailService is registered (first one wins)
| Method | Behavior |
|---|---|
TryAddTransient |
Add if not already registered |
TryAddScoped |
Add if not already registered |
TryAddSingleton |
Add if not already registered |
TryAddEnumerable |
Add if implementation not already registered |
Container Capabilities
✅ What the Built-in Container Can Do
- Constructor injection
- Service lifetimes (Transient, Scoped, Singleton)
- Dependency resolution
- Circular dependency detection
- Disposable service cleanup
- Generic type registration
- Open generic registration
- Multiple implementations
❌ What It Cannot Do
- Property injection (without attributes)
- Method injection
- Named registrations
- Conditional registration based on context
- Interception/AOP
- Auto-registration by convention
If you need advanced features, you can replace the built-in container with:
- Autofac: Most popular, feature-rich
- Ninject: Simple, fluent API
- StructureMap: Convention-based
However, the built-in container is sufficient for 95% of applications!
Complete InvenTrack Example
var builder = WebApplication.CreateBuilder(args);
// Framework services
builder.Services.AddControllers();
// Database
builder.Services.AddDbContext<InvenTrackDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Repositories (Scoped - per request)
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Business services (Scoped)
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddScoped<IOrderService, OrderService>();
// Infrastructure services (Singleton - shared)
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddSingleton<IEmailService, SendGridEmailService>();
// Transient services (created each time)
builder.Services.AddTransient<IPdfGenerator, PdfGenerator>();
var app = builder.Build();
app.MapControllers();
app.Run();
Key Takeaways
- ASP.NET Core has a built-in DI container
- IServiceCollection is used to register services
- IServiceProvider is used to resolve services
- Registration creates a ServiceDescriptor (type, implementation, lifetime)
- Resolution happens automatically via constructor injection
- Use
AddTransient,AddScoped, orAddSingleton - Multiple implementations: inject
IEnumerable<T> - Later registrations replace earlier ones
TryAddmethods only register if not already registered- Built-in container is sufficient for most applications
- Avoid the Service Locator anti-pattern
You now understand how the built-in DI container works! In the next section, we'll dive deep into Service Lifetimes—understanding Transient, Scoped, and Singleton, when to use each, and the critical rules to avoid common pitfalls.