Skip to content

DI Basics Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

Dependency injection interview questions — IServiceCollection, constructor injection, open generics, keyed services, and common DI anti-patterns.

Read the in-depth guideDependency Injection in .NET Core(opens in new tab)
15 of 15

Dependency injection (DI) is a design pattern where an object's dependencies are provided (injected) rather than created by the object itself. ASP.NET Core ships with a built-in IoC container that manages object creation and lifetime.

// WITHOUT DI — tight coupling; impossible to swap or test in isolation:
public class OrderService
{
    // hardcoded — can't change implementation or mock in tests
    private readonly EmailSender _emailSender = new EmailSender();
}

// WITH DI — the container creates and injects the dependency:
public class OrderService
{
    private readonly IEmailSender _emailSender;

    public OrderService(IEmailSender emailSender) // injected by the container
    {
        _emailSender = emailSender;
    }
}

// Wire-up in Program.cs:
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
// Now the container resolves SmtpEmailSender whenever IEmailSender is needed.
// In tests, inject FakeEmailSender instead — no code change in OrderService.

Benefits:

  • Testability — swap real implementations with fakes/mocks without touching consumers.
  • Loose coupling — classes depend on abstractions, not concrete types.
  • Lifetime management — the container controls creation and disposal.
  • Single responsibility — classes focus on their job; the container owns wiring.

Rule of thumb: If a class creates its dependencies with new, it can't be tested in isolation and can't be configured from outside. Inject dependencies instead.

IServiceCollection is the container builder — a mutable list of ServiceDescriptor entries that each describe a service type, implementation, and lifetime. After builder.Build() is called, it produces an immutable IServiceProvider (the resolver).

var builder = WebApplication.CreateBuilder(args);

// Interface → concrete type (preferred — keeps consumers decoupled):
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

// Concrete type only (useful for internal/infrastructure classes):
builder.Services.AddSingleton<GreeterService>();

// Factory delegate (for complex construction logic):
builder.Services.AddSingleton<IConnectionFactory>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new SqlConnectionFactory(config["ConnectionStrings:Default"]);
});

// Pre-built instance (rarely needed — loses lifetime management):
builder.Services.AddSingleton<ICache>(new RedisCache("localhost:6379"));

var app = builder.Build(); // freezes the container — no more registrations after this

Resolving services:

// GetRequiredService<T> — throws InvalidOperationException if not registered:
var svc = app.Services.GetRequiredService<IOrderService>(); // prefer this

// GetService<T> — returns null if not registered (easy to miss):
var svc = app.Services.GetService<IOrderService>(); // avoid in application code

Rule of thumb: Register against interfaces, not concrete types. This keeps consumers decoupled and lets you swap implementations without changing call sites.

Constructor injection is the default DI style. The container inspects the class's public constructor, resolves each parameter type from the registered services, and passes them in. If a parameter type isn't registered, the container throws at resolve time.

public class CheckoutController : ControllerBase
{
    private readonly IOrderService _orders;
    private readonly IPaymentGateway _payments;
    private readonly ILogger<CheckoutController> _logger;

    // All three resolved and injected by the container — no manual wiring:
    public CheckoutController(
        IOrderService orders,
        IPaymentGateway payments,
        ILogger<CheckoutController> logger)
    {
        _orders = orders;
        _payments = payments;
        _logger = logger;
    }

    [HttpPost("checkout")]
    public async Task<IActionResult> Checkout([FromBody] CartDto cart)
    {
        _logger.LogInformation("Checkout: {Count} items", cart.Items.Count);
        var order = await _orders.CreateAsync(cart);
        await _payments.ChargeAsync(order);
        return Ok(order);
    }
}

// In a test — inject fakes directly; no container needed:
var sut = new CheckoutController(
    new FakeOrderService(),
    new FakePaymentGateway(),
    NullLogger<CheckoutController>.Instance);

Why constructor injection beats alternatives:

  • Explicit — all dependencies visible in the constructor signature.
  • Required — object can't be built without its dependencies; no partial state.
  • Testable — pass fakes directly to the constructor in tests.
  • Container-free — the class doesn't reference IServiceProvider at all.

