Section 2 of 6

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.

💡 Two Key Interfaces

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.

Program.cs C#
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)

ProductsController.cs C#
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    // ASP.NET Core automatically resolves IProductService
    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }
}

Manual Resolution (Rare)

Manual Service Resolution C#
// 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>();
⚠️ Avoid Service Locator Pattern

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:

  1. Service Type: The interface or abstract class (e.g., IProductService)
  2. Implementation Type: The concrete class (e.g., ProductService)
  3. Lifetime: How long the instance lives (Transient, Scoped, Singleton)
Registration Example C#
// 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:

  1. Looks at the constructor parameters
  2. Finds the registered services for those types
  3. Creates instances (recursively resolving their dependencies)
  4. Injects them into the constructor
Resolution Example C#
// 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

Interface + Implementation C#
builder.Services.AddScoped<IProductService, ProductService>();

Request IProductService, get ProductService instance.

2. Register Concrete Type Only

Concrete Type C#
builder.Services.AddScoped<ProductService>();

Request ProductService directly (no interface).

3. Register with Factory

Factory Method C#
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

Existing Instance C#
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:

Multiple Registrations C#
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<INotificationService, SmsNotificationService>();
builder.Services.AddScoped<INotificationService, PushNotificationService>();

Injecting All Implementations

IEnumerable Injection C#
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

Single Instance C#
public class SomeService
{
    // Gets the LAST registered INotificationService (PushNotificationService)
    public SomeService(INotificationService notificationService) { }
}

Service Replacement

Later registrations replace earlier ones:

Replacement C#
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:

TryAdd C#
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
ℹ️ Third-Party Containers

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

Program.cs (InvenTrack) C#
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, or AddSingleton
  • Multiple implementations: inject IEnumerable<T>
  • Later registrations replace earlier ones
  • TryAdd methods only register if not already registered
  • Built-in container is sufficient for most applications
  • Avoid the Service Locator anti-pattern
🎯 Next Steps

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.