Waqas Ahmad — Software Architect & Technical Consultant - Available USA, Europe, Global

Waqas Ahmad — Software Architect & Technical Consultant

Specializing in

Distributed Systems

.NET ArchitectureCloud-Native ArchitectureAzure Cloud EngineeringAPI ArchitectureMicroservices ArchitectureEvent-Driven ArchitectureDatabase Design & Optimization

👋 Hi, I'm Waqas — a Software Architect and Technical Consultant specializing in .NET, Azure, microservices, and API-first system design..
I help companies build reliable, maintainable, and high-performance backend platforms that scale.

Experienced across engineering ecosystems shaped by Microsoft, the Cloud Native Computing Foundation, and the Apache Software Foundation.

Available for remote consulting (USA, Europe, Global) — flexible across EST, PST, GMT & CET.

services
Article

Dependency Injection in .NET Core: In-Depth with Code Examples

DI in .NET Core: lifetimes, registration, and best practices. Autofac and Scrutor.

services
Read the article

Introduction

This guidance is relevant when the topic of this article applies to your system or design choices; it breaks down when constraints or context differ. I’ve applied it in real projects and refined the takeaways over time (as of 2026).

Tight coupling and new everywhere make code hard to test and change when you swap implementations or add new behaviour. This article explains DI in .NET Core: registration and injection, lifetimes (Singleton, Scoped, Transient), best practices, safe data access with injected services, and how to avoid captive and circular dependencies, with full code examples. For architects and tech leads, DI keeps code testable and loosely coupled so you can evolve and replace dependencies without rewrites.

If you are new to DI, start with Topics covered and Before and after: without DI vs with DI.

For a deeper overview of this topic, explore the full .NET Architecture guide.

Decision Context

  • System scale: Any .NET app with more than a handful of services or cross-cutting concerns; the built-in container is enough for most apps; Autofac/Scrutor when you need keyed services, decorators, or convention-based registration.
  • Team size: One to many teams; someone must own registration (composition root) and lifetime choices so you don’t get captive dependency or memory leaks.
  • Time / budget pressure: Fits greenfield and brownfield; breaks down when “we’ll just new it” is the norm and nobody wants to introduce interfaces—then start with constructor injection for new code only.
  • Technical constraints: .NET Core / .NET 5+ built-in DI; optional Autofac, Scrutor; assumes you can register services at startup (e.g. Program.cs, Extensions).
  • Non-goals: This article does not optimize for minimal abstractions, for “no interfaces,” or for non-.NET stacks; it optimises for testability and loose coupling in .NET.

What is dependency injection?

Dependency Injection is a technique where a class receives its dependencies (other services it needs) from the outside—typically via constructor, property, or method—instead of creating them itself with new. A DI container (e.g. the one in .NET Core) holds registrations (interface → implementation, lifetime) and resolves instances when a class is created. The class then depends on abstractions (interfaces), so you can swap implementations (e.g. real repository in production, mock in tests) without changing the class.

Benefits: Testability (inject mocks), loose coupling (depend on IOrderRepository, not OrderRepository), single place for configuration and lifetimes, cleaner constructors and separation of concerns.


Before and after: without DI vs with DI

Seeing the difference between without DI and with DI makes the value clear. Below: same use case (creating an order), first without DI, then with DI.

Before (without DI)

The controller creates its dependencies with new. It is tightly coupled to concrete types and hard to test (you cannot replace OrderRepository with a fake).

public class OrdersController : ControllerBase
{
    private readonly OrderRepository _repo;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer("Server=...;Database=App;...")
            .Options;
        _repo = new OrderRepository(new AppDbContext(options));
        _logger = new LoggerFactory().CreateLogger<OrdersController>();
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order");
        var order = await _repo.AddAsync(request);
        return Ok(order);
    }
}

Problems: Connection string and options are hardcoded; you cannot unit test without a real database; swapping OrderRepository for a mock requires changing the controller.

After (with DI)

The controller receives its dependencies via the constructor. The container provides the registered implementations. Testable (inject IOrderRepository mock) and loosely coupled (depends on IOrderRepository, not OrderRepository).

public class OrdersController : ControllerBase
{
    private readonly IOrderRepository _repo;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController(IOrderRepository repo, ILogger<OrdersController> logger)
    {
        _repo = repo;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order");
        var order = await _repo.AddAsync(request);
        return Ok(order);
    }
}

Registration in Program.cs:

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

How this fits together: The container creates OrdersController when a request arrives; it sees the constructor needs IOrderRepository and ILogger<OrdersController>, resolves them (creating OrderRepository and the logger with the correct lifetime), and injects them. No new in the controller; configuration and lifetimes live in one place.


Injection types at a glance

Type How When to use
Constructor Dependencies in constructor parameters; container resolves and injects. Preferred for required dependencies.
Property Public settable property; container or code sets it after construction. Optional dependencies; rarely used in .NET Core.
Method Method parameter(s); container or caller passes dependencies when calling the method. When the dependency is only needed for one operation.

.NET Core and the built-in container are optimized for constructor injection. Use constructor injection for all required dependencies; use property or method injection only when necessary (e.g. optional or per-call dependency).


Constructor injection

What it is and when to use it

Constructor injection means the class declares its dependencies as constructor parameters. The container creates the class by resolving each parameter and passing the instances in. The class stores them in readonly fields and uses them for its lifetime. This is the primary and recommended way to inject dependencies in .NET Core.

When to use: For all required dependencies. The constructor makes dependencies explicit and the class immutable (dependencies do not change after construction). The container validates at startup that all dependencies can be resolved (when the first request tries to create the class).

Full example: service with multiple dependencies

public interface IOrderService { Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default); }

public class OrderService : IOrderService
{
    private readonly IOrderRepository _repo;
    private readonly IValidator<OrderRequest> _validator;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IOrderRepository repo, IValidator<OrderRequest> validator, ILogger<OrderService> logger)
    {
        _repo = repo;
        _validator = validator;
        _logger = logger;
    }

    public async Task<OrderResult> PlaceOrderAsync(OrderRequest request, CancellationToken ct = default)
    {
        var result = await _validator.ValidateAsync(request, ct);
        if (!result.IsValid) throw new ValidationException(result.Errors);
        _logger.LogInformation("Placing order for customer {CustomerId}", request.CustomerId);
        var order = await _repo.AddAsync(request, ct);
        return new OrderResult { OrderId = order.Id };
    }
}

Registration:

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IValidator<OrderRequest>, OrderRequestValidator>();

Property and method injection

Property injection

Property injection means the container (or custom code) sets a property on the instance after construction. The built-in .NET Core container does not support property injection out of the box. Some third-party containers (e.g. Autofac) do. Use it only for optional dependencies when you cannot use constructor injection.

When to use: Rarely. Prefer constructor injection. If you need optional dependencies, consider constructor with default null and check before use, or use a factory.

Method injection

Method injection means the dependency is passed as a parameter to a method when it is called. The caller (or middleware) resolves the dependency and passes it in. Useful when the dependency is only needed for one operation or when it varies per call (e.g. HttpContext).

public class ReportGenerator
{
    public async Task<byte[]> GenerateAsync(IOrderRepository repo, ReportCriteria criteria, CancellationToken ct = default)
    {
        var orders = await repo.GetByDateRangeAsync(criteria.From, criteria.To, ct);
        return BuildPdf(orders);
    }
}

When to use: When the dependency is per-call or context-specific (e.g. current user, request-scoped data) and you do not want to hold it on the class. Often used with HttpContext or when the caller has the dependency already.


Lifetimes: Singleton, Scoped, Transient

Lifetime Instance scope Use when Do not use when
Singleton One instance for the entire application Stateless: logger, config, cache client Request-scoped or stateful (e.g. DbContext)
Scoped One instance per scope (e.g. per HTTP request) DbContext, repositories, per-request state Injected into Singleton (captive dependency)
Transient New instance every time it is requested Lightweight, stateless: validators, mappers When the same instance must be shared within a request

Singleton

Singleton is created once and reused for the lifetime of the application. Use for stateless services: ILogger<T>, IConfiguration, IDistributedCache (the client), IHttpClientFactory. Do not inject Scoped or Transient into a Singleton—that creates a captive dependency (the scoped instance is never disposed and can cause concurrency or stale data).

Scoped

Scoped is created once per scope. In ASP.NET Core, each HTTP request has a new scope, so DbContext and repositories are typically Scoped: one instance per request, disposed at the end of the request. Use for anything that holds request-specific state or uses DbContext.

Transient

Transient is created every time it is requested. Use for lightweight, stateless services: IValidator<T>, IMapper, small helpers. Do not use when the same instance must be shared within a request (e.g. a cache that should be per-request)—use Scoped instead.


Registration: all cases

Basic registration

builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();

DbContext and EF Core

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// DbContext is registered as Scoped by default

Multiple implementations (choose by key or enumerate)

Option 1: Last registration wins (single interface, multiple implementations—container returns last registered unless you use a factory).

Option 2: Inject IEnumerable<T> to get all implementations:

builder.Services.AddScoped<IOrderValidator, OrderAmountValidator>();
builder.Services.AddScoped<IOrderValidator, OrderStockValidator>();

public class OrderService
{
    private readonly IEnumerable<IOrderValidator> _validators;
    public OrderService(IEnumerable<IOrderValidator> validators) => _validators = validators;
    public async Task ValidateAsync(OrderRequest req)
    {
        foreach (var v in _validators) await v.ValidateAsync(req);
    }
}

Option 3: Keyed services (.NET 8+)

builder.Services.AddKeyedScoped<IEmailService, SmtpEmailService>("smtp");
builder.Services.AddKeyedScoped<IEmailService, SendGridEmailService>("sendgrid");
// Resolve: provider.GetRequiredKeyedService<IEmailService>("smtp");

Factory registration (create instance with custom logic)

builder.Services.AddSingleton<IEmailService>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return config["Email:Provider"] == "SendGrid"
        ? new SendGridEmailService(config)
        : new SmtpEmailService(config);
});

Options pattern (strongly typed configuration)

builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));
// Inject IOptions<EmailOptions>, IOptionsSnapshot<EmailOptions>, or IOptionsMonitor<EmailOptions>

Best practices

  1. Program to interfaces – Depend on IOrderRepository, not OrderRepository. Enables testing and swapping implementations.
  2. Prefer constructor injection – Required dependencies in the constructor; avoid property injection unless necessary.
  3. Do not inject Scoped/Transient into Singleton – Causes captive dependency. Use IServiceScopeFactory in the Singleton to create a scope when you need a Scoped service.
  4. Keep constructors simple – Many dependencies may indicate the class does too much; consider splitting.
  5. Avoid service locator – Prefer constructor injection over IServiceProvider.GetRequiredService<T>() in application code. Use GetRequiredService only in composition root or factory code.
  6. Register in one place – Use Program.cs or extension methods (e.g. AddOrderServices(this IServiceCollection services)) so all registrations are visible and testable.
  7. Use IDbContextFactory<T> for background services – In Singleton (e.g. hosted service), do not inject DbContext directly; use IDbContextFactory<AppDbContext> and create a context per operation.

Safe data access with injected services

When you inject repositories, DbContext, or IDbConnection, your code runs SQL or calls stored procedures. To avoid SQL injection and manage data safely:

Parameterized queries (always)

Never concatenate user input into SQL. Always use parameters (e.g. EF Core Where, FromSqlRaw with parameters, or SqlParameter).

Before (unsafe – do not do this):

var sql = "SELECT * FROM Orders WHERE CustomerId = '" + request.CustomerId + "'";
var orders = await context.Orders.FromSqlRaw(sql).ToListAsync();

After (safe – parameterized):

var orders = await context.Orders
    .Where(o => o.CustomerId == request.CustomerId)
    .ToListAsync(ct);
// Or FromSqlRaw with parameters:
var orders = await context.Orders
    .FromSqlRaw("SELECT * FROM Orders WHERE CustomerId = @p0", request.CustomerId)
    .ToListAsync(ct);

Stored procedures (parameterized)

When calling stored procedures, pass parameters explicitly; do not build the SQL string from user input.

var customerIdParam = new SqlParameter("@CustomerId", request.CustomerId);
var results = await context.Orders
    .FromSqlRaw("EXEC dbo.GetOrdersByCustomer @CustomerId", customerIdParam)
    .ToListAsync(ct);

Repository abstraction

Keep query building and parameterization inside the repository (or DbContext); the service layer receives only DTOs or domain objects. That way, all data access is centralized and safe.

public interface IOrderRepository
{
    Task<IReadOnlyList<Order>> GetByCustomerAsync(string customerId, CancellationToken ct = default);
}

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;
    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<IReadOnlyList<Order>> GetByCustomerAsync(string customerId, CancellationToken ct = default)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .ToListAsync(ct);
    }
}

How this fits together: The injected IOrderRepository hides the data access; the service calls GetByCustomerAsync(customerId). The repository uses EF Core (parameterized) or ADO.NET with SqlParameter. Never pass raw SQL strings from the controller or service; keep SQL and parameters inside the repository or DbContext.


SQL injection simulation: table state and test values

A simulation shows what happens when you submit user input without parameterization vs with parameterization. Below: a small table, an array of test values (safe and malicious), and the effect on the table after “submit.”

Initial table state (e.g. Users)

Id UserName Role
1 alice User
2 bob User
3 admin Admin

Test values array (inputs we “submit”)

Input Intended use Actually safe?
alice Look up one user Yes
bob Look up one user Yes
'; UPDATE Users SET Role='Admin' WHERE 1=1; -- Malicious No – changes SQL meaning
'; DELETE FROM Users WHERE 1=1; -- Malicious No – deletes all rows
1; DROP TABLE Users; -- Malicious No – can drop table (if DB allows)

Unsafe “submit” (concatenated SQL)

Code builds: SELECT * FROM Users WHERE UserName = ' + input + '

  • For input alice: SQL = SELECT * FROM Users WHERE UserName = 'alice' → returns 1 row. Table unchanged.
  • For input '; UPDATE Users SET Role='Admin' WHERE 1=1; --: SQL becomes SELECT * FROM Users WHERE UserName = ''; UPDATE Users SET Role='Admin' WHERE 1=1; --'two statements run. The second updates every row.

Table after unsafe submit (malicious input)

Id UserName Role
1 alice Admin
2 bob Admin
3 admin Admin

Effect: The whole table is affected; one malicious “submit” escalated every user to Admin. With DELETE FROM Users WHERE 1=1, all rows would be removed.

Safe “submit” (parameterized)

Code uses a parameter: SELECT * FROM Users WHERE UserName = @p0 with @p0 = input.

  • For any input (including '; UPDATE Users SET Role='Admin' WHERE 1=1; --), the value is treated as a single string for UserName. No row matches that literal string. No extra SQL runs. Table unchanged.

Table after safe submit (same malicious input)

Id UserName Role
1 alice User
2 bob User
3 admin Admin

How this fits together: Without parameterization, one bad value in the “submit” can change the meaning of the SQL (extra statements, wrong rows, or destructive commands). With parameterization, the input is always one value; it cannot alter the structure of the query or affect other rows. Use this test values array (safe + malicious) in tests or demos to verify your repository never concatenates user input into SQL.


Audit trails and tracking changes

Audit trails record who did what, when (and optionally old/new values). They help you track changes, comply with regulations, and debug. When you use injected services for data access, you can add an injected audit service that writes to an audit table or audit log in a safe, parameterized way.

What to store (typical audit table or log)

Column / field Purpose
Id Unique audit entry id
Timestamp When the change happened (UTC)
UserId / Actor Who made the change (from auth context)
Action Created, Updated, Deleted
EntityType e.g. Order, User
EntityId e.g. order id, user id
OldValue / NewValue Optional JSON or columns for before/after (avoid logging passwords or secrets)

Injected audit service (safe, parameterized)

public interface IAuditStore
{
    Task AppendAsync(AuditEntry entry, CancellationToken ct = default);
}

public class AuditStore : IAuditStore
{
    private readonly AppDbContext _context;
    public AuditStore(AppDbContext context) => _context = context;

    public async Task AppendAsync(AuditEntry entry, CancellationToken ct = default)
    {
        _context.AuditLog.Add(new AuditLog
        {
            TimestampUtc = DateTime.UtcNow,
            UserId = entry.UserId,
            Action = entry.Action,
            EntityType = entry.EntityType,
            EntityId = entry.EntityId,
            NewValue = entry.NewValue  // sanitize; never log passwords
        });
        await _context.SaveChangesAsync(ct);
    }
}

Using audit in a service (injected)

public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly IAuditStore _audit;

    public OrderService(IOrderRepository repo, IAuditStore audit)
    {
        _repo = repo;
        _audit = audit;
    }

    public async Task UpdateOrderAsync(OrderUpdateRequest request, string userId, CancellationToken ct = default)
    {
        var order = await _repo.GetByIdAsync(request.OrderId, ct);
        var oldTotal = order.Total;
        order.Total = request.Total;
        await _repo.SaveAsync(ct);
        await _audit.AppendAsync(new AuditEntry
        {
            UserId = userId,
            Action = "Updated",
            EntityType = "Order",
            EntityId = request.OrderId,
            NewValue = JsonSerializer.Serialize(new { order.Total })
        }, ct);
    }
}

How this fits together: The audit store is injected like any other service; it uses the same DbContext (or a dedicated one) and parameterized writes. Every change can be recorded in one place. Do not build audit SQL from user input; use EF Core or parameters. Do not log sensitive data (passwords, tokens) in OldValue/NewValue; redact or omit.


Pivot-style queries, reporting, and logs

Pivot tables (or pivot-style queries) turn rows into columns (e.g. sales by month as columns). Reporting and query logs often involve dynamic column names or filters. Even there, never concatenate user input into SQL.

Scenario Safe approach
Pivot / dynamic columns Use whitelists: only allow a fixed set of column names (e.g. from config or enum). Build SQL from the whitelist, not from user input. For values (dates, ids), use parameters.
Report filters Pass filter values as parameters (e.g. @FromDate, @ToDate, @CustomerId). Column/table names: whitelist only.
Query logs Log parameterized SQL and parameter values separately (or redact). Do not log raw concatenated SQL with user input in production (can leak data).
Audit logs As above: inject IAuditStore, write structured audit entries with parameterized inserts; never build log text from unsanitized input.

Example: safe pivot-style query (whitelist + parameters)

private static readonly HashSet<string> AllowedColumns = new(StringComparer.OrdinalIgnoreCase)
    { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };

public async Task<List<SalesRow>> GetSalesPivotAsync(string monthColumn, int year, CancellationToken ct = default)
{
    if (!AllowedColumns.Contains(monthColumn))
        throw new ArgumentException("Invalid month column.", nameof(monthColumn));
    // monthColumn is from whitelist; year is parameterized
    return await _context.SalesPivot
        .FromSqlRaw("EXEC dbo.GetSalesPivot @MonthCol = {0}, @Year = {1}", monthColumn, year)
        .ToListAsync(ct);
}

How this fits together: Pivot and reporting often need dynamic parts (e.g. which column). Use a whitelist for identifiers (column/table names); use parameters for values. Logs (audit and query) should be written through your injected services with parameterized or structured writes so nothing untrusted ends up in SQL or in log storage.


Enterprise frameworks

Built-in container (Microsoft.Extensions.DependencyInjection)

The default container in .NET Core is sufficient for most applications: Singleton, Scoped, Transient, IServiceProvider, IServiceScopeFactory, IOptions<T>, and factory registrations. Use it unless you need keyed services (before .NET 8), property injection, child containers, or advanced lifetime management.