Rule of thumb: Inject through the constructor. If the parameter list exceeds 4–5 items, that's a signal the class has too many responsibilities.

IServiceProvider is the read-only runtime resolver that creates and returns registered service instances. Injecting it into application classes is the Service Locator anti-pattern — it hides dependencies and breaks testability.

// Anti-pattern — dependencies hidden inside method bodies:
public class ReportService
{
    private readonly IServiceProvider _sp;
    public ReportService(IServiceProvider sp) => _sp = sp;

    public void Generate()
    {
        // Callers can't see what this class actually needs:
        var repo   = _sp.GetRequiredService<IReportRepository>();
        var mailer = _sp.GetRequiredService<IMailer>();
    }
}

// Constructor injection — all dependencies are explicit:
public class ReportService
{
    public ReportService(IReportRepository repo, IMailer mailer) { }
}

// Legitimate use 1: factory delegate inside registration
builder.Services.AddSingleton<ICache>(sp =>
{
    var cfg = sp.GetRequiredService<IConfiguration>();
    return new RedisCache(cfg["Redis:Host"]);
});

// Legitimate use 2: background service needs scoped service
public class DataSyncJob : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public DataSyncJob(IServiceScopeFactory scopeFactory)
        => _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // db is scoped — safe here because we created an explicit scope
    }
}

Rule of thumb: IServiceProvider belongs in infrastructure code only — factory delegates, hosted services, extension methods. In application classes, always use constructor injection.

The three registration families differ in behavior when a service type is already registered — a critical distinction for library authors and test setup.

// Add* — always appends a new descriptor (multiple for same type is valid):
services.AddSingleton<ICache, MemoryCache>();
services.AddSingleton<ICache, RedisCache>();
// Both registered. GetService<ICache>() → RedisCache (last wins).
// GetServices<ICache>() → [MemoryCache, RedisCache] (composite pattern).

// TryAdd* — adds only if NO descriptor for the type exists yet:
services.TryAddSingleton<ICache, MemoryCache>();
services.TryAddSingleton<ICache, RedisCache>(); // ignored — MemoryCache already present
// Best for library/framework code: register a default that the app can override.

// Replace — removes the existing descriptor, then adds the new one:
services.AddSingleton<ICache, MemoryCache>();
services.Replace(ServiceDescriptor.Singleton<ICache, RedisCache>());
// Only RedisCache is registered — MemoryCache is gone.

// RemoveAll — removes all descriptors for a type:
services.RemoveAll<ICache>();

// Test setup pattern — swap real service with a fake:
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();

Rule of thumb: Use Add* in application code where you control all registrations. Use TryAdd* in library/extension code so apps can override defaults. Use Replace in test fixtures to swap implementations cleanly.

Open generic registration maps a generic interface to a generic implementation with a single line — the container closes the type arguments on demand at resolve time.

// Generic repository pattern:
public interface IRepository<T> where T : class { }
public class EfRepository<T> : IRepository<T> where T : class
{
    private readonly AppDbContext _db;
    public EfRepository(AppDbContext db) => _db = db;
}

// One registration covers every entity type:
builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

// Resolved automatically for any closed type:
// IRepository<Order>   → new EfRepository<Order>(db)
// IRepository<Product> → new EfRepository<Product>(db)
// IRepository<User>    → new EfRepository<User>(db)

// A closed registration overrides the open one for a specific type:
builder.Services.AddScoped<IRepository<AuditLog>, ReadOnlyAuditRepository>();
// IRepository<AuditLog> → ReadOnlyAuditRepository (not EfRepository<AuditLog>)

// Consumer — unchanged regardless of how many entity types exist:
public class OrderService
{
    public OrderService(IRepository<Order> orders) { } // resolved from open generic
}

Works with all three lifetimes (Singleton, Scoped, Transient).

Rule of thumb: Use open generic registration when a pattern (repository, validator, handler) applies uniformly across many types. One line replaces dozens of individual closed-type registrations.

Keyed services (.NET 8+) allow registering multiple implementations of the same interface under distinct keys and resolving them by key — the official replacement for older Func<string, T> factory workarounds.

