Yesterday you learned how to write unit tests — making sure your code actually works without manually clicking buttons like a QA intern on their first day. But here's a problem you might've bumped into: what happens when the class you're testing creates its own database connection, or worse, sends real emails every time you run a test? Today we're solving that exact headache with Dependency Injection — the art of teaching your classes to ask for what they need instead of building everything from scratch.
1. The Problem — Tightly Coupled Code
Imagine you hire a chef for your restaurant. On their first day, instead of using the kitchen you built, they drive to a farm, raise chickens, harvest wheat, build an oven from bricks they fired themselves, and then make you a sandwich. That's what tightly coupled code looks like.
public class OrderProcessor
{
private readonly EmailService _emailService;
private readonly SqlDatabase _database;
public OrderProcessor()
{
_emailService = new EmailService("smtp.company.com", 587);
_database = new SqlDatabase("Server=prod;Database=orders;");
}
public void ProcessOrder(Order order)
{
_database.Save(order);
_emailService.Send(order.CustomerEmail, "Order confirmed!");
}
}
See the problem? OrderProcessor is building its own EmailService and SqlDatabase right there in the constructor. It knows the SMTP server address. It knows the connection string. It's doing way too much.
This creates three headaches:
- Testing is a nightmare — you can't test
ProcessOrderwithout a real SMTP server and a real database. Your "unit" test just became an integration test with infrastructure baggage. - Swapping is impossible — want to switch from email to SMS notifications? You're rewriting
OrderProcessor, not just plugging in a different service. - Reuse is dead — this class only works with those exact dependencies, in that exact configuration.
2. The Fix — Pass Dependencies Through the Constructor
The solution is embarrassingly simple: stop creating dependencies inside the class and start receiving them through the constructor.
public class OrderProcessor
{
private readonly EmailService _emailService;
private readonly SqlDatabase _database;
public OrderProcessor(EmailService emailService, SqlDatabase database)
{
_emailService = emailService;
_database = database;
}
public void ProcessOrder(Order order)
{
_database.Save(order);
_emailService.Send(order.CustomerEmail, "Order confirmed!");
}
}
Now OrderProcessor doesn't care how the email service or database were created. It just says: "Give me an email service and a database, and I'll process orders." The caller decides which implementations to provide.
This is Dependency Injection in its simplest form — and yes, that's really all it is. You're injecting the dependencies from the outside instead of creating them inside. The fancy name makes it sound like a design pattern invented by a committee of architects in turtlenecks, but it's really just "pass stuff through the constructor."
3. Interfaces as Contracts — Depend on Abstractions
We've improved things, but there's still a catch. OrderProcessor depends on concrete classes — EmailService and SqlDatabase. If you want to swap EmailService for SmsService, you still have to change OrderProcessor's field types.
The fix? Interfaces. Remember those from Day 12? They're about to earn their paycheck.
public interface INotificationService
{
void Send(string recipient, string message);
}
public interface IOrderRepository
{
void Save(Order order);
}
Now you code against the interface, not the implementation:
public class OrderProcessor
{
private readonly INotificationService _notifier;
private readonly IOrderRepository _repository;
public OrderProcessor(INotificationService notifier, IOrderRepository repository)
{
_notifier = notifier;
_repository = repository;
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
_notifier.Send(order.CustomerEmail, "Order confirmed!");
}
}
OrderProcessor no longer knows — or cares — whether notifications go by email, SMS, carrier pigeon, or a guy yelling out a window. It depends on an abstraction, and the concrete implementation gets decided elsewhere. This is the Dependency Inversion Principle (the "D" in SOLID), and it's the philosophical backbone of DI.
4. The DI Container — Your Automatic Wiring System
"Okay," you're thinking, "but now somebody has to create all these objects and pass them around. Am I doing this by hand everywhere?"
Nope. That's what a DI container does. In .NET, the built-in one lives in Microsoft.Extensions.DependencyInjection, and it comes free with every ASP.NET Core, console (with Generic Host), or Worker Service project.
The two main players:
| Type | Role |
|---|---|
IServiceCollection |
The registration list — you tell the container "when someone asks for INotificationService, give them an EmailService" |
IServiceProvider |
The resolver — the thing that actually creates and hands out objects at runtime |
You register services at startup, and the container handles the rest — creating instances, injecting them into constructors, and managing their lifetimes. Think of it like a restaurant supply company: you tell them what ingredients you need, and they show up every morning with exactly the right stuff. You never visit the farm yourself.
5. Service Lifetimes — Transient, Scoped, and Singleton
When you register a service, you also tell the container how long each instance should live. Get this wrong and you'll either waste memory or share state in terrifying ways.
| Lifetime | What It Means | Use When |
|---|---|---|
| Transient | A brand-new instance every single time it's requested | Lightweight, stateless services — like a formatter or validator |
| Scoped | One instance per "scope" (per HTTP request in web apps) | Database contexts, unit-of-work patterns — things that should live for one request but not leak into the next |
| Singleton | One instance for the entire application lifetime | Caches, configuration objects, or anything expensive to create that's thread-safe |
The golden rule: a service should never depend on something with a shorter lifetime than itself. A Singleton that grabs a Scoped service is a ticking time bomb — the Singleton will hold onto a disposed Scoped instance and eventually blow up at runtime. The .NET container will actually throw an InvalidOperationException if you enable scope validation (which is on by default in Development mode). It's the framework saving you from yourself.
6. Registering and Resolving Services
Here's how registration looks in a typical Program.cs:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
// Register services with the container
builder.Services.AddTransient<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<ICache, InMemoryCache>();
var app = builder.Build();
// Resolve a service manually (rare — usually the container does this for you)
var processor = app.Services.GetRequiredService<OrderProcessor>();
processor.ProcessOrder(new Order { CustomerEmail = "test@example.com" });
Each Add___ method maps an interface to a concrete class:
AddTransient<INotificationService, EmailNotificationService>()— "every time someone needs anINotificationService, create a freshEmailNotificationService"AddScoped<IOrderRepository, SqlOrderRepository>()— "oneSqlOrderRepositoryper scope"AddSingleton<ICache, InMemoryCache>()— "oneInMemoryCache, forever, for everyone"
You almost never call GetRequiredService yourself in real apps. In ASP.NET Core, the framework resolves dependencies automatically when it creates your controllers or minimal API endpoint handlers. You just declare what you need in the constructor, and it shows up like room service — you don't have to walk down to the kitchen.
7. Constructor Injection in Action — A Practical Example
Let's build something real. A notification system that can send via email or SMS — swappable with zero code changes to the consuming class.
// The contract
public interface INotificationService
{
void Send(string recipient, string message);
}
// Implementation 1: Email
public class EmailNotificationService : INotificationService
{
public void Send(string recipient, string message)
{
Console.WriteLine($"[EMAIL] To: {recipient} | {message}");
}
}
// Implementation 2: SMS
public class SmsNotificationService : INotificationService
{
public void Send(string recipient, string message)
{
Console.WriteLine($"[SMS] To: {recipient} | {message}");
}
}
// The consumer — doesn't know or care which implementation it gets
public class OrderProcessor
{
private readonly INotificationService _notifier;
public OrderProcessor(INotificationService notifier)
{
_notifier = notifier;
}
public void ProcessOrder(string customerContact)
{
Console.WriteLine("Processing order...");
_notifier.Send(customerContact, "Your order has been confirmed!");
}
}
Now in Program.cs, switching from email to SMS is a one-line change:
var builder = Host.CreateApplicationBuilder(args);
// Swap this ONE line to change notification behavior everywhere
builder.Services.AddTransient<INotificationService, EmailNotificationService>();
// builder.Services.AddTransient<INotificationService, SmsNotificationService>();
builder.Services.AddTransient<OrderProcessor>();
var app = builder.Build();
var processor = app.Services.GetRequiredService<OrderProcessor>();
processor.ProcessOrder("customer@example.com");
Run it, and you'll see:
Processing order...
[EMAIL] To: customer@example.com | Your order has been confirmed!
Uncomment the SMS line, comment out the email line, run again:
Processing order...
[SMS] To: customer@example.com | Your order has been confirmed!
OrderProcessor didn't change at all. Not one character. That's the payoff of depending on abstractions — you rewire behavior at the composition root, not scattered across your codebase.
8. Why DI Makes Testing Easy
Remember yesterday's unit testing lesson? The whole point of a unit test is to test one thing in isolation. But if your class creates a real database connection in its constructor, you're not testing in isolation — you're testing your class and the database and the network and whatever mood SQL Server is in today.
With DI, you just create a fake (sometimes called a stub or mock):
// A fake notification service for testing
public class FakeNotificationService : INotificationService
{
public List<string> SentMessages { get; } = [];
public void Send(string recipient, string message)
{
SentMessages.Add($"{recipient}: {message}");
}
}
Now your test is clean, fast, and completely isolated:
[Fact]
public void ProcessOrder_SendsNotification()
{
// Arrange
var fakeNotifier = new FakeNotificationService();
var processor = new OrderProcessor(fakeNotifier);
// Act
processor.ProcessOrder("test@test.com");
// Assert
Assert.Single(fakeNotifier.SentMessages);
Assert.Contains("test@test.com", fakeNotifier.SentMessages[0]);
}
No SMTP server. No database. No network. Just pure, fast, reliable logic testing. The test runs in milliseconds, and it'll pass whether you're on a plane, in a coffee shop, or in a bunker with no internet.
This is why DI and unit testing are best friends. You can technically test without DI, but it's like trying to change a tire while the car is still moving — technically possible, definitely not recommended.
9. Your Homework: Refactor a Tightly-Coupled ReportGenerator
Here's a tightly coupled ReportGenerator that creates its own dependencies. Your mission: refactor it to use DI with interfaces.
Before (the mess):
public class ReportGenerator
{
private readonly SqlDatabase _database = new("Server=localhost;Database=reports;");
private readonly PdfExporter _exporter = new();
private readonly EmailService _emailer = new("smtp.company.com");
public void GenerateAndSend(string reportName, string recipientEmail)
{
var data = _database.Query($"SELECT * FROM {reportName}");
var pdf = _exporter.Export(data);
_emailer.Send(recipientEmail, "Your report", pdf);
Console.WriteLine("Report sent!");
}
}
Your tasks:
- Create three interfaces:
IDataSource,IReportExporter, andIEmailSender - Refactor
ReportGeneratorto accept all three via constructor injection - Register everything in a
Program.csusingHost.CreateApplicationBuilder - Write a
FakeEmailSenderand a unit test that verifiesGenerateAndSendcalls the email sender with the correct recipient - Bonus: register two different
IDataSourceimplementations (one for SQL, one for in-memory test data) and swap them by changing a single registration line
Summary of Day 29
- Tightly coupled code creates its own dependencies inside the constructor — making it hard to test, hard to swap, and hard to reuse.
- Dependency Injection means passing dependencies through the constructor instead of creating them internally. It's just "give me what I need" instead of "I'll build everything myself."
- Interfaces let you depend on abstractions rather than concrete classes, so you can swap implementations without touching the consuming code.
- The DI container (
IServiceCollection+IServiceProvider) inMicrosoft.Extensions.DependencyInjectionhandles object creation and lifetime management automatically. - Transient = new instance every time. Scoped = one per request/scope. Singleton = one for the whole app. Don't let a longer-lived service depend on a shorter-lived one.
- Constructor injection is the standard pattern in .NET — declare what you need in the constructor, and the container delivers it.
- DI makes unit testing straightforward — swap real services with fakes and test your logic in complete isolation.
Tomorrow: it's Day 30 — the grand finale. We'll look back at everything you've learned and map out where to go next. Thirty days ago you didn't know what a semicolon was for — now you're injecting dependencies like a seasoned architect. 🚀
See you on Day 30!