Autofac

Autofac provides modules, keyed services, property injection, child scopes, and decorators. Use when you need enterprise features or modular registration.

builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>((context, cb) =>
{
    cb.RegisterType<OrderRepository>().As<IOrderRepository>().InstancePerLifetimeScope();
    cb.RegisterModule<OrderModule>();
});

Scrutor

Scrutor adds assembly scanning and decorator registration on top of the built-in container. Use to register many implementations by convention (e.g. all I*Handler in an assembly) or to wrap services with decorators (e.g. logging, caching) without manual registration of each decorator.

builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(classes => classes.AssignableTo<IOrderService>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

How to avoid common pitfalls

Pitfall How to avoid
Captive dependency Do not inject Scoped or Transient into Singleton. Use IServiceScopeFactory in the Singleton and create a scope when you need a Scoped service; resolve from the scope and dispose the scope when done.
Circular dependency A → B → A. Fix: Introduce an abstraction (e.g. both depend on IEventBus) or restructure so one direction is removed.
Wrong lifetime Singleton for DbContext or request-specific state causes cross-request leakage. Use Scoped for DbContext and per-request state. Use Transient only for stateless, cheap-to-create services.
Service locator Avoid GetRequiredService<T>() in application code. Use constructor injection so dependencies are explicit. Use GetRequiredService only in the composition root (e.g. Program.cs) or in factories.
SQL injection Never concatenate user input into SQL. Use parameterized queries (EF Core, SqlParameter) or stored procedures with parameters. Keep SQL inside repositories.

Common pitfalls

  • Captive dependency – Singleton holding Scoped (e.g. DbContext). The Scoped instance is never disposed; concurrency and stale data. Fix: IServiceScopeFactory or make the consumer Scoped.
  • Wrong lifetime – Singleton for something that should be per-request (e.g. current user). Fix: Scoped for request-scoped state.
  • Circular dependency – A depends on B, B depends on A. Container cannot resolve. Fix: abstraction or restructure.
  • Overuse of service locatorGetRequiredService<T>() everywhere. Fix: constructor injection.
  • Registering concrete types only – Hard to mock. Fix: register interface → implementation.
  • SQL injection – Building SQL from user input. Fix: parameterized queries and stored procedures with parameters.

Summary

DI in .NET Core means registering services in the container and injecting them via constructors so code stays testable and loosely coupled. Skipping DI or misusing lifetimes (e.g. captive dependency) leads to hard-to-test code and runtime bugs; program to interfaces and avoid injecting Scoped into Singleton. Next, audit one service that still uses new for a dependency, introduce an interface and register it, then inject it and add a unit test.

Dependency Injection in .NET Core means registering services (interface → implementation, lifetime) and injecting them via constructors (or property/method when needed). Before DI, classes create dependencies with new (tight coupling, hard to test); after DI, the container provides dependencies (loose coupling, testable). Use constructor injection for required dependencies; Singleton, Scoped, and Transient for lifetimes. Best practices: program to interfaces, avoid captive dependency (do not inject Scoped into Singleton; use IServiceScopeFactory), avoid service locator in application code, keep data access safe (parameterized queries, stored procedures with parameters). The SQL injection simulation (table state, test values array, unsafe vs safe submit) shows how one malicious input can affect the whole table without parameterization. Audit trails (injected IAuditStore, who/when/what, parameterized writes) let you track changes; pivot-style queries and reporting use whitelists for column names and parameters for values; logs (audit and query) should be written safely. Enterprise options: built-in container (usually enough), Autofac (modules, keyed, property injection), Scrutor (scanning, decorators). This article covered what DI is, before/after, injection types, lifetimes, registration, best practices, safe data access, SQL injection simulation, audit trails, pivot/reporting/logs, enterprise frameworks, and how to avoid common pitfalls.


Position & Rationale

I use constructor injection by default; property and method injection only when the framework demands it (e.g. optional late-bound dependencies). I register interfaces to implementations in one place (composition root); I avoid Scoped or Singleton services that capture Transient or Scoped dependencies (captive dependency). I use the built-in container until I need keyed services, decorators, or convention-based registration—then Autofac or Scrutor. I avoid service locator and new for services that should be swappable; tests need mocks.


Trade-Offs & Failure Modes

  • What this sacrifices: More interfaces and indirection; onboarding takes longer until people internalise lifetimes. In return you get testability and loose coupling.
  • Where it degrades: When nobody owns the composition root and registrations scatter; or when Scoped is used inside Singleton (captive dependency, often bugs or memory leaks).
  • How it fails when misapplied: Registering everything as Singleton (shared state, threading issues); or the opposite—Transient for DbContext so each request gets a new one and you break unit-of-work. Another failure: service locator so tests can’t inject mocks.
  • Early warning signs: “We get ObjectDisposedException in production”; “our tests don’t use mocks”; “we have 50 services registered as Singleton.”

What Most Guides Miss

Most guides show registration and lifetimes but skip captive dependency: a Singleton that takes a Scoped or Transient dependency holds it for the app lifetime and can cause disposed-object or threading bugs. The other gap: where to register. Registration belongs in the composition root (e.g. API Program.cs or Extensions), not in libraries that “register themselves”—otherwise you get hidden coupling. Finally: Scoped in background services. A hosted service is Singleton; if it needs a DbContext, create a scope per operation (e.g. IServiceScopeFactory) and resolve Scoped inside that scope.


Decision Framework

  • If you need testability or swappable implementations → Use constructor injection and register interfaces in the composition root.
  • If the dependency is optional or late-bound → Consider method injection or IOptions; avoid service locator for core dependencies.
  • If you’re not sure about lifetime → Prefer Scoped for request-scoped resources (e.g. DbContext); Singleton for stateless utilities; Transient when each consumer must get a new instance.
  • If Singleton holds a dependency → That dependency must be Singleton or you have captive dependency; use IServiceScopeFactory in the Singleton to create a scope when you need Scoped.
  • If built-in container is not enough → Add Autofac (keyed, decorators) or Scrutor (convention-based assembly scan); don’t mix multiple containers for the same app.

You can also explore more patterns in the .NET Architecture resource page.

Key Takeaways

  • Constructor injection and interface registration in the composition root; avoid service locator and new for injectable services.
  • Match lifetime to usage: Scoped for request-scoped (e.g. DbContext), Singleton for stateless, Transient when each consumer needs a new instance.
  • Avoid captive dependency: Singleton must not hold Scoped or Transient; use IServiceScopeFactory in long-lived services when you need Scoped.
  • Register in one place (API startup); don’t let libraries self-register in a way that hides dependencies.
  • Use built-in container first; add Autofac or Scrutor when you need keyed services, decorators, or convention-based registration.

Need architectural guidance for real-world .NET platforms? I offer consulting for .NET architecture, API platforms, and enterprise system design.

When I Would Use This Again — and When I Wouldn’t

I would use DI again for any non-trivial .NET app where I want testability and loose coupling—greenfield or brownfield. I wouldn’t skip it to “move faster” if the app will grow; introducing interfaces later is costlier. I also wouldn’t use property or method injection for core dependencies when constructor injection works. Alternative: for tiny scripts or throwaway tools, new is fine; add DI when you add tests or multiple implementations.


services
Frequently Asked Questions

Frequently Asked Questions

What is dependency injection?

Dependency Injection is a technique where dependencies (e.g. repositories, services) are injected into a class (e.g. via constructor) instead of being created inside the class. A container holds registrations and creates instances when needed. This makes code testable (inject mocks) and keeps concerns separated.

What are DI lifetimes?

Singleton: one instance for the entire application. Scoped: one instance per scope (e.g. per HTTP request). Transient: a new instance every time it is requested. Choose based on whether the service is stateless and app-wide (Singleton), request-scoped (Scoped), or lightweight and not shared (Transient).

What is captive dependency?

A captive dependency is when a long-lived service (e.g. Singleton) holds a reference to a short-lived service (e.g. Scoped DbContext). The short-lived instance is never disposed correctly and can cause concurrency or stale data. Fix: do not inject Scoped into Singleton; use IServiceScopeFactory to create a scope when the Singleton needs a Scoped service.

When should I use Singleton?

For stateless services: logger, config, cache client. Services that are expensive to create and safe to share. Do not use for DbContext or request-specific state.

When should I use Scoped?

For per-request services: DbContext, repositories, anything with request-specific state. Disposed at end of request.

When should I use Transient?

For lightweight, stateless services like validators, mappers. New instance each time, not shared.

How do I inject into controllers?

Constructor injection. Declare interfaces in the constructor; the container provides the registered implementations when the controller is created.

How do I resolve services manually?

Inject IServiceProvider or IServiceScopeFactory. Use GetRequiredService<T>() or GetService<T>(). For Scoped services, create a scope with CreateScope(), resolve from the scope, and dispose the scope when done.

How do I avoid captive dependency?

Do not inject Scoped (or Transient used as scoped) into Singleton. Use IServiceScopeFactory in the Singleton: create a scope, resolve the Scoped service from the scope, use it, then dispose the scope.

What is circular dependency?

A depends on B and B depends on A. The container cannot resolve. Fix: introduce an abstraction (e.g. both depend on a third interface) or restructure so one direction is removed.

How do I test with DI?

Register mocks (e.g. Mock) in a test ServiceCollection, build a ServiceProvider, and resolve the class under test. Or use WebApplicationFactory for integration tests.

Can I use third-party containers?

Yes. Autofac, Ninject, etc. The built-in container is usually enough. Use Autofac for modules, keyed services, or property injection; use Scrutor for assembly scanning and decorators.

How do I register multiple implementations?

Register each implementation for the same interface. Inject IEnumerable<IYourInterface> to get all, or use keyed services (.NET 8+: AddKeyedScoped, GetRequiredKeyedService<T>(key)) to resolve by key.

What is IServiceScopeFactory?

A factory that creates scopes. Use it in Singleton or long-lived code when you need to resolve Scoped services (e.g. DbContext). Create a scope, resolve from it, use, then dispose the scope.

How do I avoid SQL injection when using injected repositories?

Never concatenate user input into SQL. Use parameterized queries (EF Core Where, FromSqlRaw with parameters, or SqlParameter). Use stored procedures with parameters. Keep all SQL and parameters inside the repository or DbContext; the service layer receives only DTOs or domain objects.

What are keyed services?

.NET 8+ feature: register multiple implementations of the same interface under different keys. Resolve with GetRequiredKeyedService<IEmailService>("smtp"). Use when you need to choose implementation by configuration or context (e.g. “smtp” vs “sendgrid”).

What is the SQL injection simulation (table state, test values)?

A simulation uses a small table (e.g. Users), an array of test values (safe inputs like alice and malicious inputs like '; UPDATE Users SET Role='Admin' WHERE 1=1; --), and shows before/after table state. Unsafe submit (concatenated SQL): malicious input can run extra SQL and affect the whole table (e.g. all rows become Admin). Safe submit (parameterized): the same input is treated as one string; no extra SQL runs; table unchanged. Use this to verify your repo never concatenates user input into SQL.

How do I add audit trails with DI?

Inject an IAuditStore (or similar) that writes to an audit table or log. Store who (UserId), when (TimestampUtc), what (Action, EntityType, EntityId), and optionally old/new value (sanitized; never log passwords). Use parameterized inserts (EF Core or SqlParameter). Call _audit.AppendAsync(entry) after each change in your service. Register IAuditStore as Scoped (same scope as DbContext).

Pivot tables, reporting, and logs – safe with DI?

Pivot-style and reporting queries: use a whitelist for column/table names (never from user input); use parameters for filter values (dates, ids). Query logs: log parameterized SQL and parameter values separately; do not log raw concatenated SQL with user input. Audit logs: use an injected audit service with parameterized writes; do not build log text from unsanitized input.

services
Related Guides & Resources

services
Related services