// Register multiple implementations under string keys:
builder.Services.AddKeyedSingleton<ICache, MemoryCache>("memory");
builder.Services.AddKeyedSingleton<ICache, RedisCache>("redis");
builder.Services.AddKeyedSingleton<ICache, NullCache>("null");

// Constructor injection — [FromKeyedServices] attribute specifies the key:
public class ProductService
{
    public ProductService([FromKeyedServices("redis")] ICache cache)
    {
        _cache = cache; // RedisCache
    }
}

// Programmatic resolution:
var cache = sp.GetRequiredKeyedService<ICache>("memory");

// Keys can be enums, not just strings:
public enum CacheType { Memory, Redis }

builder.Services.AddKeyedSingleton<ICache, MemoryCache>(CacheType.Memory);
builder.Services.AddKeyedSingleton<ICache, RedisCache>(CacheType.Redis);

// Pre-.NET 8 workaround (for reference):
builder.Services.AddSingleton<Func<string, ICache>>(sp => key => key switch
{
    "redis"  => sp.GetRequiredService<RedisCache>(),
    "memory" => sp.GetRequiredService<MemoryCache>(),
    _        => throw new ArgumentException($"Unknown cache: {key}")
});

Rule of thumb: Use keyed services (.NET 8+) when you need multiple named implementations of the same interface. Prefer enum keys over strings for type safety.

When multiple implementations of the same interface are registered, inject IEnumerable<T> to receive all of them — the core of composite, pipeline, and notification patterns.

// Register three validators for the same interface:
builder.Services.AddScoped<IOrderValidator, StockValidator>();
builder.Services.AddScoped<IOrderValidator, PriceValidator>();
builder.Services.AddScoped<IOrderValidator, FraudValidator>();

// Inject IEnumerable<T> to consume all of them:
public class OrderService
{
    private readonly IEnumerable<IOrderValidator> _validators;

    public OrderService(IEnumerable<IOrderValidator> validators)
        => _validators = validators;

    public async Task<ValidationResult> ValidateAsync(Order order)
    {
        foreach (var validator in _validators)
        {
            var result = await validator.ValidateAsync(order);
            if (!result.IsValid) return result; // fail fast on first failure
        }
        return ValidationResult.Success;
    }
}

// Programmatic resolution:
// GetService<IOrderValidator>()   → FraudValidator   (last registered wins)
// GetServices<IOrderValidator>()  → all three in registration order
var all = sp.GetServices<IOrderValidator>();

Rule of thumb: Use IEnumerable<T> injection for composite and pipeline patterns where all registered implementations should participate. For a single-winner scenario, the last registered implementation wins with GetService<T>.

ValidateOnBuild verifies the entire service graph at startup and throws if any registered service has an unresolvable dependency. ValidateScopes detects captive dependency bugs (a scoped service captured by a singleton).

// Enable in development via UseDefaultServiceProvider:
builder.Host.UseDefaultServiceProvider((ctx, options) =>
{
    bool isDev = ctx.HostingEnvironment.IsDevelopment();
    options.ValidateOnBuild = isDev;  // fail at startup, not first HTTP request
    options.ValidateScopes  = isDev;  // catch lifetime mismatches at startup
});

// Example: ValidateOnBuild catches this missing registration immediately:
builder.Services.AddScoped<IOrderService, OrderService>();
// If OrderService's constructor needs IPaymentGateway but it's not registered,
// app.Build() throws — not the first production request that hits the endpoint.

// Example: ValidateScopes catches captive dependencies:
builder.Services.AddSingleton<ReportCache>();
builder.Services.AddScoped<AppDbContext>();

public class ReportCache
{
    // Captive dependency — AppDbContext (scoped) held by singleton:
    public ReportCache(AppDbContext db) { }
}
// With ValidateScopes = true, app.Build() throws InvalidOperationException.

These options add startup overhead; only enable them in development or test environments.

Rule of thumb: Always enable ValidateOnBuild and ValidateScopes in development. Misconfigured DI graphs that slip to production cause intermittent, hard-to-reproduce bugs.

Minimal APIs resolve registered services by parameter type automatically — no [FromServices] attribute needed in most cases. The framework distinguishes services from route parameters and model bindings by type.

builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddSingleton<ICache, MemoryCache>();

var app = builder.Build();

// Services are injected by type; route params come from the URL:
app.MapGet("/products/{id:int}", async (
    int id,                              // from route — not a service
    IProductService products,            // from DI
    ICache cache,                        // from DI
    ILogger<Program> logger) =>          // from DI (built-in)
{
    logger.LogInformation("Fetching product {Id}", id);
    var product = await products.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

// [FromServices] is explicit — use when the type is ambiguous:
app.MapPost("/orders", async (
    [FromBody] CreateOrderDto dto,
    [FromServices] IOrderService orders) =>
{
    var order = await orders.CreateAsync(dto);
    return Results.Created($"/orders/{order.Id}", order);
});

Rule of thumb: In minimal APIs, registered services are injected automatically by parameter type. Use [FromServices] only when there's ambiguity or for explicitness.

Extension methods on IServiceCollection group related registrations into a named, composable unit — the same pattern Microsoft uses for AddDbContext, AddAuthentication, and AddMvc.

// Group all ordering-related registrations:
public static class OrderingServiceExtensions
{
    public static IServiceCollection AddOrderingServices(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IOrderRepository, EfOrderRepository>();
        services.AddScoped<IOrderValidator, StockValidator>();
        services.AddScoped<IOrderValidator, PriceValidator>();
        services.AddSingleton<IOrderEventPublisher>(sp =>
        {
            var cfg = configuration.GetSection("EventBus");
            return new RabbitMqPublisher(cfg["Host"], cfg["Exchange"]);
        });

        return services; // enables fluent chaining
    }
}

// Program.cs stays clean — reads as a feature list, not a type list:
builder.Services.AddOrderingServices(builder.Configuration);
builder.Services.AddCatalogServices(builder.Configuration);
builder.Services.AddAuthServices(builder.Configuration);
builder.Services.AddInfrastructureServices(builder.Configuration);

Rule of thumb: Once a domain area has 3+ registrations, extract them into an Add<Feature>Services extension method. Program.cs should read like a table of contents, not a registry dump.

Three anti-patterns cause the most production bugs in ASP.NET Core DI:

// 1. Service Locator — hides dependencies, breaks testability:
public class OrderService
{
    private readonly IServiceProvider _sp;
    public OrderService(IServiceProvider sp) => _sp = sp;

    public void Process()
    {
        // Callers can't see what this class needs:
        var repo = _sp.GetRequiredService<IOrderRepository>();
    }
}
// Fix: inject IOrderRepository directly in the constructor.

// 2. Captive dependency — scoped service held by a singleton:
public class ReportCache  // registered as Singleton
{
    public ReportCache(AppDbContext db) { } // AppDbContext is Scoped
    // db outlives the request — shared state across requests → data corruption
}
// Fix: inject IServiceScopeFactory; create a new scope per operation:
public class ReportCache
{
    private readonly IServiceScopeFactory _factory;
    public ReportCache(IServiceScopeFactory factory) => _factory = factory;

    public void Refresh()
    {
        using var scope = _factory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        // db is properly scoped here
    }
}

// 3. Bastard injection — hidden fallback dependency in the constructor:
public class EmailService
{
    private readonly ILogger _logger;
    public EmailService(ILogger logger = null)
    {
        _logger = logger ?? new ConsoleLogger(); // misleading in tests
    }
}
// Fix: always require the dependency; use NullLogger.Instance in tests:
public EmailService(ILogger<EmailService> logger) => _logger = logger;

Rule of thumb: If a class reaches into IServiceProvider, it has a Service Locator. If a singleton holds a scoped service in its constructor, it's a captive dependency. Both produce mysterious, request-intermittent bugs — catch them with ValidateScopes.

The built-in container supports constructor injection only. Property and method injection require a third-party container (Autofac, Castle Windsor) or a manual workaround. This is by design — constructor injection makes dependencies explicit and verifiable.

// Built-in container: constructor injection only — this is the right approach:
public class ReportService
{
    private readonly IReportRepository _repo;
    private readonly ILogger<ReportService> _logger;

    public ReportService(IReportRepository repo, ILogger<ReportService> logger)
    {
        _repo   = repo;
        _logger = logger;
    }
}

// Property injection — NOT supported by the built-in container:
public class ReportService
{
    // The built-in container will NOT set this automatically:
    public IReportRepository Repo { get; set; } = null!; // Bad: hidden dependency
}

// Workaround if property injection is genuinely needed — factory delegate:
builder.Services.AddScoped<ReportService>(sp =>
{
    var svc = new ReportService();          // parameterless ctor
    svc.Repo = sp.GetRequiredService<IReportRepository>(); // manual wiring
    return svc;
});
// Note: still visible at the registration site — but hides deps from consumers.

// Third-party container (Autofac) — supports property injection natively:
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
// Then use ContainerBuilder.RegisterType<T>().PropertiesAutowired() in Autofac config.

Rule of thumb: Stick with constructor injection and the built-in container for 95% of projects. Only reach for property injection (and a third-party container) when integrating with frameworks that require a parameterless constructor.

The built-in container doesn't have first-class decorator support, but you can wire decorators using factory delegates that resolve the inner service and wrap it. Scrutor (a NuGet package) adds a .Decorate<TInterface, TDecorator>() extension for cleaner syntax.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task SaveAsync(Order order);
}

public class EfOrderRepository : IOrderRepository { /* EF Core implementation */ }

// Decorator: adds caching around the real repository:
public class CachedOrderRepository : IOrderRepository
{
    private readonly IOrderRepository _inner; // wraps the real implementation
    private readonly IMemoryCache _cache;

    public CachedOrderRepository(IOrderRepository inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<Order?> GetByIdAsync(int id)
    {
        return await _cache.GetOrCreateAsync($"order:{id}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await _inner.GetByIdAsync(id); // delegates to real repo
        });
    }

    public Task SaveAsync(Order order)
    {
        _cache.Remove($"order:{order.Id}"); // invalidate on write
        return _inner.SaveAsync(order);
    }
}

// Manual wiring with factory delegate:
builder.Services.AddScoped<EfOrderRepository>();  // register concrete inner
builder.Services.AddScoped<IOrderRepository>(sp =>
    new CachedOrderRepository(
        sp.GetRequiredService<EfOrderRepository>(), // inner resolved by concrete type
        sp.GetRequiredService<IMemoryCache>()));

// Cleaner with Scrutor (Microsoft.Extensions.DependencyInjection.Abstractions extension):
// builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
// builder.Services.Decorate<IOrderRepository, CachedOrderRepository>();

Rule of thumb: For a single decorator layer, the factory delegate approach is clear enough. For multiple layers or frequent decorator use, add Scrutor to avoid nesting factory delegates.

The built-in container covers the vast majority of scenarios. Reach for a third-party container (Autofac, Lamar, Grace) only when you need features it doesn't provide: property injection, convention-based registration, advanced interception, or child/nested container scoping.

// Add Autofac as the service provider factory (Autofac.Extensions.DependencyInjection):
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

// Configure Autofac modules alongside standard IServiceCollection registrations:
builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
    // Standard registrations still work — Autofac wraps IServiceCollection:
    // (already added via builder.Services above)

    // Autofac-specific features:
    containerBuilder.RegisterType<EfOrderRepository>()
        .As<IOrderRepository>()
        .InstancePerLifetimeScope()
        .EnableInterfaceInterceptors(); // AOP interception — not in built-in

    // Convention-based scan and register all types in an assembly:
    containerBuilder.RegisterAssemblyTypes(typeof(Program).Assembly)
        .Where(t => t.Name.EndsWith("Service"))
        .AsImplementedInterfaces()
        .InstancePerLifetimeScope();

    // Property injection:
    containerBuilder.RegisterType<LegacyReporter>()
        .As<IReporter>()
        .PropertiesAutowired(); // not supported by built-in container
});

// Note: ASP.NET Core's IServiceCollection registrations are imported automatically.
// Third-party containers must implement IServiceProviderFactory<TContainerBuilder>.

Rule of thumb: Start with the built-in container — it's fast, simple, and supports 90% of DI patterns. Switch to a third-party container only for features the built-in can't provide, and document why so the next developer understands the dependency.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel