TL;DR
What: High-level modulesCode that expresses business rules and policies — the "what." An OrderProcessor that decides how to fulfill orders is high-level. It shouldn't know whether orders go to SQL Server, MongoDB, or a carrier pigeon. High-level modules are the reason the software exists. (business logic) should not depend on low-level modulesCode that implements infrastructure details — the "how." A SqlOrderRepository that writes to SQL Server is low-level. An SmtpEmailSender that sends via SMTP is low-level. These are interchangeable plumbing — the business doesn't care which database or email provider you use. (infrastructure). Both should depend on abstractions. And abstractions should not depend on details — details should depend on abstractions.
Why: Without DIP, your business logic is weldedWhen class A directly creates or references class B's concrete type (new SqlRepository()), changing B means changing A. This is "welded" coupling — you can't swap B without surgery on A. DIP breaks this weld by inserting an interface between them. to infrastructure. Changing your database means rewriting your business rules. Replacing your email provider means touching your order processor. DIP breaks this coupling by pointing all dependency arrows toward abstractions owned by the business layer.
Modern .NET: ASP.NET Core's entire architecture is built on DIP — IServiceCollection wires abstractions to implementations, controllers depend on IOrderService (not SqlOrderService), middleware depends on ILogger (not ConsoleLogger). The built-in DI containerMicrosoft.Extensions.DependencyInjection — the DI container included with ASP.NET Core since 1.0. It supports constructor injection, three lifetimes (Transient, Scoped, Singleton), factory delegates, and keyed services (.NET 8). Third-party containers (Autofac, Scrutor) add decoration, interception, and convention-based registration. is the delivery mechanism for DIP-compliant designs.
Quick Code:
// ✗ VIOLATES DIP — business logic depends on infrastructure
public class OrderProcessor
{
private readonly SqlOrderRepository _repo = new(); // ← welded to SQL
private readonly SmtpEmailSender _email = new(); // ← welded to SMTP
public void Process(Order order)
{
_repo.Save(order); // Can't test without SQL
_email.Send(order.CustomerEmail, "Order placed"); // Can't test without SMTP
}
}
// ✓ FOLLOWS DIP — business logic depends on abstractions
public interface IOrderRepository { void Save(Order order); }
public interface INotifier { void Send(string to, string message); }
public class OrderProcessor(IOrderRepository repo, INotifier notifier)
{
public void Process(Order order)
{
repo.Save(order); // Could be SQL, Mongo, or in-memory
notifier.Send(order.CustomerEmail, "Order placed"); // Could be email, SMS, or mock
}
}
// The ABSTRACTIONS (interfaces) live in the business layer
// The IMPLEMENTATIONS (SqlOrderRepository, SmtpNotifier) live in the infrastructure layer
// Dependency arrows point INWARD toward the business — that's the "inversion"
Prerequisites
interface and abstract class work in C#, start there.
SRP — Classes with a single responsibility naturally have focused dependencies. A class doing 5 things needs 5 infrastructure dependencies — DIP can't save a God classA class that does everything — data access, business logic, email sending, logging, validation — all in one file. God classes violate SRP and make DIP meaningless because even if you inject interfaces, the class still has 15 constructor parameters and changes for 15 different reasons..
ISP — DIP says "depend on abstractions." ISP says "make those abstractions small and focusedIf you depend on IRepository with 20 methods but only use 3, you're following DIP (depending on an abstraction) but violating ISP (the abstraction is too wide). DIP + ISP together = depend on small, focused abstractions.." Together they ensure you depend on the RIGHT abstractions.
Constructor Injection — The primary way to deliver dependencies in .NET. If you haven't used IServiceCollection.AddScoped<T>(), read the ASP.NET Core DI docsMicrosoft's official documentation covers the built-in DI container: lifetimes (Transient, Scoped, Singleton), registration methods, factory delegates, and best practices. Start with "Dependency injection in ASP.NET Core" on learn.microsoft.com. first.
Analogies
The Power Outlet (Primary)
Your laptop doesn't have a wire soldered directly to the power plant. Instead, both the laptop (high-level) and the power plant (low-level) depend on a shared abstraction: the wall outlet standardIn DIP terms, the outlet standard is the interface. The laptop manufacturer builds to the standard. The power company builds to the standard. Neither depends on the other directly. You can swap power sources (grid, solar, generator) without modifying the laptop — and swap laptops without modifying the grid.. You can plug in ANY device that follows the standard. You can switch power sources (grid → solar → generator) without modifying the laptop. That's DIP: both sides depend on the standard (abstraction), not on each other.
The Headphone Jack
A phone with a 3.5mm jackThe 3.5mm audio jack is a universal interface — any headphones with a 3.5mm plug work with any device that has a 3.5mm port. The phone doesn't know if you're using $5 earbuds or $500 studio monitors. The headphones don't know if they're plugged into a phone, laptop, or synthesizer. Both sides depend on the 3.5mm standard. doesn't care if you plug in $5 earbuds or $500 studio monitors. The phone depends on the "audio output" abstraction, not on a specific headphone model. If Apple had soldered AirPods directly into the iPhone (no jack, no Bluetooth), you'd be "welded" to one vendor — that's a DIP violation.
The Postal System
When you mail a letter, you don't drive it to the recipient's house. You depend on an abstraction: the postal address. The postal service (low-level) handles delivery — whether by truck, plane, or drone. You (high-level sender) and the recipient both depend on the addressing standard. Changing the delivery mechanism (USPS → FedEx → drone) doesn't change how you write the address. That's DIP: the business logic (sending a letter) doesn't know the infrastructure (delivery truck).
The CEO and the Report
A CEO (high-level policy maker) doesn't write SQL queries to get sales data. They depend on a report format (abstraction) — a dashboard, a PDF, a spreadsheet. The data team (low-level) implements the report from whatever database they use. If the company migrates from Oracle to PostgreSQL, the CEO's report format doesn't change. The CEO depends on "I need sales numbers," not on "I need Oracle SQL." The dependency is invertedWithout DIP: CEO → Oracle Database (CEO depends on the implementation). With DIP: CEO → ISalesReport ← OracleSalesReport (both depend on the abstraction). The direction of the dependency has been "inverted" — it now points toward the abstraction instead of the implementation.: the implementation conforms to the abstraction, not the other way around.
Core Concept Diagram
DIP says: both high-level and low-level modules should depend on abstractions. The dependency arrowsIn UML and architecture diagrams, an arrow from A → B means "A depends on B" or "A knows about B." Without DIP, arrows point from business logic → infrastructure (OrderProcessor → SqlRepository). With DIP, arrows from both sides point to the abstraction (OrderProcessor → IRepository ← SqlRepository). The arrows toward infrastructure are "inverted." should point toward abstractions, not toward infrastructure.
Code Implementations
The classic DIP example. An order processor that depends on abstractions for data access, payment, and notifications — not on SQL, Stripe, or SendGrid directly.
// ❌ DIP VIOLATION — high-level module creates low-level dependencies directly
public class OrderProcessor
{
// Welded to concrete implementations
private readonly SqlOrderRepository _repo = new("Server=prod;Database=Orders;...");
private readonly StripePaymentGateway _payment = new("sk_live_abc123");
private readonly SendGridEmailSender _email = new("SG.api_key_here");
private readonly NLogLogger _logger = new("OrderProcessor");
public async Task<OrderResult> ProcessAsync(Order order)
{
_logger.Info($"Processing order {order.Id}");
// Can't change payment provider without rewriting this class
var charge = await _payment.ChargeAsync(order.Total, order.PaymentToken);
if (!charge.Success) return OrderResult.Failed(charge.Error);
// Can't switch from SQL Server without rewriting this class
await _repo.SaveAsync(order);
// Can't swap email for SMS/push without rewriting this class
await _email.SendAsync(order.CustomerEmail, "Order Confirmation",
$"Your order {order.Id} has been placed.");
return OrderResult.Success(order.Id);
}
}
// Problems:
// 1. Can't unit test without real SQL, Stripe, and SendGrid
// 2. Can't swap SQL → Mongo, Stripe → PayPal, SendGrid → Mailgun
// 3. Connection strings and API keys are hardcoded
// 4. Every infrastructure change requires modifying business logic
// ✅ DIP-COMPLIANT — abstractions owned by the business layer
// These interfaces live in the BUSINESS/DOMAIN project
public interface IOrderRepository
{
Task SaveAsync(Order order);
Task<Order?> GetByIdAsync(int id);
}
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string paymentToken);
}
public interface INotifier
{
Task SendAsync(string to, string subject, string body);
}
// Business logic depends ONLY on abstractions
public class OrderProcessor(
IOrderRepository repo,
IPaymentGateway payment,
INotifier notifier,
ILogger<OrderProcessor> logger)
{
public async Task<OrderResult> ProcessAsync(Order order)
{
logger.LogInformation("Processing order {OrderId}", order.Id);
var charge = await payment.ChargeAsync(order.Total, order.PaymentToken);
if (!charge.Success) return OrderResult.Failed(charge.Error);
await repo.SaveAsync(order);
await notifier.SendAsync(order.CustomerEmail, "Order Confirmation",
$"Your order {order.Id} has been placed.");
return OrderResult.Success(order.Id);
}
}
// Implementations live in a SEPARATE infrastructure project
public class SqlOrderRepository(AppDbContext db) : IOrderRepository
{
public async Task SaveAsync(Order order) { db.Orders.Add(order); await db.SaveChangesAsync(); }
public async Task<Order?> GetByIdAsync(int id) => await db.Orders.FindAsync(id);
}
public class StripePaymentGateway(IOptions<StripeSettings> opts) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string token)
{
var client = new StripeClient(opts.Value.ApiKey);
var charge = await client.Charges.CreateAsync(new() { Amount = (long)(amount * 100), Currency = "usd", Source = token });
return charge.Status == "succeeded" ? PaymentResult.Ok(charge.Id) : PaymentResult.Failed(charge.FailureMessage);
}
}
public class SendGridNotifier(IOptions<SendGridSettings> opts) : INotifier
{
public async Task SendAsync(string to, string subject, string body)
{
var client = new SendGridClient(opts.Value.ApiKey);
var msg = MailHelper.CreateSingleEmail(new("noreply@shop.com", "Shop"), new(to), subject, body, body);
await client.SendEmailAsync(msg);
}
}
// DI wiring (Program.cs) — the COMPOSITION ROOT
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddScoped<INotifier, SendGridNotifier>();
builder.Services.AddScoped<OrderProcessor>();
// Now:
// 1. Unit test with Mock<IOrderRepository> — no SQL needed
// 2. Swap Stripe → PayPal: new PayPalGateway : IPaymentGateway + 1 line in DI
// 3. Config lives in appsettings.json, not in code
// 4. Business logic NEVER changes when infrastructure changes
A notification system where the business logic decides when to notify, and infrastructure decides how. DIP ensures the "when" never knows the "how."
// ❌ Business logic knows about SMTP, Twilio, and Firebase
public class AppointmentService
{
public async Task BookAsync(Appointment appt)
{
// Save appointment...
// Hardcoded notification logic — business class knows infrastructure details
using var smtp = new SmtpClient("smtp.gmail.com", 587);
smtp.Credentials = new NetworkCredential("app@gmail.com", "password123");
smtp.EnableSsl = true;
await smtp.SendMailAsync(new MailMessage("app@gmail.com", appt.PatientEmail,
"Appointment Confirmed", $"Your appointment is at {appt.Time}"));
// Adding SMS means modifying THIS business class
var twilio = new TwilioRestClient("AC_sid", "auth_token");
await twilio.SendSmsAsync(appt.PatientPhone, "Appointment confirmed");
}
}
// ✅ Business logic depends on INotificationService — doesn't know how notifications work
public interface INotificationService
{
Task NotifyAsync(string recipient, string message);
}
public class AppointmentService(IAppointmentRepository repo, INotificationService notifier)
{
public async Task BookAsync(Appointment appt)
{
await repo.SaveAsync(appt);
await notifier.NotifyAsync(appt.PatientEmail,
$"Your appointment is at {appt.Time}");
// Business logic done. Doesn't know if it's email, SMS, push, or all three.
}
}
// Infrastructure: can compose multiple channels
public class CompositeNotifier(IEnumerable<INotificationChannel> channels) : INotificationService
{
public async Task NotifyAsync(string recipient, string message)
{
var tasks = channels.Select(c => c.SendAsync(recipient, message));
await Task.WhenAll(tasks);
}
}
public interface INotificationChannel
{
Task SendAsync(string to, string message);
}
public class EmailChannel(IOptions<SmtpSettings> opts) : INotificationChannel { /* ... */ }
public class SmsChannel(IOptions<TwilioSettings> opts) : INotificationChannel { /* ... */ }
public class PushChannel(IOptions<FirebaseSettings> opts) : INotificationChannel { /* ... */ }
// Adding a new channel: one new class + one DI line. Business logic untouched.
Logging is the DIP example every .NET developer encounters on day one — ILogger<T> is an abstraction, and the provider (Console, Seq, Application Insights) is infrastructure.
// ❌ Business logic depends on specific logging implementation
using NLog;
public class InventoryService
{
// Welded to NLog — can't switch to Serilog without rewriting every class
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
public void Restock(string sku, int quantity)
{
_logger.Info($"Restocking {quantity} units of {sku}");
// ... business logic ...
_logger.Warn($"Stock for {sku} is critically low");
}
}
// Every class in the project imports NLog directly
// Switching to Serilog = modifying hundreds of files
// ✅ Business logic depends on ILogger<T> — Microsoft's abstraction
using Microsoft.Extensions.Logging;
public class InventoryService(ILogger<InventoryService> logger)
{
public void Restock(string sku, int quantity)
{
logger.LogInformation("Restocking {Quantity} units of {Sku}", quantity, sku);
// ... business logic ...
logger.LogWarning("Stock for {Sku} is critically low", sku);
}
}
// Switch logging provider in ONE place (Program.cs):
builder.Logging.ClearProviders();
// Option A: Console
builder.Logging.AddConsole();
// Option B: Serilog
// builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
// Option C: Application Insights
// builder.Logging.AddApplicationInsights();
// InventoryService NEVER changes — it depends on ILogger (abstraction)
// The provider is infrastructure — wired at the composition root
Junior vs Senior
Both developers are asked to build a report generation system that creates PDF and Excel reports from data stored in various sources (SQL, API, CSV files).
// ❌ JUNIOR: Everything in one class, welded to concrete implementations
public class ReportGenerator
{
private readonly SqlConnection _db = new("Server=prod;Database=Reports;...");
public byte[] GenerateSalesReport(string format, DateTime from, DateTime to)
{
// Welded to SQL Server
var data = new List<SalesRecord>();
using var cmd = new SqlCommand(
$"SELECT * FROM Sales WHERE Date BETWEEN '{from}' AND '{to}'", _db);
_db.Open();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
data.Add(new SalesRecord
{
Id = reader.GetInt32(0),
Amount = reader.GetDecimal(1),
Date = reader.GetDateTime(2)
});
}
// Welded to format — if-else for every new format
if (format == "pdf")
{
// Welded to iTextSharp
using var ms = new MemoryStream();
var doc = new iTextSharp.text.Document();
// ... 50 lines of PDF generation ...
return ms.ToArray();
}
else if (format == "excel")
{
// Welded to EPPlus
using var package = new ExcelPackage();
// ... 40 lines of Excel generation ...
return package.GetAsByteArray();
}
throw new ArgumentException($"Unknown format: {format}");
}
}
// Problems:
// 1. Can't test without SQL Server
// 2. Adding CSV format means modifying this class (OCP violation too)
// 3. Can't reuse PDF generation for other reports
// 4. Data access + formatting + business logic all in one class (SRP violation)
// 5. SQL injection vulnerability in the date parameters
Problems
ReportGenerator creates SqlConnection directly — you can't test it without a running SQL Server. Every test is a slow integration test. CI/CD pipelines break when the database is down.
Switching from iTextSharp to QuestPDF means rewriting the class. Replacing SQL Server with an API data source means rewriting the class. Every infrastructure swap is a full rewrite — because business logic is welded to concrete libraries.
Data access, formatting, and business logic all live in one class. New developers can't tell where "business rules" end and "infrastructure" begins. Every change risks breaking unrelated functionality.
// ✅ SENIOR: Abstractions owned by the business layer
// These interfaces live in MyApp.Domain (business project)
public interface IReportDataSource
{
Task<IReadOnlyList<SalesRecord>> GetSalesAsync(DateTime from, DateTime to);
}
public interface IReportFormatter
{
string Format { get; } // "pdf", "excel", "csv"
byte[] Render(IReadOnlyList<SalesRecord> data, ReportMetadata metadata);
}
public record ReportMetadata(string Title, DateTime From, DateTime To);
public record SalesRecord(int Id, decimal Amount, DateTime Date, string Product);
// Business logic — depends ONLY on abstractions
// Doesn't know about SQL, iTextSharp, or EPPlus
public class ReportGenerator(
IReportDataSource dataSource,
IEnumerable<IReportFormatter> formatters,
ILogger<ReportGenerator> logger)
{
public async Task<byte[]> GenerateAsync(string format, DateTime from, DateTime to)
{
logger.LogInformation("Generating {Format} report for {From} to {To}", format, from, to);
var data = await dataSource.GetSalesAsync(from, to);
if (data.Count == 0)
throw new InvalidOperationException("No data found for the specified date range");
var formatter = formatters.FirstOrDefault(f =>
f.Format.Equals(format, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException($"Unsupported format: {format}");
var metadata = new ReportMetadata($"Sales Report", from, to);
return formatter.Render(data, metadata);
}
}
// This class:
// 1. Never changes when we switch databases
// 2. Never changes when we add new formats (OCP!)
// 3. Is fully testable with mocks
// 4. Has clear, focused responsibility (SRP!)
// Infrastructure project — implements the abstractions
public class SqlReportDataSource(AppDbContext db) : IReportDataSource
{
public async Task<IReadOnlyList<SalesRecord>> GetSalesAsync(DateTime from, DateTime to) =>
await db.Sales
.Where(s => s.Date >= from && s.Date <= to)
.Select(s => new SalesRecord(s.Id, s.Amount, s.Date, s.ProductName))
.ToListAsync();
}
public class PdfReportFormatter : IReportFormatter
{
public string Format => "pdf";
public byte[] Render(IReadOnlyList<SalesRecord> data, ReportMetadata metadata)
{
using var ms = new MemoryStream();
// iTextSharp / QuestPDF rendering...
return ms.ToArray();
}
}
public class ExcelReportFormatter : IReportFormatter
{
public string Format => "excel";
public byte[] Render(IReadOnlyList<SalesRecord> data, ReportMetadata metadata)
{
using var package = new ExcelPackage();
// EPPlus rendering...
return package.GetAsByteArray();
}
}
// Adding CSV: one new class, one DI line. ZERO changes to business logic.
public class CsvReportFormatter : IReportFormatter
{
public string Format => "csv";
public byte[] Render(IReadOnlyList<SalesRecord> data, ReportMetadata metadata)
{
var sb = new StringBuilder("Id,Amount,Date,Product\n");
foreach (var r in data)
sb.AppendLine($"{r.Id},{r.Amount},{r.Date:yyyy-MM-dd},{r.Product}");
return Encoding.UTF8.GetBytes(sb.ToString());
}
}
// The Composition Root — where abstractions meet implementations
var builder = WebApplication.CreateBuilder(args);
// Data source: swap SQL → API → CSV by changing ONE line
builder.Services.AddScoped<IReportDataSource, SqlReportDataSource>();
// Formatters: register all — new ones auto-discovered via DI
builder.Services.AddSingleton<IReportFormatter, PdfReportFormatter>();
builder.Services.AddSingleton<IReportFormatter, ExcelReportFormatter>();
builder.Services.AddSingleton<IReportFormatter, CsvReportFormatter>();
// Business logic
builder.Services.AddScoped<ReportGenerator>();
// Usage in a controller:
app.MapGet("/reports/{format}", async (
string format, DateTime from, DateTime to, ReportGenerator generator) =>
{
var bytes = await generator.GenerateAsync(format, from, to);
return Results.File(bytes, format == "pdf" ? "application/pdf" : "application/octet-stream");
});
Testable: Mock IReportDataSource to return test data — no database needed. Extensible: Add CSV format with one class + one DI line. Swappable: Switch from SQL Server to a REST API by changing the DI registration. Clear: Each class has one job, each dependency is explicit.
Testability
How DIP enables unit testing without real infrastructure.
Click to exploreSwappability
Changing infrastructure without touching business logic.
Click to exploreLayer Architecture
How DIP shapes project structure and layer boundaries.
Click to exploreEvolution & History
1996 — Robert C. Martin Defines DIP
Martin published the Dependency Inversion Principle in his 1996 article "The Dependency Inversion Principle" in The C++ ReportA technical magazine focused on C++ programming (1989-2002). Martin's SOLID articles were originally published here before being compiled into his book "Agile Software Development, Principles, Patterns, and Practices" (2002). DIP was the final of the five SOLID principles.. The principle has two parts: (A) High-level modules should not depend on low-level modules — both should depend on abstractions. (B) Abstractions should not depend on details — details should depend on abstractions. Martin used the "Copy" program example: a copy routine shouldn't depend on ReadKeyboard and WritePrinter directly — it should depend on IReader and IWriter abstractions.
2004 — Martin Fowler's IoC Containers Article
Fowler published "Inversion of Control Containers and the Dependency Injection patternFowler's 2004 article (martinfowler.com) distinguished three forms of DI: constructor injection, setter injection, and interface injection. He also clarified the difference between IoC (a general principle) and DI (a specific pattern). This article established the terminology the industry uses today." in 2004, coining the term "Dependency Injection" to distinguish it from the broader "Inversion of Control." He described three injection styles: constructor injection (preferred), setter injection, and interface injection. This article became the definitive reference for DI patterns and directly influenced how .NET frameworks would later implement DI.
2005-2010 — IoC Container Explosion in .NET
The .NET ecosystem exploded with IoC containers: Castle WindsorOne of the oldest .NET IoC containers (2004). Known for its powerful facilities system, interceptors, and convention-based registration. Used heavily in enterprise .NET before ASP.NET Core's built-in container. (2004), StructureMapA .NET IoC container by Jeremy Miller (2004). Pioneered auto-registration by convention (scan assemblies for types matching naming patterns). Influenced ASP.NET Core's DI design but was retired in 2018. (2004), AutofacA popular .NET IoC container (2007) that introduced module-based registration, lifetime scopes, and powerful decoration support. Still widely used alongside ASP.NET Core's built-in container for advanced scenarios. (2007), NinjectA "lightning-fast" .NET IoC container (2008) known for its fluent binding syntax and contextual binding. Popular in the ASP.NET MVC era. Less commonly used in modern .NET Core projects. (2008), Unity (2008). Each provided different ways to wire abstractions to implementations. The problem: no standard. Every framework used a different container, making it hard to share libraries across projects.
2016 — ASP.NET Core: DIP Built-In
ASP.NET Core 1.0 shipped with a built-in DI container (Microsoft.Extensions.DependencyInjection) — the first Microsoft web framework to make DIP a first-class citizenBefore ASP.NET Core, DI in ASP.NET MVC was possible but required third-party containers and manual plumbing. ASP.NET Core made DI the default — controllers, middleware, filters, and services all receive dependencies via constructor injection out of the box. You can't build an ASP.NET Core app without using DI.. Controllers, middleware, filters, and Razor Pages all receive dependencies via constructor injection. The framework itself uses DIP internally — IWebHostBuilder, IApplicationBuilder, ILogger are all abstractions with swappable implementations.
2023 — .NET 8: Keyed Services
.NET 8 added keyed servicesA .NET 8 feature that lets you register multiple implementations of the same interface with different string keys. Inject with [FromKeyedServices("key")] attribute. Use case: IPaymentGateway with "stripe" and "paypal" keys — resolve the right one based on runtime context. — the ability to register multiple implementations of the same interface with different keys. This solved a long-standing limitation where you needed third-party containers (Autofac, Scrutor) to distinguish between multiple implementations. Now the built-in container supports [FromKeyedServices("key")] for constructor injection of specific implementations.
// .NET 8 Keyed Services — multiple implementations, same interface
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, PayPalGateway>("paypal");
builder.Services.AddKeyedScoped<IPaymentGateway, SquareGateway>("square");
// Inject a specific one:
public class CheckoutService([FromKeyedServices("stripe")] IPaymentGateway gateway)
{
// Gets StripeGateway specifically
}
// Or resolve dynamically:
public class PaymentRouter(IServiceProvider sp)
{
public IPaymentGateway GetGateway(string provider) =>
sp.GetRequiredKeyedService<IPaymentGateway>(provider);
}
.NET / ASP.NET Core Ecosystem
Microsoft.Extensions.DependencyInjection
The built-in DI container that ships with every ASP.NET Core app. Three lifetimesHow long an instance lives. Transient: new instance every time it's requested. Scoped: one instance per HTTP request (or per scope). Singleton: one instance for the entire application lifetime. Choosing wrong lifetimes causes captive dependency bugs (e.g., scoped service captured by a singleton)., constructor injection by default, and factory delegates for complex wiring.
var builder = WebApplication.CreateBuilder(args);
// TRANSIENT — new instance every time it's resolved
// Use for: lightweight, stateless services
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// SCOPED — one instance per HTTP request
// Use for: DbContext, Unit of Work, anything request-specific
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<AppDbContext>();
// SINGLETON — one instance for the entire app lifetime
// Use for: caching, configuration, HTTP client factories
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
// FACTORY DELEGATE — complex creation logic
builder.Services.AddScoped<IPaymentGateway>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var env = sp.GetRequiredService<IHostEnvironment>();
return env.IsDevelopment()
? new FakePaymentGateway() // Fake in dev
: new StripePaymentGateway(config); // Real in prod
});
// ⚠️ CAPTIVE DEPENDENCY WARNING:
// Never inject a Scoped service into a Singleton!
// builder.Services.AddSingleton<IMyService, MyService>(); // MyService depends on scoped DbContext
// → DbContext is captured and reused across requests → data corruption!
Clean Architecture Layers
DIP is the foundation of Clean ArchitectureAn architecture pattern by Robert C. Martin where dependencies point inward — outer layers (Infrastructure, UI) depend on inner layers (Application, Domain), never the reverse. The Domain layer has zero external dependencies. DIP is the mechanism that makes this dependency direction possible.. The dependency rule: source code dependencies must point inward. Inner layers (Domain, Application) define interfaces. Outer layers (Infrastructure, Presentation) implement them.
// PROJECT STRUCTURE — dependency arrows point INWARD
// MyApp.Domain (innermost — NO external references)
// ├── Entities/ (Order, Product, Customer)
// ├── Interfaces/ (IOrderRepository, IPaymentGateway)
// └── ValueObjects/ (Money, Email, Address)
// MyApp.Application (depends on Domain only)
// ├── Commands/ (CreateOrderCommand, ICreateOrderHandler)
// ├── Queries/ (GetOrderQuery, IGetOrderHandler)
// └── Services/ (OrderProcessor — uses Domain interfaces)
// MyApp.Infrastructure (depends on Domain + Application)
// ├── Persistence/ (SqlOrderRepository : IOrderRepository)
// ├── External/ (StripePaymentGateway : IPaymentGateway)
// └── DependencyInjection.cs (registers all implementations)
// MyApp.WebApi (depends on Application — NOT Infrastructure directly)
// ├── Controllers/ (OrdersController — depends on ICreateOrderHandler)
// └── Program.cs (composition root — wires everything)
// The INTERFACES live in Domain (inner layer)
// The IMPLEMENTATIONS live in Infrastructure (outer layer)
// Dependency arrows: Infrastructure → Domain ← Application
// This IS the Dependency Inversion Principle at the architecture level
MediatR & CQRS
MediatRA popular .NET library by Jimmy Bogard that implements the Mediator pattern. Instead of controllers depending on services directly, they send IRequest objects to MediatR, which routes them to the right IRequestHandler. This is DIP in action: the controller depends on the IMediator abstraction, not on any specific handler. is DIP applied to command/query handling. Controllers don't depend on service classes — they depend on IMediator, which routes requests to handlers. Each handler depends on domain interfaces, not infrastructure.
// Controller depends on IMediator (abstraction), not on handlers
[ApiController, Route("api/orders")]
public class OrdersController(IMediator mediator) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request) =>
Ok(await mediator.Send(new CreateOrderCommand(request.Items)));
}
// Command + Handler — handler depends on domain interfaces
public record CreateOrderCommand(List<OrderItem> Items) : IRequest<int>;
public class CreateOrderHandler(
IOrderRepository repo,
IPaymentGateway payment,
ILogger<CreateOrderHandler> logger) : IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.Items);
await payment.ChargeAsync(order.Total, order.PaymentToken);
await repo.SaveAsync(order);
logger.LogInformation("Order {Id} created", order.Id);
return order.Id;
}
}
// DIP chain: Controller → IMediator → IRequestHandler → IOrderRepository
// Every link depends on an abstraction. Zero concrete coupling.
Entity Framework Core: The DIP Debate
EF Core's DbContext is itself an abstraction over the database. The debate: should you add another abstraction (IRepository) on top? The answer depends on your architecture.
// OPTION A: DbContext IS the abstraction (pragmatic)
// Pro: Less code, EF is already testable (in-memory provider)
// Con: Business logic coupled to EF's API (LINQ, DbSet, SaveChanges)
public class OrderService(AppDbContext db)
{
public async Task<Order?> GetAsync(int id) => await db.Orders.FindAsync(id);
}
// OPTION B: Repository wraps DbContext (DIP-compliant)
// Pro: Business logic doesn't know about EF, truly swappable
// Con: More code, repository often just proxies DbContext
public class OrderService(IOrderRepository repo)
{
public async Task<Order?> GetAsync(int id) => await repo.GetByIdAsync(id);
}
// RECOMMENDATION:
// - Small apps / CRUD-heavy: Option A. DbContext is good enough.
// - Domain-heavy / complex business logic: Option B. The repository
// translates between domain models and EF entities.
// - Libraries / shared packages: ALWAYS Option B. You can't force
// consumers to use a specific ORM.
IHttpClientFactory
IHttpClientFactoryA .NET factory abstraction for creating HttpClient instances. It handles DNS rotation, connection pooling, and lifetime management. Instead of creating new HttpClient() directly (which causes socket exhaustion), inject IHttpClientFactory and call CreateClient(). This is DIP for HTTP — business code depends on the factory abstraction, not on HttpClient construction details. is DIP for HTTP calls. Instead of new HttpClient() scattered through business code, you depend on the factory abstraction. Named/typed clients keep configuration in the composition root.
// ❌ DIP violation — business code creates HttpClient directly
public class WeatherService
{
private readonly HttpClient _client = new() { BaseAddress = new Uri("https://api.weather.com") };
// Socket exhaustion risk, DNS caching issues, can't test
}
// ✅ DIP-compliant — typed client with factory
public class WeatherService(HttpClient client) // Injected by IHttpClientFactory
{
public async Task<Weather?> GetAsync(string city) =>
await client.GetFromJsonAsync<Weather>($"/v1/current?q={city}");
}
// Registration in Program.cs:
builder.Services.AddHttpClient<WeatherService>(client =>
{
client.BaseAddress = new Uri("https://api.weather.com");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, retry => TimeSpan.FromSeconds(Math.Pow(2, retry))));
// Configuration, retry policies, and HTTP details all in the composition root
// WeatherService knows nothing about base URLs, timeouts, or retry logic
ASP.NET Core Identity
ASP.NET Core Identity is a masterclass in DIP. The identity framework depends on abstractions (IUserStore<T>, IRoleStore<T>, IPasswordHasher<T>) — and you can swap every component.
// ASP.NET Core Identity — every component is an abstraction
builder.Services.AddIdentity<AppUser, IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>() // IUserStore → EF implementation
.AddDefaultTokenProviders(); // IUserTwoFactorTokenProvider → defaults
// Want to store users in MongoDB instead of SQL?
builder.Services.AddIdentity<AppUser, IdentityRole>()
.AddMongoDbStores<AppUser, IdentityRole, Guid>(config); // Different IUserStore
// Want a custom password hasher (Argon2 instead of PBKDF2)?
builder.Services.AddScoped<IPasswordHasher<AppUser>, Argon2PasswordHasher>();
// Want custom user validation?
builder.Services.AddScoped<IUserValidator<AppUser>, StrictEmailValidator>();
// DIP: Identity framework depends on IUserStore, IPasswordHasher, IUserValidator
// You provide implementations — the framework never knows the details
When To Apply / When Not To
Invert When
Don't Invert When
Ask: "Does this dependency cross a boundary where I'd want to swap, mock, or protect business logic from change?" If yes → invert. If no → direct reference is fine. DIP is about protecting volatility boundaries, not wrapping every class in an interface.
Comparisons
DIP vs Dependency Injection (DI)
- Design principle — "depend on abstractions, not concretions"
- Tells you what your dependency graph should look like
- Existed since 1996 (Robert C. Martin)
- Language-agnostic — works in C++, Java, C#, Go, Rust
- You can follow DIP without a DI container
- Delivery mechanism — passes dependencies from outside
- Tells you how to wire objects together at runtime
- Constructor, property, or method injection
- Often uses a DI containerA framework that automatically resolves and injects dependencies. In ASP.NET Core, this is Microsoft.Extensions.DependencyInjection — the built-in container. Third-party options include Autofac, Ninject, and Castle Windsor. The container reads your service registrations and constructs the object graph. (but doesn't require one)
- DI can exist without DIP (injecting concrete classes)
DIP says "depend on interfaces." DI says "I'll hand you the right implementation." DIP is the WHY; DI is the HOW. You can do DI without DIP (inject concrete classes) and DIP without DI (use a factory). But together they're the standard in modern .NET.
DIP vs IoC (Inversion of Control)
- About compile-time dependency direction
- High-level modules own the interfaces
- Applies to any dependency relationship
- One of the five SOLID principles
- About runtime control flow — "don't call us, we'll call you"
- The Hollywood PrincipleNamed after Hollywood casting calls: "Don't call us, we'll call you." In software: instead of your code calling the framework, the framework calls your code. ASP.NET Core calls your middleware, controllers, and filters — you don't call ASP.NET Core. This is IoC in action.
- Frameworks use IoC — ASP.NET calls YOUR controllers
- Broader concept — DIP is one way to achieve IoC
DIP vs Strategy Pattern
- Architectural principle — shapes dependency graphs
- Focus: who owns the abstraction
- Applied at project/layer boundaries
- Interface defined in high-level module
- Design pattern — swappable algorithms at runtime
- Focus: behavioral flexibility
- Applied at class level
- Strategy often uses DIP (depends on interface)
// Strategy IS DIP in action:
// IShippingCalculator (abstraction) owned by the domain layer
// FedExCalculator (low-level detail) depends on the abstraction
// OrderService (high-level policy) depends on the abstraction
// Both point inward → DIP!
public class OrderService(IShippingCalculator calc)
{
public decimal GetShipping(Order o) => calc.Calculate(o);
}
SOLID Connections
| Principle | Relation | Explanation |
|---|---|---|
| SRPSingle Responsibility Principle — a class should have only one reason to change. SRP naturally leads to smaller, focused classes that are easier to abstract behind interfaces, making DIP straightforward to apply. | Enables | SRP creates focused classes → easier to extract meaningful interfaces → DIP becomes natural. A God class with 10 responsibilities produces an unusable 50-method interface. |
| OCPOpen/Closed Principle — open for extension, closed for modification. DIP is the mechanism that MAKES OCP possible. When you depend on an interface (DIP), you can add new implementations without modifying existing code (OCP). | Enables | DIP is the mechanism that makes OCP work. Depending on IPaymentGateway (DIP) lets you add PayPalGateway without modifying OrderProcessor (OCP). They're two sides of the same coin. |
| LSPLiskov Substitution Principle — subtypes must be substitutable for their base types. LSP guarantees that any implementation you inject through DIP actually works correctly. Without LSP, DIP's promise of swappability falls apart. | Validates | DIP says "depend on the interface." LSP says "every implementation behind that interface must honor the contractThe behavioral expectations defined by an interface — not just the method signatures, but the semantics. If IPaymentGateway.ChargeAsync promises to return a result (never throw on declined cards), every implementation must follow this. Contracts include preconditions, postconditions, and invariants.." Without LSP, DIP breaks — you inject a StripeGateway that throws NotImplementedException on RefundAsync. |
| ISPInterface Segregation Principle — no client should be forced to depend on methods it doesn't use. ISP ensures the interfaces you create for DIP are lean and focused, not bloated "God interfaces" with 30 methods. | Refines | DIP says "depend on abstractions." ISP says "make those abstractions small and cohesive." Without ISP, DIP degenerates into fat interfaces that are painful to implement and mock. |
DIP is often taught last because it requires the other four. SRP creates focused classes to abstract. OCP is the goal DIP enables. LSP guarantees substitutability. ISP keeps interfaces lean. DIP ties them all together — it's the architectural glue of SOLID.
Bug Studies
Bug 1: Captive Dependency — Scoped Inside Singleton
A multi-tenantAn application architecture where a single instance serves multiple customers (tenants), each seeing only their own data. Common in SaaS products. Tenant isolation is critical — one tenant must never see another's data. DI scoping (Scoped services per request) is a key mechanism for tenant isolation. SaaS app leaked data between tenants. TenantService (Scoped — resolves the current tenant from the HTTP request) was injected into CacheManager (Singleton). The singleton captured the scoped service once at startup, so ALL requests used the first tenant's context. Customer A saw Customer B's data.
Imagine you run an apartment building where each tenant has their own mailbox. Now imagine you hire a permanent security guard (a Singleton) and on his first day, you hand him the key to Apartment 1's mailbox. "Use this to check mail," you say. The guard uses that same key every single day — even when Apartment 5's residents ask him to check their mail. He always opens Apartment 1's box. That's a captive dependency: a long-lived service (the guard) holding onto something that was only meant for one short-lived context (one tenant's request).
In this real incident, a multi-tenant SaaS application had a CacheManager registered as a Singleton — it lived for the entire lifetime of the application. It needed to know which tenant was making the current request, so it took ITenantService through its constructor. The problem? ITenantService was Scoped, meaning it was designed to be created fresh for every HTTP request, reading the tenant ID from the request headers.
But a Singleton is only created once, at application startup. So CacheManager got an ITenantService instance from the very first request — and held onto it forever. Every subsequent request, no matter which tenant it came from, used that same frozen ITenantService. The tenant ID never changed. Customer A logged in first, and from that point on, every customer's cache lookups used Customer A's tenant ID.
The bug was maddeningly intermittent. QA couldn't reproduce it locally because the dev server restarted between test runs, clearing the Singleton. It only showed up in staging where the server stayed alive across multiple requests from different tenants. It took 4 hours to diagnose because the code looked perfectly reasonable — it was the lifetime mismatch that was invisible.
Time to Diagnose
4 hours. The bug was intermittent — it only appeared after the second request because the first request correctly set the tenant. QA couldn't reproduce it locally because the dev server restarted between tests.
// ❌ SINGLETON captures SCOPED — captive dependency!
public class CacheManager
{
private readonly ITenantService _tenant; // Scoped!
public CacheManager(ITenantService tenant) // Injected ONCE at startup
{
_tenant = tenant; // Captured forever — never refreshed per request
}
public async Task<T?> GetAsync<T>(string key)
{
// _tenant.TenantId is ALWAYS the first request's tenant
var fullKey = $"{_tenant.TenantId}:{key}";
return await _cache.GetAsync<T>(fullKey);
}
}
Walking through the buggy code: Look at the constructor — it takes ITenantService tenant and stores it in a private field. That's perfectly normal for constructor injection. The problem is when this constructor runs. Because CacheManager is a Singleton, this constructor runs exactly once, during the very first request. The ITenantService instance it receives belongs to that first request's scope. After the first request ends, that scoped service is "stale" — it still holds the first tenant's ID but the request it belonged to is long gone. Every future call to GetAsync uses _tenant.TenantId, which is permanently frozen to the first tenant's value.
// ✅ Fix: Inject IServiceScopeFactory instead
public class CacheManager
{
private readonly IServiceScopeFactory _scopeFactory;
public CacheManager(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task<T?> GetAsync<T>(string key)
{
using var scope = _scopeFactory.CreateScope();
var tenant = scope.ServiceProvider.GetRequiredService<ITenantService>();
var fullKey = $"{tenant.TenantId}:{key}";
return await _cache.GetAsync<T>(fullKey);
}
}
Why the fix works: Instead of capturing the ITenantService directly, the Singleton now holds an IServiceScopeFactory — which is itself a Singleton, so there's no lifetime mismatch. Each time GetAsync is called, it creates a brand new scope with CreateScope(), and from that scope, it resolves a fresh ITenantService. That fresh instance reads the current request's tenant, not the first request's. The using keyword ensures the scope is disposed after the call, preventing memory leaks. Now every request gets the correct tenant, every time.
A Singleton must never inject a Scoped or Transient dependency directly. The lifetime hierarchy is: Singleton (longest) → Scoped → Transient (shortest). A longer-lived service must not capture a shorter-lived one. Use IServiceScopeFactory to create a fresh scope when needed. ASP.NET Core enables ValidateScopes by default in Development (since 2.0) — it catches this at startup.
Search for any class registered as AddSingleton and check its constructor parameters. If any parameter is a Scoped or Transient service, you have a captive dependency. A quick way to check: enable ValidateScopes = true in your development configuration — the DI container will throw an InvalidOperationException at startup if it detects a Singleton consuming a Scoped service. Also look for Singleton classes that inject DbContext (which is always Scoped) — that's the most common captive dependency in real projects.
Bug 2: Disposed DbContext in Background Task
An API endpoint queued a background job to send emails after an order. The job used the injected AppDbContext — but by the time the background thread ran, the HTTP request had ended and the Scoped DbContext was disposed. Result: ObjectDisposedException in production with no email sent and no error visible to the user.
Think about it like a restaurant. A waiter (the HTTP request) takes your order, hands it to the kitchen, and then clocks out for the night. But the kitchen still needs to look at the order slip (the DbContext) to prepare your food. The problem? When the waiter clocked out, he took the order slip with him and shredded it. The kitchen reaches for the slip and finds nothing — that's ObjectDisposedException.
In this real production incident, an API endpoint created an order in the database, then spun up a background task with Task.Run to send a confirmation email. The background task needed to look up the customer's email address, so it used the same AppDbContext that the controller was injected with. Seems harmless — the context is right there in the closure.
But here's the catch: AppDbContext is registered as Scoped, which means ASP.NET Core creates it at the start of the HTTP request and disposes it when the response is sent. The controller returns Ok(orderId) immediately, the response flies back to the client, and the DI scope closes — disposing the DbContext. Meanwhile, the background thread is still starting up. By the time Task.Run actually executes that FindAsync call, the DbContext is already disposed. The exception was swallowed by the fire-and-forget pattern (_ = Task.Run(...)), so no error appeared in the API response and no one noticed until customers reported missing confirmation emails.
The scariest part: this bug didn't show up in development. The dev machine was fast enough that the background task finished before the scope was disposed. It only appeared under production load where the thread pool was busy and the background task was delayed.
// ❌ DbContext is Scoped — dies when the request ends
public class OrderController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IEmailSender _email;
[HttpPost]
public async Task<IActionResult> Create(OrderDto dto)
{
var order = await _db.Orders.AddAsync(MapToEntity(dto));
await _db.SaveChangesAsync();
// Fire-and-forget — _db is disposed by the time this runs!
_ = Task.Run(async () =>
{
var customer = await _db.Customers.FindAsync(order.Entity.CustomerId);
await _email.SendAsync(customer!.Email, "Order Confirmed", BuildBody(order.Entity));
});
return Ok(order.Entity.Id);
}
}
Walking through the buggy code: The controller injects AppDbContext and IEmailSender — both Scoped services. After saving the order, it kicks off Task.Run to send an email. The lambda inside Task.Run captures _db and _email from the controller's fields. But notice the _ = at the start — that's the fire-and-forget pattern. The controller doesn't await the task; it immediately returns Ok(orderId). As soon as the response is sent, ASP.NET Core disposes the request scope, which disposes _db. When the background thread eventually calls _db.Customers.FindAsync, the DbContext is already dead. Exception thrown, email never sent, nobody knows.
// ✅ Fix: Use IServiceScopeFactory for background work
public class OrderController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IServiceScopeFactory _scopeFactory;
[HttpPost]
public async Task<IActionResult> Create(OrderDto dto)
{
var order = await _db.Orders.AddAsync(MapToEntity(dto));
await _db.SaveChangesAsync();
var orderId = order.Entity.Id;
var customerId = order.Entity.CustomerId;
// Create a NEW scope for background work
_ = Task.Run(async () =>
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var email = scope.ServiceProvider.GetRequiredService<IEmailSender>();
var customer = await db.Customers.FindAsync(customerId);
await email.SendAsync(customer!.Email, "Order Confirmed", BuildBody(orderId));
});
return Ok(orderId);
}
}
Why the fix works: Instead of capturing the controller's DbContext, the background task creates its own DI scope with _scopeFactory.CreateScope(). That scope provides a brand-new AppDbContext and IEmailSender that are completely independent of the HTTP request. Notice that we also capture orderId and customerId as plain values before entering Task.Run — these are just integers, so they're safe to use anywhere. The using keyword on the scope ensures that the new DbContext and all its resources are properly disposed when the background work is done. The scope's lifetime is now controlled by the task, not by the HTTP request.
Scoped services are tied to the HTTP request lifetime. Background work needs its own scope. Better yet, use a proper background queue (IHostedServiceAn ASP.NET Core interface for long-running background services. Implementations run alongside the web server and have their own DI scope. Common uses: message queue consumers, scheduled tasks, health checks. Combined with Channel<T> (an async-safe producer/consumer queue), it's the proper way to do background work in ASP.NET Core. + Channel<T>) instead of fire-and-forget Task.Run.
Search for Task.Run and _ = Task (fire-and-forget patterns) inside controllers or services. Then check whether the code inside the task uses any injected services. If it references _db, _repo, or any field that came from constructor injection, you probably have this bug. The same applies to ThreadPool.QueueUserWorkItem, BackgroundJob.Enqueue (Hangfire), or any pattern that runs code after the HTTP response is sent. The fix is always the same: create a new scope inside the background work.
Bug 3: Circular Dependency Deadlock
Application failed to start with a cryptic StackOverflowException. OrderService depended on IInventoryService, and InventoryService depended on IOrderService. The DI container tried to resolve the cycle infinitely.
Picture two people standing in a hallway, each refusing to go first. "After you." "No, after you." "No, I insist, after you." They stand there forever, blocking everyone. That's exactly what happens with a circular dependency: Service A says "I need Service B to be created first," and Service B says "I need Service A to be created first." Neither can exist without the other already existing, so the DI container tries to create A, which triggers creating B, which triggers creating A again, which triggers creating B again... until the call stack overflows and the application crashes.
In this case, OrderService needed IInventoryService because placing an order requires checking stock. Meanwhile, InventoryService needed IOrderService because inventory adjustments needed to look up order details. Both teams thought they were following DIP perfectly — they were programming to interfaces, using constructor injection, everything by the book. But neither team noticed the cycle because each service was developed independently.
The app worked in unit tests because each service was tested in isolation with mocked dependencies. The cycle only appeared when the real DI container tried to wire everything together at startup. The error message was a StackOverflowException with no useful stack trace — just thousands of repeated frames showing the container trying to resolve the same two types over and over.
The root cause wasn't a DIP problem — it was a design problem. When two services depend on each other, it usually means there's a missing concept between them. In this case, that missing concept was a domain event: "an order was placed." Instead of OrderService calling InventoryService directly, it should publish the fact that something happened, and let interested parties react.
// ❌ Circular dependency — A needs B, B needs A
public class OrderService : IOrderService
{
public OrderService(IInventoryService inventory) { }
}
public class InventoryService : IInventoryService
{
public InventoryService(IOrderService orders) { } // Boom! Cycle.
}
Walking through the buggy code: When the DI container tries to create OrderService, it sees the constructor needs IInventoryService. So it tries to create InventoryService. But wait — InventoryService's constructor needs IOrderService. So the container tries to create OrderService again. Which needs IInventoryService. Which needs IOrderService. And around and around it goes. The call stack grows deeper with every iteration until it hits the CLR's stack limit (usually around 1MB, roughly 10,000-15,000 frames), and you get a StackOverflowException with no useful information about what went wrong.
// ✅ Fix: Break cycle with MediatR events
public class OrderService : IOrderService
{
private readonly IMediator _mediator;
public OrderService(IMediator mediator)
{
_mediator = mediator;
}
public async Task PlaceOrderAsync(Order order)
{
// Instead of calling IInventoryService directly,
// publish an event that InventoryService handles
await _mediator.Publish(new OrderPlacedEvent(order));
}
}
public class ReserveStockHandler : INotificationHandler<OrderPlacedEvent>
{
private readonly IInventoryRepository _inventory;
public ReserveStockHandler(IInventoryRepository inventory)
{
_inventory = inventory;
}
public async Task Handle(OrderPlacedEvent e, CancellationToken ct)
{
await _inventory.ReserveStockAsync(e.Order.Items);
}
}
Why the fix works: The cycle is broken because OrderService no longer knows about IInventoryService at all. It only knows about IMediator, a generic message bus. When an order is placed, it publishes an OrderPlacedEvent — a plain data object saying "this happened." Separately, ReserveStockHandler listens for that event and does the inventory work. Notice that the handler depends on IInventoryRepository (not IInventoryService) — it only needs the data access piece, not the full service. The two services no longer reference each other. The mediator sits in between and routes events to handlers at runtime, with no compile-time coupling.
Circular dependencies mean your abstractions are tangled. Break cycles by: (1) introducing a mediatorThe Mediator pattern decouples objects so they don't reference each other directly. Instead of A→B and B→A, both A and B talk through a mediator. In .NET, MediatR is the most popular implementation — it uses INotification for events and IRequest for commands. (MediatR events), (2) extracting a shared interface, or (3) using Lazy<T> to defer resolution. Option 1 is preferred — it usually reveals a missing domain event.
If your application crashes at startup with a StackOverflowException or an extremely long repeated stack trace, suspect a circular dependency. To find cycles proactively, draw a quick dependency graph: for each service, list what it injects. If you can follow the arrows and end up back where you started, you have a cycle. Tools like NDepend or dotnet-depends can automate this. Also watch for the subtler variant: A → B → C → A (three-way cycle). The longer the chain, the harder it is to spot by reading code alone.
Bug 4: Registering Interface but Resolving Concrete
Tests passed (using mocked IEmailSender) but production sent real emails during integration tests. The test host registered a fake IEmailSender, but a middleware was resolving EmailSender (the concrete class) directly from the container.
Imagine a company has a phone directory (the DI container). There's an entry under "Email Department" (the interface IEmailSender) that points to "John's desk" (EmailSender). During testing, the team redirects "Email Department" to a dummy mailbox. But one employee has John's direct extension written on a sticky note and keeps calling him directly. The test team thinks all email is being intercepted, but John is still getting real calls because someone bypassed the directory.
Here's what happened in practice. The team properly registered IEmailSender with AddScoped<IEmailSender, EmailSender>(). All controllers and services used IEmailSender through constructor injection. Tests replaced IEmailSender with a fake that discarded emails. Everything looked clean.
But somewhere in the codebase, someone also added builder.Services.AddScoped<EmailSender>() — registering the concrete class directly. And a middleware was resolving GetRequiredService<EmailSender>() (the concrete type, not the interface). This created two completely separate registrations in the container. When tests replaced IEmailSender with a fake, the concrete EmailSender registration was untouched. The middleware happily used the real email sender, sending actual emails to real customers during integration tests.
The team only noticed when a customer replied to a test email asking "What's this about?" The root cause was that two different developers added the registrations at different times, in different PRs, and neither noticed the duplication.
// Registration — looks correct
builder.Services.AddScoped<IEmailSender, EmailSender>();
// ❌ But ALSO registered the concrete type directly!
builder.Services.AddScoped<EmailSender>();
// Middleware resolves the CONCRETE type, bypassing the interface
app.Use(async (ctx, next) =>
{
var sender = ctx.RequestServices.GetRequiredService<EmailSender>(); // Concrete!
// This is NOT the same instance as IEmailSender
// Tests that replace IEmailSender don't affect this
await next();
});
Walking through the buggy code: There are two AddScoped calls. The first one says "when someone asks for IEmailSender, give them an EmailSender." The second one says "when someone asks for EmailSender (the concrete class), also give them an EmailSender." These are separate entries in the container's dictionary. The middleware calls GetRequiredService<EmailSender>() with the concrete type, so it hits the second registration — the one that tests never replaced. The test team only swapped the IEmailSender entry with a fake, leaving the concrete EmailSender entry completely untouched.
// ✅ Fix: ONLY register the interface, never the concrete type
builder.Services.AddScoped<IEmailSender, EmailSender>();
// Do NOT add: builder.Services.AddScoped<EmailSender>();
// Always resolve via the interface
app.Use(async (ctx, next) =>
{
var sender = ctx.RequestServices.GetRequiredService<IEmailSender>();
await next();
});
Why the fix works: With only one registration (IEmailSender → EmailSender), every part of the application goes through the same entry in the container. When tests replace IEmailSender with a fake, the middleware uses that fake too — because the middleware now resolves IEmailSender, not the concrete type. There's only one door into the "email department," and that door is the interface. If you need to redirect traffic, you only need to redirect that one door.
Registering both IEmailSender → EmailSender AND EmailSender separately creates two different registrations. Code resolving the concrete type bypasses any test fakes. Rule: register via interface, resolve via interface, never register concrete types that have an interface.
Search your Program.cs (and any extension methods called from it) for AddScoped<SomeConcreteClass>() or AddSingleton<SomeConcreteClass>() where SomeConcreteClass implements an interface. If you find both AddScoped<IFoo, Foo>() and AddScoped<Foo>(), you have a duplicate registration. Also search for GetRequiredService<SomeConcreteClass>() — if the type has an interface, it should be resolved via the interface instead. A code review rule: "never resolve a concrete type that has an interface."
Bug 5: new() Inside a DIP-Compliant Class
Code review approved a "DIP-compliant" service. Constructor accepted IOrderRepository. But deep inside one method, the developer wrote new SmtpClient("smtp.company.com"). Tests couldn't mock email sending, and the service silently depended on a specific SMTP server.
Think of DIP like customs inspection at an airport. The constructor is the front door — every dependency that comes through it gets declared and inspected. But imagine a traveler who goes through customs with an empty suitcase (clean constructor), then pulls contraband out of their coat pocket inside the terminal (using new inside a method). The inspection system never saw it. That's what new SmtpClient("smtp.company.com") is — a smuggled dependency that bypasses the constructor's "customs."
The class looked perfectly DIP-compliant to code reviewers. The constructor took IOrderRepository — an interface, just like the textbook says. But 40 lines into the CompleteAsync method, there was a new SmtpClient("smtp.company.com") hiding in plain sight. This created a hard dependency on a specific SMTP server that nobody could see by looking at the constructor signature.
The test team wrote unit tests that mocked IOrderRepository perfectly. All tests passed. But they couldn't prevent real emails from being sent during testing, because SmtpClient wasn't injected — it was created inside the method. The tests couldn't reach it. Even worse, when the SMTP server changed addresses, this service broke silently because the server name was hardcoded in the new call, not in configuration.
This is one of the sneakiest DIP violations because it passes both code review and unit testing. The class looks DIP-compliant (interfaces in constructor, check!) but behaves like a tightly-coupled monolith (hardcoded infrastructure inside methods, fail!).
public class OrderService
{
private readonly IOrderRepository _repo; // ✅ Injected
public OrderService(IOrderRepository repo) => _repo = repo;
public async Task CompleteAsync(int orderId)
{
var order = await _repo.GetByIdAsync(orderId);
order.Complete();
await _repo.SaveAsync(order);
// ❌ Hidden dependency — new() buried inside method
using var smtp = new SmtpClient("smtp.company.com");
var msg = new MailMessage("noreply@co.com", order.Email, "Done!", "...");
await smtp.SendMailAsync(msg);
}
}
Walking through the buggy code: The constructor looks textbook-perfect: one interface parameter, one private field, clean injection. But scroll down to CompleteAsync. After the repository work, the method creates a new SmtpClient("smtp.company.com"). That single new call creates three problems at once. First, the SMTP server address is hardcoded as a string literal — no configuration, no flexibility. Second, tests can't intercept email sending because there's no interface to mock. Third, the class's constructor signature is misleading — it says "I only need a repository" when it actually also needs an SMTP server. The constructor is an incomplete ingredient list.
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailSender _email; // ✅ All dependencies injected
public OrderService(IOrderRepository repo, IEmailSender email)
{
_repo = repo;
_email = email;
}
public async Task CompleteAsync(int orderId)
{
var order = await _repo.GetByIdAsync(orderId);
order.Complete();
await _repo.SaveAsync(order);
await _email.SendAsync(order.Email, "Done!", "...");
}
}
Why the fix works: The hidden new SmtpClient() is replaced with an injected IEmailSender. Now the constructor is an honest ingredient list: "I need a repository and an email sender." Tests can mock IEmailSender and verify emails are sent without actually hitting an SMTP server. The SMTP configuration now lives inside the IEmailSender implementation (which reads from IOptions<SmtpOptions>), not hardcoded in business logic. And the CompleteAsync method is simpler — it just calls _email.SendAsync() without worrying about SMTP details.
DIP compliance isn't just about the constructor — it's about the entire class. Search for new keyword in business logic. If the newed-up object has side effects (I/O, network, file system), it's a hidden dependency that breaks testability. DTOs and value objects are fine to new up.
Do a project-wide search for the new keyword inside service and business logic classes (exclude DTOs, models, and test files). For each hit, ask: "Does this object do I/O?" If it touches the network (HttpClient, SmtpClient), the file system (StreamWriter, File.Open), or external services, it's a hidden dependency that should be injected instead. Common culprits: new HttpClient(), new SmtpClient(), new SqlConnection(), File.ReadAllText(). A code review checklist item: "No new on I/O types in business logic."
Bug 6: Service Locator Disguised as DIP
A developer injected IServiceProvider into every class and called GetRequiredService<T>() everywhere. "We're using DIP — all dependencies come from the container!" But no constructor signature revealed the true dependencies. The team couldn't tell what a class needed without reading every method.
Imagine you're cooking a recipe, but instead of a clear ingredient list at the top, the recipe just says "Go to the pantry." You start cooking, and in step 3 it says "grab flour from the pantry." Step 7 says "grab sugar from the pantry." Step 12 says "grab that obscure spice you might not have." You can't prepare ingredients ahead of time because you don't know what you need until you're mid-recipe. That's what the Service Locator does — it replaces a clear ingredient list (constructor injection) with "just ask the pantry (IServiceProvider) whenever you need something."
A developer on the team had read about DIP and understood the idea of "don't create your own dependencies." So they injected IServiceProvider into every class and used GetRequiredService<T>() inside methods whenever they needed something. Technically, no class was using new to create dependencies. Technically, everything came from the DI container. The developer genuinely believed this was proper DIP.
The problem was invisible at first but grew into a maintenance nightmare. When a new developer tried to write tests for InvoiceService, they looked at the constructor: just IServiceProvider. "Easy, I'll mock that." But what should the mock return? To find out, they had to read every line of every method to discover the 4 hidden dependencies scattered across 500 lines. Miss one, and the test crashes with a NullReferenceException at runtime.
Even worse, when someone removed IBlobStorage from the DI container (during a refactor), the compiler didn't catch it. The app compiled perfectly. The bug only appeared when a user actually triggered the invoice generation code path, weeks after the refactor. With constructor injection, the compiler would have caught it immediately — you can't create an InvoiceService without providing an IBlobStorage.
// ❌ Service Locator — hides real dependencies
public class InvoiceService
{
private readonly IServiceProvider _sp;
public InvoiceService(IServiceProvider sp) => _sp = sp;
public async Task GenerateAsync(int orderId)
{
// What does this class ACTUALLY need? Nobody knows without reading every line
var repo = _sp.GetRequiredService<IOrderRepository>();
var pdf = _sp.GetRequiredService<IPdfGenerator>();
var storage = _sp.GetRequiredService<IBlobStorage>();
var email = _sp.GetRequiredService<IEmailSender>();
// ... hidden dependencies scattered across 500 lines
}
}
Walking through the buggy code: The constructor takes just IServiceProvider — a universal key that can resolve anything from the DI container. Inside the method, four separate GetRequiredService calls pull out the real dependencies one by one. From the outside (looking only at the constructor), this class appears to need just "the container." You have no idea it actually needs a repository, a PDF generator, blob storage, and an email sender. It's like a function that takes object as its only parameter — technically works, but tells the caller nothing about what it actually expects.
// ✅ Explicit constructor injection — dependencies are visible
public class InvoiceService
{
private readonly IOrderRepository _repo;
private readonly IPdfGenerator _pdf;
private readonly IBlobStorage _storage;
private readonly IEmailSender _email;
public InvoiceService(
IOrderRepository repo,
IPdfGenerator pdf,
IBlobStorage storage,
IEmailSender email)
{
_repo = repo;
_pdf = pdf;
_storage = storage;
_email = email;
}
public async Task GenerateAsync(int orderId)
{
var order = await _repo.GetByIdAsync(orderId);
var document = await _pdf.CreateAsync(order);
var url = await _storage.UploadAsync(document);
await _email.SendAsync(order.CustomerEmail, "Invoice", url);
}
}
Why the fix works: The constructor now explicitly lists all four dependencies. Anyone looking at this class immediately knows: "It needs a repository, a PDF generator, blob storage, and an email sender." Tests provide mocks for exactly those four interfaces. If you remove IBlobStorage from the DI container, the application won't even start — the compiler and DI container both enforce completeness. The constructor is an honest contract, not a blank check. And as a bonus, if the constructor grows to 8+ parameters, that's a clear signal the class does too much (SRP violation) — a signal you'd never see with IServiceProvider.
The Service LocatorAn anti-pattern where classes ask a global container for their dependencies at runtime instead of receiving them through the constructor. It hides dependencies, makes testing harder, and defeats the purpose of DIP. Mark Seemann calls it "the opposite of DI" in his book Dependency Injection in .NET. pattern is the opposite of DIP. It hides dependencies behind IServiceProvider. Use explicit constructor injection — the constructor is your class's honest list of requirements. The only place IServiceProvider is acceptable is in factory classes or middleware that must create scopes.
Search for IServiceProvider in constructor parameters. If you find it in anything other than a factory class, middleware, or scope-creation code, it's likely the Service Locator anti-pattern. Also search for GetRequiredService and GetService calls outside of Program.cs and factory classes. Each one is a hidden dependency that should probably be an explicit constructor parameter. A healthy codebase has IServiceProvider in at most 2-3 classes; if you find it in 20+ classes, you have a systemic Service Locator problem.
Pitfalls
Mistake: Creating IOrderService for OrderService, IUserService for UserService — one interface per class, 1:1 mapping, zero additional implementations.
Why This Happens: Developers learn "depend on abstractions" and interpret it as "every class needs an interface." It feels like the right thing to do — more interfaces must mean more DIP compliance, right? But DIP says abstractions should represent boundaries, not mirror every class. A pure calculation function that adds up numbers has no side effects, doesn't talk to a database, doesn't send emails. There's nothing to mock, nothing to swap, and no boundary to protect. Wrapping it in an interface just doubles the files (ICalculatorService.cs + CalculatorService.cs) and makes "Go to Definition" take an extra click every time.
The rule of thumb: if you can't imagine a second implementation, and the class does no I/O, you probably don't need an interface.
// ❌ Header interface — adds indirection with no value
public interface ICalculatorService
{
decimal Calculate(Order order);
}
public class CalculatorService : ICalculatorService
{
public decimal Calculate(Order order) => order.Items.Sum(i => i.Price * i.Quantity);
}
// No other class implements ICalculatorService
// No test needs to mock a pure calculation
Why Bad: Adds navigation overhead, doubles the file count, and provides no testability or swappability benefit. Pure functions don't need interfaces.
// ✅ Pure logic — just use it directly, no interface needed
public class PriceCalculator
{
public decimal Calculate(Order order) => order.Items.Sum(i => i.Price * i.Quantity);
}
// In your service — inject directly or even make it static
public class OrderService(IOrderRepository repo, PriceCalculator calc)
{
public decimal GetTotal(int id) => calc.Calculate(repo.GetById(id));
}
The connection: The bad code wraps a pure math function behind an interface nobody will ever swap or mock. The good code uses the calculator directly — it's just math, and math doesn't need an abstraction layer. Save interfaces for things that actually cross boundaries: databases, APIs, file systems, email providers.
Mistake: Putting IOrderRepository in the Infrastructure project (next to SqlOrderRepository). Now the Domain project references Infrastructure — the dependency flows downward, violating DIP entirely.
Why This Happens: It feels natural to keep an interface next to its implementation — they're related, so they should be in the same folder, right? That's how you organize files in a simpler project. But in a layered or Clean Architecture setup, the interface and its implementation serve different masters. The interface defines what the consumer needs. The implementation satisfies that need using specific technology. If the interface lives in the Infrastructure project, then Domain must add a project reference to Infrastructure just to see it. And now Domain depends on Infrastructure — the exact opposite of what DIP is trying to achieve.
The correct mental model: the interface is a "want ad" posted by Domain. It says "I need someone who can save orders." The interface belongs to the one who needs it (Domain), not the one who fulfills it (Infrastructure).
// ❌ Interface lives next to implementation
MyApp.Infrastructure/
├── IOrderRepository.cs // Interface HERE
├── SqlOrderRepository.cs // Implementation HERE
// Domain must reference Infrastructure to use the interface!
// ✅ Interface lives in the CONSUMER's project
MyApp.Domain/
├── IOrderRepository.cs // Interface HERE — owned by Domain
MyApp.Infrastructure/
├── SqlOrderRepository.cs // Implementation references Domain
The connection: In the bad layout, Domain → Infrastructure (wrong direction). In the good layout, Infrastructure → Domain (correct — the low-level detail depends on the high-level policy). The only change is moving one file from one project to another, but it completely flips the dependency direction.
Mistake: A class has 8+ constructor parameters — all properly injected interfaces, but the sheer count signals the class does too much.
Why This Happens: A developer follows DIP perfectly — every dependency is an interface, every dependency is injected through the constructor. No new keywords hiding anywhere. The code is technically correct, and yet something is deeply wrong. When a constructor has 10 parameters, it means the class has 10 different reasons to change. If the email provider changes, this class changes. If the tax rules change, this class changes. If the PDF template changes, this class changes. That's not one class doing one thing — it's one class doing everything.
DIP is not a license to inject the entire world. A bloated constructor is a code smell that says "this class has too many responsibilities." The solution isn't to stop using DIP — it's to split the class into smaller focused services, or group related dependencies behind a higher-level interface (a Facade).
// ❌ Too many dependencies — SRP violation disguised as DIP compliance
public class OrderOrchestrator(
IOrderRepository orders,
IInventoryService inventory,
IPaymentGateway payment,
IEmailSender email,
ISmsSender sms,
IPdfGenerator pdf,
IBlobStorage storage,
IAuditLogger audit,
IDiscountEngine discounts,
ITaxCalculator tax) { }
// ✅ Group related dependencies behind facades
public class OrderOrchestrator(
IOrderRepository orders,
IPricingService pricing, // wraps: discounts + tax
INotificationService notifier, // wraps: email + sms
IDocumentService documents) // wraps: pdf + blob storage
{ }
// Each facade is its own focused class
public class PricingService(IDiscountEngine discounts, ITaxCalculator tax) : IPricingService { }
public class NotificationService(IEmailSender email, ISmsSender sms) : INotificationService { }
public class DocumentService(IPdfGenerator pdf, IBlobStorage storage) : IDocumentService { }
The connection: The bad code has 10 parameters — all valid interfaces, but too many responsibilities in one place. The good code groups related dependencies into facades: pricing stuff together, notification stuff together, document stuff together. The orchestrator drops from 10 to 4 parameters, and each facade is a focused class with its own single responsibility. DIP still applies at every level.
Mistake: Defining an interface that leaks infrastructure details — IRepository<T> with IQueryable<T> return types, or IEmailSender with SmtpSettings parameters.
Why This Happens: Developers want to be "flexible" and let callers compose their own queries. Returning IQueryable<T> from a repository seems generous — callers can filter, sort, and page however they want. But IQueryable is EF Core's query pipeline. It translates LINQ into SQL behind the scenes. Callers don't realize they're writing SQL Server-specific queries through what looks like C# code. When you try to swap the SQL Server implementation for Cosmos DB, those LINQ queries fail because Cosmos DB's IQueryable supports a different subset of operations.
The interface was supposed to be an abstraction — a wall that hides infrastructure details. But by returning IQueryable, you punched a giant hole in that wall. The infrastructure detail (the ORM's query engine) leaked right through.
// ❌ Leaky — IQueryable exposes the ORM's query provider
public interface IOrderRepository
{
IQueryable<Order> GetAll(); // Callers write LINQ-to-SQL without knowing it
// Now you can't swap SQL Server for Cosmos DB — IQueryable differs!
}
// ✅ Clean — returns domain objects, hides the query mechanism
public interface IOrderRepository
{
Task<IReadOnlyList<Order>> GetByStatusAsync(OrderStatus status);
Task<Order?> GetByIdAsync(int id);
}
The connection: The bad interface returns IQueryable, which is a technology-specific type that exposes the database engine's capabilities. The good interface returns simple domain types (Order, IReadOnlyList<Order>) wrapped in Task for async. The implementation decides how to query; the interface just describes what the caller needs. That's the essence of DIP — the interface speaks the caller's language, not the implementation's.
Mistake: Creating a beautiful interface + implementation, but forgetting to register it in Program.cs. The app compiles but crashes at runtime with InvalidOperationException: No service for type 'IMyService'.
Why This Happens: In C#, the compiler checks types, not DI wiring. You can create INotificationService, write a perfect EmailNotificationService, inject it into a controller's constructor, and the compiler says "looks good!" But the DI container is a separate system that only runs at startup. If you forgot to add services.AddScoped<INotificationService, EmailNotificationService>() in Program.cs, the container doesn't know about it. The app starts, and the first time someone hits the controller, boom — InvalidOperationException.
This is especially common in large solutions where teams add new services daily. Each new interface-implementation pair needs a registration line, and it's easy to forget when you're focused on the business logic.
// ❌ Interface + implementation exist, but nobody told the container
public interface INotificationService { Task NotifyAsync(string msg); }
public class EmailNotificationService : INotificationService { ... }
// Program.cs — oops, forgot to add:
// builder.Services.AddScoped<INotificationService, EmailNotificationService>();
// ✅ Scrutor auto-registers by convention — never forget again
builder.Services.Scan(scan => scan
.FromAssemblyOf<EmailNotificationService>()
.AddClasses()
.AsImplementedInterfaces()
.WithScopedLifetime());
// Also enable build-time validation to catch stragglers
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateOnBuild = true; // Catches missing registrations at startup
options.ValidateScopes = true; // Catches captive dependencies
});
The connection: The bad scenario relies on manual registration for every new service — and humans forget. The good code uses Scrutor to scan assemblies and auto-register everything that implements an interface. Combined with ValidateOnBuild, the app won't even start if any dependency is missing. You go from "runtime crash in production" to "startup failure in development."
Mistake: Registering a DbContext as Singleton ("it's expensive to create!") or a stateless validator as Scoped ("everything should be Scoped!").
Why This Happens: Developers often default to one lifetime for everything without thinking about what each service actually needs. "Make it Scoped because that's what DbContext uses" is the most common reasoning — but a stateless email validator doesn't need a new instance per request. It does the same thing every time with no internal state. Conversely, making DbContext a Singleton sounds like an optimization ("one instance = less overhead"), but DbContext tracks every entity it loads. As a Singleton, it accumulates tracked entities across all requests, eating more memory over time and causing concurrency exceptions when two requests try to modify the same entity.
The key question for every service: "Does it hold state that changes per request?" If yes, Scoped. If it's stateless or thread-safe, Singleton. If it needs a fresh instance every time (like an HttpClient message handler), Transient.
// ❌ DbContext as Singleton — tracks entities across requests, memory leaks, concurrency bugs
builder.Services.AddSingleton<AppDbContext>();
// ❌ Stateless service as Scoped — wastes memory (new instance per request for no reason)
builder.Services.AddScoped<IEmailValidator, EmailValidator>();
// ✅ Correct lifetimes
builder.Services.AddScoped<AppDbContext>(); // Scoped: one per request
builder.Services.AddSingleton<IEmailValidator, EmailValidator>(); // Singleton: stateless, reuse
The connection: The bad code gives DbContext too long a life (Singleton when it should be Scoped) and the validator too short a life (Scoped when it can be Singleton). The good code matches each service's lifetime to its actual behavior: DbContext holds per-request state so it's Scoped, and the validator is pure logic so it's Singleton.
Mistake: Creating IDateTimeProvider, IGuidFactory, IStringFormatter, IListWrapper<T> — abstracting BCL types that are already stable and side-effect-free.
Why This Happens: After learning DIP, some developers go all-in. "Everything should be an interface!" They create abstractions around string.Format(), Guid.NewGuid(), even List<T>. The thinking is: "What if we need to swap out the string formatter someday?" The answer is: you won't. String.Format has worked the same way since .NET 1.0 and it's not going to change. Robert C. Martin himself clarified that DIP targets volatile dependencies — things that change often, have side effects, or connect to external systems. The .NET base class library is the opposite of volatile.
The exception is worth noting: IDateTimeProvider (or .NET 8's built-in TimeProvider) IS valuable because DateTime.Now is a hidden dependency on the system clock, which makes time-sensitive tests impossible to control. But IStringFormatter? That's just noise.
// ❌ Abstracting stable BCL types — noise, no value
public interface IGuidFactory { Guid Create(); }
public interface IStringFormatter { string Format(string template, params object[] args); }
public interface IListWrapper<T> { void Add(T item); int Count { get; } }
// ✅ Only abstract volatile/side-effect dependencies
// Guid.NewGuid() — just use it directly, it's stable
var id = Guid.NewGuid();
// DateTime.Now — abstract this one! It's a hidden side effect (system clock)
public interface ITimeProvider { DateTimeOffset Now { get; } }
// .NET 8+ has built-in: TimeProvider.System
The connection: The bad code creates interfaces for things that will never change and never need mocking. The good code uses stable BCL types directly and only creates abstractions for things with real side effects (like the system clock). The decision rule: "Will I ever swap this? Do tests need to control this?" If both answers are no, skip the interface.
Mistake: Scattering services.AddScoped<...>() calls throughout the application — in controllers, in domain services, in random utility classes.
Why This Happens: As a project grows, different developers add registrations wherever it feels convenient. A domain service discovers it needs a new dependency, so the developer adds the registration right there in the domain project. A controller needs a special factory, so the registration goes in the controller's folder. Over time, DI registrations are scattered across 15 files in 5 projects. Nobody can answer the question "what's registered and with what lifetime?" without searching the entire solution.
The composition rootThe single location where ALL dependency registrations happen. In ASP.NET Core, this is Program.cs (or extension methods called from Program.cs). Every other class in the application should be completely unaware of the DI container. Only the composition root knows about concrete types and their lifetimes. should be the ONE place that knows about all concrete types. Business logic should be completely unaware that a DI container even exists.
// ❌ Registrations scattered across business logic
public class OrderService
{
public static void Register(IServiceCollection services) // DI leaks into domain
{
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<OrderService>();
}
}
// ✅ All registrations in dedicated extension methods
public static class InfrastructureExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
}
// Program.cs — the composition root
builder.Services.AddInfrastructure();
builder.Services.AddApplication();
The connection: The bad code puts DI registration inside domain classes, coupling them to IServiceCollection. The good code keeps all registrations in dedicated extension methods organized by layer, called from one place (Program.cs). Domain classes never see IServiceCollection — they only see the interfaces they depend on.
Mistake: Injecting IConfiguration directly into services and reading config["Smtp:Host"] with magic strings everywhere.
Why This Happens: IConfiguration is always available in the DI container — no registration needed. So developers take the path of least resistance: inject IConfiguration and read values with string keys like config["Smtp:Host"]. It works, and it's quick. But it creates three hidden problems. First, the key is a magic string — typo in the key name? No compiler error, just a null value that crashes at runtime. Second, you can't validate configuration at startup — missing values are only discovered when the code path runs. Third, testing is painful because you need to build an entire IConfiguration object just to provide one value.
IConfiguration is an infrastructure detail — it knows about JSON files, environment variables, and key vaults. Your business service shouldn't need to know any of that. It should just receive a strongly-typed object with the values it needs.
// ❌ Raw IConfiguration — magic strings, no validation, hard to test
public class EmailSender(IConfiguration config)
{
public void Send(string to, string body)
{
var host = config["Smtp:Host"]; // Magic string, no compile-time safety
var port = int.Parse(config["Smtp:Port"]!); // Crashes if missing
}
}
// ✅ Strongly-typed options with validation
public class SmtpOptions
{
public string Host { get; init; } = "";
public int Port { get; init; } = 587;
}
public class EmailSender(IOptions<SmtpOptions> options)
{
public void Send(string to, string body)
{
var smtp = options.Value; // Strongly typed, validated at startup
}
}
// In Program.cs:
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
The connection: The bad code depends on a global configuration dictionary with magic string keys. The good code depends on a POCO options classPlain Old CLR Objects that represent a configuration section. Registered with builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp")). Injected as IOptions<SmtpOptions>. Supports validation via DataAnnotations or IValidateOptions<T>. Tested by passing Options.Create(new SmtpOptions { ... }). — a simple object with properties that the compiler can type-check. In tests, you just pass Options.Create(new SmtpOptions { Host = "test", Port = 25 }). No need to build a whole configuration system.
Mistake: Defining synchronous interface methods when implementations will be async, forcing .Result or .GetAwaiter().GetResult() calls that cause deadlocks.
Why This Happens: When the interface was first written, the implementation was synchronous — maybe it read from an in-memory cache. The interface returned Order? directly. Later, the team swapped to a database implementation. But FindAsync returns Task<Order?>, and the interface demands synchronous Order?. The implementation is forced to bridge the gap with .GetAwaiter().GetResult(). This blocks the calling thread while waiting for the database. In ASP.NET Core, where the thread pool is limited, this blocking can cause deadlocks under load — all threads are blocked waiting for I/O that can't complete because there are no free threads to handle the callback.
The lesson: when you design an interface, think about what future implementations might need. Any method that could involve I/O (database, network, file system) should return Task<T> from day one.
// ❌ Sync interface forces blocking calls
public interface IOrderRepository
{
Order? GetById(int id); // Sync signature
}
public class SqlOrderRepository : IOrderRepository
{
public Order? GetById(int id)
{
// Forced to block — potential deadlock in ASP.NET!
return _db.Orders.FindAsync(id).GetAwaiter().GetResult();
}
}
// ✅ Async from the start — even if current impl is sync
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id);
}
// Sync implementation? No problem — wrap in Task.FromResult
public class InMemoryOrderRepository : IOrderRepository
{
public Task<Order?> GetByIdAsync(int id)
=> Task.FromResult(_cache.GetValueOrDefault(id));
}
// Async implementation? Natural fit
public class SqlOrderRepository : IOrderRepository
{
public async Task<Order?> GetByIdAsync(int id)
=> await _db.Orders.FindAsync(id);
}
The connection: The bad code defines a sync interface that forces async implementations to block, risking deadlocks. The good code defines the interface as async-firstDefine interface methods returning Task<T> or ValueTask<T> from the start, even if the current implementation is synchronous. Synchronous implementations can return Task.FromResult(). Going from sync to async later is a breaking change that ripples through the entire call chain. Starting async costs nothing.. Sync implementations wrap their return value in Task.FromResult() (zero overhead). Async implementations use async/await naturally. Starting async costs nothing; converting from sync to async later is a breaking change that ripples through every caller.
Testing Strategies
Strategy 1: Mock-Based Unit Testing (Moq)
The bread and butter of DIP testing. Each interface dependency gets a mockA test double that records interactions and can be configured to return specific values. Moq is the most popular .NET mocking library. Mocks verify that your code CALLS the right methods with the right arguments — they test behavior, not state. that isolates the class under test from all I/O.
[Fact]
public async Task ProcessAsync_ValidOrder_ChargesAndSaves()
{
// Arrange — create mocks for each interface
var repo = new Mock<IOrderRepository>();
var payment = new Mock<IPaymentGateway>();
var notifier = new Mock<INotifier>();
var logger = NullLogger<OrderProcessor>.Instance;
payment.Setup(p => p.ChargeAsync(It.IsAny<decimal>(), It.IsAny<string>()))
.ReturnsAsync(PaymentResult.Ok("ch_test_123"));
var sut = new OrderProcessor(repo.Object, payment.Object, notifier.Object, logger);
var order = new Order { Total = 99.99m, PaymentToken = "tok_test" };
// Act
var result = await sut.ProcessAsync(order);
// Assert — verify interactions
Assert.True(result.IsSuccess);
payment.Verify(p => p.ChargeAsync(99.99m, "tok_test"), Times.Once);
repo.Verify(r => r.SaveAsync(order), Times.Once);
notifier.Verify(n => n.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
Strategy 2: In-Memory Fakes for Integration Tests
Replace infrastructure implementations with lightweight in-memory versions. Great for testing multi-class workflows without hitting real databases or APIs.
// A fake that implements the same interface as SqlOrderRepository
public class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = [];
private int _nextId = 1;
public Task<Order?> GetByIdAsync(int id)
=> Task.FromResult(_orders.FirstOrDefault(o => o.Id == id));
public Task SaveAsync(Order order)
{
if (order.Id == 0) order.Id = _nextId++;
_orders.RemoveAll(o => o.Id == order.Id);
_orders.Add(order);
return Task.CompletedTask;
}
}
// In test setup — swap real DB for in-memory fake
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
Strategy 3: WebApplicationFactory Integration Tests
Test the entire HTTP pipeline with selected dependencies swapped out. WebApplicationFactoryA test fixture from Microsoft.AspNetCore.Mvc.Testing that boots your entire ASP.NET Core app in-memory. You can override DI registrations to swap real infrastructure (databases, APIs) with fakes while keeping everything else real — middleware, routing, model binding, authentication. boots your app in-memory and lets you override DI registrations.
public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove real payment gateway, add fake
services.RemoveAll<IPaymentGateway>();
services.AddScoped<IPaymentGateway, FakePaymentGateway>();
// Use EF In-Memory instead of SQL Server
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("test"));
});
}).CreateClient();
}
[Fact]
public async Task POST_Orders_Returns201()
{
var response = await _client.PostAsJsonAsync("/api/orders", new { Amount = 50m });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}
Strategy 4: Verify DI Configuration at Startup
Catch missing registrations, lifetime mismatches, and circular dependencies before the app handles any traffic.
var builder = WebApplication.CreateBuilder(args);
// Enable DI validation in Development
if (builder.Environment.IsDevelopment())
{
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // Default in Dev since ASP.NET Core 2.0
options.ValidateOnBuild = true; // Opt-in: catches missing registrations at startup
});
}
// Unit test: verify ALL interfaces can be resolved
[Fact]
public void AllServices_CanBeResolved()
{
var factory = new WebApplicationFactory<Program>();
using var scope = factory.Services.CreateScope();
// This will throw if any registration is missing or circular
var orderProcessor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
Assert.NotNull(orderProcessor);
}
Performance Considerations
DIP adds a layer of indirection (interfaces + DI resolution). Here's when it matters and when it doesn't.
DI Container Resolution Overhead
The ASP.NET Core DI container resolves services in nanoseconds for Singleton/Scoped (cached) and low microseconds for Transient (allocates). For 99.9% of applications, this is noise compared to a single database query (~1-50ms).
| Lifetime | Resolution Cost | Notes |
|---|---|---|
| Singleton | ~5 ns | Resolved once, cached forever. Zero per-request cost. |
| Scoped | ~50 ns | Resolved once per request scope. Cached within scope. |
| Transient | ~200 ns | New instance every time. Most "expensive" but still trivial. |
Verdict: DI resolution is never the bottleneck. Your database, HTTP calls, and serialization are 10,000x slower.
Virtual Dispatch (Interface Calls)
Calling a method through an interface uses virtual dispatchWhen calling a method on an interface or virtual method, the CLR looks up the actual implementation in a vtable (virtual method table) at runtime. This adds one pointer dereference (~1ns) compared to a direct call. The JIT compiler can sometimes devirtualize these calls when it proves only one implementation exists. — one extra pointer lookup (~1ns). The JIT often devirtualizes these when it can prove only one implementation exists (which is common in DI scenarios). When the JIT sees that only one class implements IOrderRepository in your app, it can prove the call target at compile time and inline the method directly — eliminating virtual dispatch entirely.
Verdict: Unmeasurable in business applications. Only matters in ultra-hot paths (game engines, allocators, parsers processing millions of items per second).
When DIP Actually Helps Performance
DIP enables performance optimizations that are impossible without it:
- Caching decorators: Wrap
IProductRepositorywith aCachedProductRepository— zero changes to business logic - Connection pooling:
IHttpClientFactorymanagesHttpClientlifetimes and DNS changes — your code just callsIOrderApi - Lazy loading: Inject
Lazy<IExpensiveService>to defer initialization until first use - Swapping implementations: Switch from EF Core to Dapper for read-heavy queries by changing one DI line
Verdict: DIP's performance cost is negligible. Its performance benefit — enabling optimizations without touching business code — is significant.
Interview Pitch
Opening: "The Dependency Inversion Principle says high-level modules shouldn't depend on low-level modules — both should depend on abstractions. It's the 'D' in SOLID and the architectural foundation of modern .NET."
Core: "The key insight is that the interface lives with the consumer, not the provider. My OrderProcessor defines IPaymentGateway in the Domain project. The StripeGateway implementation lives in Infrastructure and references Domain — so the dependency arrow points inward toward business logic, not outward toward infrastructure."
Example: "In practice, this means I can test OrderProcessor with a mock IPaymentGateway — no Stripe sandbox needed. In production, swapping from Stripe to PayPal is one new class plus one line in Program.cs. Business logic never changes."
Modern .NET: "ASP.NET Core's built-in DI container, IOptions<T> for configuration, IHttpClientFactory for HTTP calls, and ILogger<T> for logging — they're all DIP in action. The framework itself is built on this principle."
Close: "DIP is what makes Clean Architecture possible. It protects the domain from infrastructure churn and ensures that the most important code — the business rules — is the most stable code in the system."
Q&As
Easy (Conceptual)
Think of a traditional office building. The CEO (high-level business logic) has the IT department's direct phone number on speed dial. If the IT department moves offices or changes their number, the CEO's phone needs updating. The CEO depends on IT's details. That's the traditional direction: high-level depends on low-level.
Now invert it. Instead of the CEO knowing IT's number, the company creates a general "help desk" extension (the abstraction). Both the CEO and the IT department know this extension. If IT moves offices, only the phone system routing changes — the CEO's speed dial stays the same. The dependency arrow has flipped: now IT conforms to the company standard, not the other way around.
In code terms, DIP inverts the direction of the source code dependency arrow. Traditionally, your OrderProcessor (business logic) imports and references SqlOrderRepository (infrastructure). After inversion, both depend on IOrderRepository — an interface owned by the business logic layer. Infrastructure references Domain, not the reverse. The runtime call still flows from OrderProcessor to SqlOrderRepository, but the compile-time reference has flipped.
These two get confused all the time because they share the word "dependency" and often appear together. But they're completely different things. Think of it this way: DIP is a rule about direction ("your code should depend on abstractions, not concrete implementations"). DI is a delivery mechanism ("pass the thing your code needs from the outside, instead of creating it yourself").
You can do DI without DIP — just inject concrete classes directly: new OrderService(new SqlOrderRepository()). That's dependency injection (the repository is passed from outside), but not DIP (you're still depending on the concrete SqlOrderRepository). You can also do DIP without DI — use a factory method that returns IOrderRepository, and the class calls the factory internally. That's DIP (depending on an abstraction), but not DI (the class creates its own dependency). In modern .NET, they work best together: DIP defines the interfaces, and the DI container delivers the right implementations.
A wall outlet is the abstraction. Your laptop charger, phone charger, and lamp all depend on the outlet standard — not on each other, and not on the specific wiring behind the wall. The power company (low-level) also conforms to the standard. Neither the appliance nor the power company knows about each other — both depend on the outlet interface.
Here's another one that might click even better: think about USB-C. Your laptop has a USB-C port (the abstraction). You can plug in a charger, an external monitor, a keyboard, or a hard drive. The laptop doesn't know or care what's on the other end — it just knows the USB-C standard. And the accessory manufacturers don't need to know your specific laptop model — they just build to the USB-C spec. Both sides depend on the standard, not on each other. That's DIP in the real world: a shared contract that decouples the "who uses it" from the "who provides it."
Going back to the USB-C analogy: imagine if the USB-C "standard" said "you must use Samsung's specific wire gauge and Intel's specific chip." That's not really a standard anymore — it's just Samsung and Intel's implementation dressed up as a standard. Any other manufacturer trying to build a USB-C device would be forced to use Samsung wires.
The same thing happens in code when an interface like IOrderRepository returns SqlDataReader or IQueryable<T>. Those types are SQL Server-specific or EF Core-specific. The abstraction is supposed to hide how data is stored and only express what data the caller needs. When it leaks infrastructure details, swapping to a different database (or even a different ORM) becomes impossible because the interface itself is tied to a specific implementation. The fix: return domain types like Task<Order?> or Task<IReadOnlyList<Order>> — types that say nothing about SQL, EF, or HTTP.
In the consumer's project — typically Domain or Application. DIP says the high-level module owns the abstraction. IOrderRepository lives in Domain because Domain defines what it needs. SqlOrderRepository lives in Infrastructure and references Domain. This way, Domain has zero references to Infrastructure.
The composition root is the single place where all abstractions are wired to their implementations. In ASP.NET Core, it's Program.cs. It's the only file that references both Domain interfaces and Infrastructure implementations. Every other class only knows about interfaces. This keeps the DIP "contract" clean — business logic is completely unaware of which concrete types are used.
Medium (Applied)
A captive dependency occurs when a longer-lived service captures a shorter-lived one. Example: a Singleton injecting a Scoped DbContext — the DbContext is created once and reused across all requests, causing data leaks and concurrency bugs. Prevent it by: (1) enabling ValidateScopes in development, (2) injecting IServiceScopeFactory in singletons that need scoped services, (3) following the lifetime rule: Singleton → Scoped → Transient (dependencies flow up only).
When the dependency is stable (List<T>, String, Math), a pure function with no side effects, or within the same layer with no boundary crossing. Also for small scripts, prototypes, or console tools where the overhead isn't justified. The key question: "Will I ever need to swap, mock, or protect against change here?" If the answer is clearly no, skip the interface.
DIP is the mechanism that makes Clean Architecture work. The Dependency Rule says source code dependencies must point inward — from Infrastructure to Application to Domain. DIP enforces this: Domain defines interfaces (IOrderRepository), Infrastructure implements them (SqlOrderRepository). Without DIP, the inner circles would reference outer circles, and the architecture collapses.
IOptions<T> is Singleton — reads config once at startup, never changes. IOptionsSnapshot<T> is Scoped — re-reads per request, great for config that changes via hot reload. IOptionsMonitor<T> is Singleton but supports change notifications — calls OnChange() callback when config file updates. Use IOptions for static config, IOptionsMonitor for long-lived services that need live updates.
Three approaches: (1) .NET 8 Keyed Services — AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe") and resolve with [FromKeyedServices("stripe")]. (2) Inject IEnumerable<IPaymentGateway> to get all implementations and pick by type or convention. (3) Factory pattern — register a IPaymentGatewayFactory that takes a key and returns the right implementation. Keyed services is the modern .NET 8+ approach.
Service Locator hides dependencies — the constructor says "I need everything" (IServiceProvider) instead of "I need these specific interfaces." This makes the class (1) hard to test (can't see what to mock), (2) hard to understand (dependencies scattered across methods), and (3) prone to runtime errors (missing registrations found at call time, not startup). Constructor injection makes dependencies explicit and discoverable.
Circular dependencies mean missing abstractions. Solutions: (1) Mediator/Events — A publishes an event, B handles it (MediatR INotification). (2) Extract shared interface — move the shared contract to a third project. (3) Lazy<T> — defer resolution to break the cycle (band-aid, not preferred). (4) Redesign — often the cycle reveals that one service is doing too much. Solution 1 is preferred because it usually uncovers a missing domain event.
Inject DbContext directly for most cases — it's Scoped and works perfectly in web requests. Use IDbContextFactory<T> when: (1) Blazor Server — circuits don't have per-request scopes. (2) Background services — IHostedService runs outside HTTP scopes. (3) Parallel queries — DbContext isn't thread-safe, so each parallel operation needs its own instance. The factory creates short-lived contexts you control and dispose.
Hard (Architecture / Design)
Each microservice defines its own client interface: IOrderServiceClient lives in the consuming service's project. The implementation (HttpOrderServiceClient) uses IHttpClientFactory and lives in Infrastructure. This means: (1) the consuming service doesn't know if communication is HTTP, gRPC, or message queue, (2) tests mock the client interface, (3) switching from HTTP to gRPC is one new implementation class. The contract (API schema) is shared, but the client abstraction is owned locally.
Register (composition root): configure all services → services.AddScoped<IRepo, SqlRepo>(). Resolve (runtime): container builds the object graph → constructor injection. Release (disposal): container disposes IDisposable services when the scope ends. In ASP.NET Core, a scope is created per request, resolved during controller activation, and released when the response completes. This lifecycle ensures no memory leaks from undisposed services.
Three strategies: (1) ValidateOnBuild — catches missing registrations at startup. (2) ValidateScopes — catches captive dependencies. (3) Integration test — boot the app with WebApplicationFactory and resolve every registered service type. Mark Seemann's approach: enumerate all registered ServiceDescriptor entries and verify each can be resolved. This catches issues CI would miss.
Use Scrutor when you have many services following a convention (all classes in Infrastructure implementing interfaces in Domain). It eliminates manual registration: scan.FromAssemblyOf<SqlOrderRepo>().AddClasses().AsImplementedInterfaces().WithScopedLifetime(). Trade-off: implicit registration is harder to debug — you can't "Find All References" on a registration that doesn't exist. Best practice: use scanning for bulk registrations but keep special cases (decorators, keyed services, factories) explicit.
The built-in container doesn't natively support decorators, but Scrutor does: services.Decorate<IOrderRepository, CachedOrderRepository>(). This wraps the original implementation. The decorator receives the inner service via constructor: CachedOrderRepository(IOrderRepository inner, IMemoryCache cache). It checks cache first, falls back to inner. Without Scrutor, you manually register: services.AddScoped<IOrderRepository>(sp => new CachedOrderRepository(new SqlOrderRepository(sp.GetRequiredService<AppDbContext>()), sp.GetRequiredService<IMemoryCache>())).
DIP makes incremental migration possible. Define an interface (IInventoryService) in the consuming project. Start with a LegacyInventoryAdapter that wraps the old system's API. Gradually build a NewInventoryService. Use a feature flag or DI registration to route traffic: services.AddScoped<IInventoryService>(sp => useNew ? sp.GetRequiredService<NewInventoryService>() : sp.GetRequiredService<LegacyInventoryAdapter>()). Business logic never changes — it only knows IInventoryService.
Constructor injection (preferred): dependencies are required, explicit, and set once. The class can't be created without them. Property injection: dependencies are optional, set after construction. Useful for optional cross-cutting concerns but makes dependencies hidden. Method injection: dependencies passed per-call. Useful when the dependency varies per operation (e.g., CancellationToken). In .NET, constructor injection is the standard — the built-in container only supports it.
Use the Decorator pattern with DI. Wrap interfaces with cross-cutting behavior: LoggingOrderRepository wraps IOrderRepository, adding logging before/after each call. Alternatively, MediatR pipeline behaviors act as middleware for commands/queries: IPipelineBehavior<TRequest, TResponse> for validation, logging, caching, and performance tracking. This keeps business classes clean — they don't know about logging or caching.
ASP.NET Core middleware IS DIP in action. The framework defines the contract (RequestDelegate / IMiddleware), your code provides implementations. The framework calls YOUR middleware — it doesn't know what your middleware does (authentication, CORS, logging). Each middleware depends on the RequestDelegate abstraction (the next middleware in the pipeline), never on the concrete class that follows it.
ValidateOnBuild = true tells the DI container to try resolving every registered service at startup. It catches: (1) missing registrations — forgot to register IEmailSender, (2) circular dependencies — A→B→A, (3) invalid constructor parameters — no matching registration. Without it, these errors only appear when the specific code path is hit at runtime — potentially in production under specific conditions.
Controversial topic. Pro-abstraction: wrap DbContext behind IOrderRepository for testability and potential database swaps. Anti-abstraction: DbContext is already an abstraction (Unit of Work + Repository), and wrapping it loses EF features (change tracking, migrations, LINQ). Pragmatic approach: use DbContext directly in Application layer (it's an abstraction from the EF team), but put it behind an interface if you genuinely might swap databases or need complex test isolation.
Define IOutboxRepository and IMessagePublisher in the Domain layer. The OutboxProcessor (background service) reads pending messages from the outbox and publishes them via IMessagePublisher. Business logic writes to the outbox in the same DB transaction as the domain change. The abstractions let you swap from RabbitMQ to Azure Service Bus without touching business logic — and test the processor with in-memory fakes.
Keyed Services (AddKeyedScoped/AddKeyedSingleton/AddKeyedTransient) let you register multiple implementations with string keys: services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe"). Resolve with [FromKeyedServices("stripe")] IPaymentGateway gateway. Use when you need to select implementations by runtime context (payment provider per tenant, storage backend per region). Before .NET 8, you needed factory methods or IEnumerable hacks.
Domain entities raise events (e.g., OrderPlacedEvent) without knowing who handles them — pure DIP. The IDomainEventDispatcher interface lives in Domain. The implementation (using MediatR or manual dispatch) lives in Infrastructure and resolves handlers from the DI container. Handlers (INotificationHandler<OrderPlacedEvent>) implement an interface owned by the framework. Domain code is completely decoupled from side effects — it just says "this happened."
The built-in container handles 90% of use cases. Switch to Autofac when you need: (1) native decorator support (without Scrutor), (2) property injection, (3) child scopes with named lifetimes, (4) module-based registration for large solutions, (5) open generic decorators. Most teams don't need these. The built-in container + Scrutor covers decorators and scanning. Only reach for Autofac when you hit a concrete limitation, not as a default choice.
Exercises
This NotificationService directly creates an SmtpClient and calls Twilio's API. Refactor it to use DIP — extract interfaces, move side effects to infrastructure implementations, and wire them up with constructor injection.
// Refactor this to follow DIP
public class NotificationService
{
public void Notify(string email, string phone, string message)
{
using var smtp = new SmtpClient("smtp.company.com");
smtp.Send("noreply@company.com", email, "Alert", message);
var twilio = new TwilioRestClient("ACCT_SID", "AUTH_TOKEN");
twilio.SendSms("+1234567890", phone, message);
File.AppendAllText("C:\\logs\\notifications.log",
$"{DateTime.Now}: Sent to {email}, {phone}");
}
}
- Identify three separate side effects — each becomes an interface
- IEmailSender, ISmsSender, ILogger (or use ILogger<T> from Microsoft.Extensions.Logging)
- All three get injected via the constructor
- The implementations (SmtpEmailSender, TwilioSmsSender) live in an Infrastructure project
// Domain/Interfaces
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public interface ISmsSender
{
Task SendAsync(string to, string message);
}
// Application/Services
public class NotificationService
{
private readonly IEmailSender _email;
private readonly ISmsSender _sms;
private readonly ILogger<NotificationService> _logger;
public NotificationService(IEmailSender email, ISmsSender sms, ILogger<NotificationService> logger)
{
_email = email;
_sms = sms;
_logger = logger;
}
public async Task NotifyAsync(string email, string phone, string message)
{
await _email.SendAsync(email, "Alert", message);
await _sms.SendAsync(phone, message);
_logger.LogInformation("Sent notification to {Email}, {Phone}", email, phone);
}
}
// Infrastructure/Email
public class SmtpEmailSender(IOptions<SmtpOptions> options) : IEmailSender
{
public async Task SendAsync(string to, string subject, string body)
{
using var smtp = new SmtpClient(options.Value.Host);
await smtp.SendMailAsync("noreply@company.com", to, subject, body);
}
}
// Program.cs
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<ISmsSender, TwilioSmsSender>();
This code has a captive dependency. Find the bug, explain why it's dangerous, and fix it.
// Program.cs
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddScoped<ICurrentUser, HttpContextCurrentUser>();
// RedisCacheService.cs
public class RedisCacheService : ICacheService
{
private readonly ICurrentUser _user;
public RedisCacheService(ICurrentUser user) => _user = user;
public async Task<T?> GetAsync<T>(string key)
{
var fullKey = $"user:{_user.Id}:{key}";
return await _redis.GetAsync<T>(fullKey);
}
}
- Singleton lives forever. Scoped lives per request. What happens when Singleton captures Scoped?
- ICurrentUser resolves the user from HttpContext — but which request's HttpContext?
- Consider IServiceScopeFactory or IHttpContextAccessor
// Fix: Use IHttpContextAccessor (Singleton-safe, reads current request)
public class RedisCacheService : ICacheService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public RedisCacheService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public async Task<T?> GetAsync<T>(string key)
{
var userId = _httpContextAccessor.HttpContext?.User.FindFirst("sub")?.Value ?? "anon";
var fullKey = $"user:{userId}:{key}";
return await _redis.GetAsync<T>(fullKey);
}
}
// IHttpContextAccessor is Singleton-safe because it uses AsyncLocal<T>
// It always reads the CURRENT request's HttpContext, not a captured one
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
You're building a report generator that: (1) fetches data from a database, (2) generates a PDF, (3) uploads it to blob storage, (4) sends an email with the link. Design the interfaces (not implementations) following DIP. Decide which project each interface lives in.
// All interfaces live in Domain or Application (consumer owns them)
namespace MyApp.Application.Contracts;
public interface IReportDataSource
{
Task<ReportData> GetReportDataAsync(ReportRequest request);
}
public interface IPdfGenerator
{
Task<byte[]> GenerateAsync(ReportData data, ReportTemplate template);
}
public interface IBlobStorage
{
Task<string> UploadAsync(string fileName, byte[] content, string contentType);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
// The orchestrator — lives in Application, depends only on interfaces
public class ReportGenerator(
IReportDataSource dataSource,
IPdfGenerator pdf,
IBlobStorage storage,
IEmailSender email)
{
public async Task<string> GenerateAndSendAsync(ReportRequest request)
{
var data = await dataSource.GetReportDataAsync(request);
var pdfBytes = await pdf.GenerateAsync(data, request.Template);
var url = await storage.UploadAsync($"report-{request.Id}.pdf", pdfBytes, "application/pdf");
await email.SendAsync(request.RecipientEmail, "Your Report", $"Download: {url}");
return url;
}
}
You have a working IProductRepository and SqlProductRepository. Add an in-memory caching layer using the Decorator pattern and DI, without modifying SqlProductRepository or any business logic class. Products should be cached for 5 minutes.
- Create
CachedProductRepositorythat implementsIProductRepository - Inject both
IProductRepository(inner) andIMemoryCache - Use Scrutor's
Decorate<IProductRepository, CachedProductRepository>() - Or manually register with a factory lambda
public class CachedProductRepository : IProductRepository
{
private readonly IProductRepository _inner;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public CachedProductRepository(IProductRepository inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetByIdAsync(int id)
{
var key = $"product:{id}";
return await _cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return await _inner.GetByIdAsync(id);
});
}
public async Task<IReadOnlyList<Product>> GetAllAsync()
{
return await _cache.GetOrCreateAsync("products:all", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return await _inner.GetAllAsync();
}) ?? [];
}
}
// Program.cs — register with Scrutor
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
builder.Services.AddMemoryCache();
Cheat Sheet
1. High-level modules must NOT depend on low-level modules. Both → abstractions. 2. Abstractions must NOT depend on details. Details → abstractions.
DIP = Principle (what) "Depend on abstractions" DI = Technique (how) "Pass deps from outside" IoC = Runtime control flow "Framework calls YOU"
✓ Domain / Application project ✗ Infrastructure project ✗ WebApi / Presentation Consumer OWNS the abstraction. Provider IMPLEMENTS it.
Singleton → Scoped → Transient ✓ Scoped injects Transient ✓ Singleton injects Singleton ✗ Singleton injects Scoped ✗ Singleton injects Transient* *Use IServiceScopeFactory
✓ Crosses layer boundary ✓ Has side effects (I/O) ✓ Needs mocking in tests ✓ Multiple implementations ✗ Stable BCL type (String) ✗ Pure function / POCO ✗ Same layer, changes together
❌ new SmtpClient() in business ❌ IServiceProvider in class ❌ IQueryable in interface ❌ 10+ constructor params ❌ Interface in Infra project ✓ Constructor injection only ✓ Async-first interfaces ✓ Domain types in contracts
Deep Dive
.NET 8 Keyed Services — The Missing Feature
Before .NET 8, registering multiple implementations of the same interface required ugly workarounds — injecting IEnumerable<T> and filtering by type, or building factory methods. Keyed Services solves this natively.
// Register with keys
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, PayPalGateway>("paypal");
builder.Services.AddKeyedScoped<IPaymentGateway, SquareGateway>("square");
// Resolve by key in constructor
public class PaymentProcessor(
[FromKeyedServices("stripe")] IPaymentGateway primary,
[FromKeyedServices("paypal")] IPaymentGateway fallback)
{
public async Task<PaymentResult> ChargeAsync(decimal amount)
{
var result = await primary.ChargeAsync(amount);
if (!result.IsSuccess)
result = await fallback.ChargeAsync(amount); // Automatic fallback
return result;
}
}
// Dynamic resolution by key
public class TenantPaymentRouter(IServiceProvider sp)
{
public IPaymentGateway GetGateway(string tenantProvider)
=> sp.GetRequiredKeyedService<IPaymentGateway>(tenantProvider);
}
Keyed Services work with all lifetimes (Singleton, Scoped, Transient) and are resolved at the same speed as regular registrations.
MediatR Pipeline Behaviors — DIP at the Command Level
MediatR's IPipelineBehavior<TRequest, TResponse> is DIP applied to cross-cutting concerns. Each behavior wraps the next like middlewareA software component that forms a pipeline, where each component can process a request before/after passing it to the next component. ASP.NET Core HTTP middleware, MediatR pipeline behaviors, and Express.js middleware all follow this pattern. Each layer adds cross-cutting concern without modifying the core handler. — logging, validation, caching, and transactions, all without modifying business handlers.
// Validation behavior — runs BEFORE every command handler
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next(); // Call the actual handler
}
}
// Register — applies to ALL commands automatically
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
The pipeline execution order: Logging → Validation → Caching → Transaction → Handler. Each behavior depends only on the IPipelineBehavior abstraction and RequestHandlerDelegate — pure DIP.
IHttpClientFactory — DIP for HTTP Communication
IHttpClientFactoryIntroduced in .NET Core 2.1 to solve HttpClient's DNS and socket exhaustion problems. Instead of new HttpClient() (which holds sockets open), the factory manages HttpMessageHandler lifetimes and pools connections. It also supports typed clients, named clients, and Polly-based resilience policies. is a masterclass in DIP. Your business code depends on a typed client interface — the factory handles connection pooling, DNS rotation, and resilience policies behind the scenes.
// Domain — your interface
public interface IWeatherApi
{
Task<WeatherForecast?> GetForecastAsync(string city);
}
// Infrastructure — typed client implementation
public class WeatherApiClient : IWeatherApi
{
private readonly HttpClient _http;
public WeatherApiClient(HttpClient http) => _http = http;
public async Task<WeatherForecast?> GetForecastAsync(string city)
=> await _http.GetFromJsonAsync<WeatherForecast>($"/forecast/{city}");
}
// Program.cs — configure with resilience
builder.Services.AddHttpClient<IWeatherApi, WeatherApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.weather.com");
client.DefaultRequestHeaders.Add("ApiKey", builder.Configuration["Weather:Key"]);
})
.AddStandardResilienceHandler(); // Retry, circuit breaker, timeout — automatic
Mini-Project
Build an Order Processing Pipeline — evolve from a tightly-coupled monolith to a DIP-compliant, testable architecture in three iterations.
Attempt 1: Everything Hardcoded
public class OrderProcessor
{
public void Process(Order order)
{
// Validate
if (order.Total <= 0) throw new Exception("Invalid order");
// Save to database
using var conn = new SqlConnection("Server=prod;Database=Orders;...");
conn.Open();
using var cmd = new SqlCommand("INSERT INTO Orders ...", conn);
cmd.ExecuteNonQuery();
// Charge payment
var stripe = new Stripe.StripeClient("sk_live_XXXX");
stripe.Charges.Create(new() { Amount = (long)(order.Total * 100) });
// Send confirmation email
using var smtp = new SmtpClient("smtp.company.com");
smtp.Send("noreply@co.com", order.Email, "Confirmed!", "...");
// Log
File.AppendAllText("C:\\logs\\orders.log", $"Processed {order.Id}");
}
}
Hardcoded connection strings, live API keys in code, untestable (needs real DB + Stripe + SMTP), single method doing 5 things (SRP violation), no way to swap payment provider.
Attempt 2: Interfaces But Wrong Ownership
// Interfaces live in Infrastructure (WRONG!)
namespace MyApp.Infrastructure;
public interface IDatabase { void Save(Order order); }
public interface IStripeService { void Charge(decimal amount, string token); }
// ↑ Leaky abstraction — named after the provider!
public class OrderProcessor
{
private readonly IDatabase _db;
private readonly IStripeService _stripe; // Tied to Stripe by NAME
public OrderProcessor(IDatabase db, IStripeService stripe)
{
_db = db;
_stripe = stripe;
}
}
Uses interfaces (good!) but they live in Infrastructure (wrong project), are named after providers (leaky), and the dependency arrow still points from business → infrastructure.
Attempt 3: Proper DIP Architecture
// All interfaces in Domain — named after WHAT, not WHO
namespace MyApp.Domain;
public interface IOrderRepository
{
Task SaveAsync(Order order);
Task<Order?> GetByIdAsync(int id);
}
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string token);
}
public interface INotifier
{
Task SendAsync(string to, string subject, string body);
}
namespace MyApp.Application;
public class OrderProcessor
{
private readonly IOrderRepository _repo;
private readonly IPaymentGateway _payment;
private readonly INotifier _notifier;
private readonly ILogger<OrderProcessor> _logger;
public OrderProcessor(
IOrderRepository repo,
IPaymentGateway payment,
INotifier notifier,
ILogger<OrderProcessor> logger)
{
_repo = repo;
_payment = payment;
_notifier = notifier;
_logger = logger;
}
public async Task<OrderResult> ProcessAsync(Order order)
{
if (order.Total <= 0)
return OrderResult.Fail("Invalid order total");
var payment = await _payment.ChargeAsync(order.Total, order.PaymentToken);
if (!payment.IsSuccess)
return OrderResult.Fail($"Payment failed: {payment.Error}");
order.MarkAsPaid(payment.TransactionId);
await _repo.SaveAsync(order);
await _notifier.SendAsync(order.Email, "Order Confirmed", $"Order #{order.Id}");
_logger.LogInformation("Processed order {OrderId}", order.Id);
return OrderResult.Ok(order.Id);
}
}
namespace MyApp.Infrastructure;
public class SqlOrderRepository(AppDbContext db) : IOrderRepository
{
public async Task SaveAsync(Order order) { db.Orders.Update(order); await db.SaveChangesAsync(); }
public async Task<Order?> GetByIdAsync(int id) => await db.Orders.FindAsync(id);
}
public class StripePaymentGateway(IOptions<StripeOptions> opts) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string token)
{
var client = new StripeClient(opts.Value.SecretKey);
var charge = await client.Charges.CreateAsync(new() { Amount = (long)(amount * 100), Source = token });
return charge.Status == "succeeded"
? PaymentResult.Ok(charge.Id)
: PaymentResult.Fail(charge.FailureMessage);
}
}
public class SmtpNotifier(IOptions<SmtpOptions> opts) : INotifier
{
public async Task SendAsync(string to, string subject, string body)
{
using var smtp = new SmtpClient(opts.Value.Host, opts.Value.Port);
await smtp.SendMailAsync("noreply@company.com", to, subject, body);
}
}
var builder = WebApplication.CreateBuilder(args);
// Configuration
builder.Services.Configure<StripeOptions>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
// DI Registration — the composition root
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddScoped<INotifier, SmtpNotifier>();
builder.Services.AddScoped<OrderProcessor>();
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Validation in development
if (builder.Environment.IsDevelopment())
builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true; o.ValidateOnBuild = true; });
public class OrderProcessorTests
{
[Fact]
public async Task ProcessAsync_InvalidTotal_ReturnsFailure()
{
var sut = CreateProcessor();
var order = new Order { Total = -1 };
var result = await sut.ProcessAsync(order);
Assert.False(result.IsSuccess);
Assert.Contains("Invalid", result.Error);
}
[Fact]
public async Task ProcessAsync_PaymentFails_DoesNotSaveOrder()
{
var repo = new Mock<IOrderRepository>();
var payment = new Mock<IPaymentGateway>();
payment.Setup(p => p.ChargeAsync(It.IsAny<decimal>(), It.IsAny<string>()))
.ReturnsAsync(PaymentResult.Fail("Declined"));
var sut = new OrderProcessor(repo.Object, payment.Object,
Mock.Of<INotifier>(), NullLogger<OrderProcessor>.Instance);
await sut.ProcessAsync(new Order { Total = 50, PaymentToken = "tok" });
repo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Never);
}
private static OrderProcessor CreateProcessor() =>
new(Mock.Of<IOrderRepository>(), Mock.Of<IPaymentGateway>(),
Mock.Of<INotifier>(), NullLogger<OrderProcessor>.Instance);
}
Migration Guide
How to introduce DIP into a legacy codebase that uses new everywhere — incrementally, without rewriting.
Step 1: Identify the Seam
Find a class with a new dependency that has side effects (database, HTTP, file I/O). This is your first target. Don't try to abstract everything at once — pick the most painful dependency (usually the one making tests slow or impossible).
// Before: find the seam (the new keyword with side effects)
public class ReportService
{
public byte[] Generate(int orderId)
{
var db = new SqlConnection("..."); // ← Seam 1: database
var pdf = new PdfDocument(); // ← Not a seam (no side effects)
var storage = new AzureBlobClient("..."); // ← Seam 2: cloud storage
// ...
}
}
Step 2: Extract Interface + Add Constructor Parameter
Extract an interface from the dependency. Add it as a constructor parameter. For backward compatibility during migration, provide a default constructorA temporary migration technique: the original parameterless constructor creates the concrete instance (preserving existing behavior), while a new constructor accepts the interface (enabling DI). Once all callers use DI, remove the default constructor. This is NOT recommended long-term — it's a migration bridge only. that creates the concrete implementation.
// Step 2a: Extract interface
public interface IOrderDataAccess
{
Task<Order?> GetByIdAsync(int id);
}
// Step 2b: Wrap existing code as implementation
public class SqlOrderDataAccess : IOrderDataAccess
{
public async Task<Order?> GetByIdAsync(int id)
{
using var conn = new SqlConnection("...");
// Move existing SQL code here
}
}
// Step 2c: Add constructor injection (keep backward compat temporarily)
public class ReportService
{
private readonly IOrderDataAccess _dataAccess;
// Migration constructor — existing callers still work
public ReportService() : this(new SqlOrderDataAccess()) { }
// DI constructor — new callers and tests use this
public ReportService(IOrderDataAccess dataAccess)
{
_dataAccess = dataAccess;
}
}
Step 3: Register in DI + Remove Default Constructor
Once all callers use the DI container, register the service and remove the parameterless constructor. The migration is complete for this dependency. Repeat for the next seam.
// Program.cs — register
builder.Services.AddScoped<IOrderDataAccess, SqlOrderDataAccess>();
builder.Services.AddScoped<ReportService>();
// ReportService — remove the migration constructor
public class ReportService
{
private readonly IOrderDataAccess _dataAccess;
public ReportService(IOrderDataAccess dataAccess)
{
_dataAccess = dataAccess;
}
// Parameterless constructor is GONE — DIP is enforced
}
// Now you can test ReportService with a mock
[Fact]
public async Task Generate_ReturnsReport()
{
var mockData = new Mock<IOrderDataAccess>();
mockData.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(testOrder);
var service = new ReportService(mockData.Object);
var result = await service.GenerateAsync(1);
Assert.NotNull(result);
}
Step 4: Reorganize Projects (Optional)
Once interfaces are extracted, move them to the Domain/Application project and implementations to Infrastructure. This enforces DIP at the project reference level — if Infrastructure references Domain but Domain doesn't reference Infrastructure, developers physically can't violate DIP.
// Final project structure
MyApp.Domain/ → Interfaces + Entities (ZERO external dependencies)
MyApp.Application/ → Business logic (references: Domain only)
MyApp.Infrastructure/ → Implementations (references: Domain + NuGet packages)
MyApp.WebApi/ → Entry point (references: Application + Infrastructure)
// Project references enforce DIP:
// Domain ← Application ← WebApi → Infrastructure → Domain
// Infrastructure CAN'T be referenced by Domain (compilation error)
Code Review Checklist
| # | Check | How to Verify |
|---|---|---|
| 1 | No new keyword for I/O dependencies in business logic | Search for new SmtpClient, new SqlConnection, new HttpClient |
| 2 | Interfaces live in Domain/Application, not Infrastructure | Check project containing I*.cs interface files |
| 3 | No IServiceProvider injection (Service Locator) | Search constructors for IServiceProvider — only allowed in factories |
| 4 | Constructor has ≤ 5 parameters | Count constructor params — if >5, consider Facade or splitting the class |
| 5 | Interfaces don't leak infrastructure types | Check return types for IQueryable, SqlDataReader, HttpResponseMessage |
| 6 | Lifetimes are correct (no captive dependencies) | Verify Singleton doesn't inject Scoped. Enable ValidateScopes |
| 7 | All registrations exist in composition root | Enable ValidateOnBuild or run DI resolution test |
| 8 | Configuration uses IOptions<T>, not raw IConfiguration | Search for IConfiguration outside Program.cs |
| 9 | Interfaces are async-first | Check for sync methods that implementations force to block with .Result |
| 10 | No header interfaces (1:1 class-to-interface for pure logic) | Check if any interface has exactly one implementation with no test benefit |
dotnet build --warnaserror after enabling Roslyn analyzersCode analysis tools built into the .NET compiler. Enable with <EnableNETAnalyzers>true</EnableNETAnalyzers> in your .csproj. Rules like CA1062 (validate parameters) and CA2000 (dispose objects) catch DIP-adjacent issues. Third-party analyzers like Microsoft.VisualStudio.Threading can catch async anti-patterns. to catch many DIP violations at compile time.