Have you ever wondered how modern .NET applications manage their components and services so efficiently? That’s where Dependency Injection (DI) comes in! Think of DI as a way to make your code more like LEGO blocks - easily connectable, replaceable, and maintainable. In this guide, we’ll break down this important concept into easy-to-understand pieces.
What You’ll Learn
- What Dependency Injection is and why it matters
- How to use DI in .NET Core applications
- Best practices for implementing DI
- Common pitfalls and how to avoid them
Prerequisites
- Basic understanding of C#
- .NET Core SDK installed
- Visual Studio or VS Code
- Basic understanding of classes and interfaces
What is Dependency Injection?
The Problem It Solves
Let’s start with a common scenario WITHOUT dependency injection:
public class EmailService
{
private SmtpClient _smtpClient;
public EmailService()
{
// Hard-coded dependency
_smtpClient = new SmtpClient("smtp.myserver.com");
}
public void SendEmail(string to, string subject, string body)
{
// Send email logic
}
}
public class UserService
{
private EmailService _emailService;
public UserService()
{
// Creating dependency directly
_emailService = new EmailService();
}
public void RegisterUser(string email)
{
// Register user logic
_emailService.SendEmail(email, "Welcome!", "Welcome to our service!");
}
}
Problems with this approach:
- Hard to test (can’t easily replace EmailService with a mock)
- Tightly coupled code
- Hard to change implementations
- Difficult to manage dependencies
The Solution: Dependency Injection
Here’s the same code WITH dependency injection:
public interface IEmailService
{
void SendEmail(string to, string subject, string body);
}
public class EmailService : IEmailService
{
private readonly ISmtpClient _smtpClient;
// Dependencies are injected through constructor
public EmailService(ISmtpClient smtpClient)
{
_smtpClient = smtpClient;
}
public void SendEmail(string to, string subject, string body)
{
// Send email logic
}
}
public class UserService
{
private readonly IEmailService _emailService;
// Dependency is injected
public UserService(IEmailService emailService)
{
_emailService = emailService;
}
public void RegisterUser(string email)
{
// Register user logic
_emailService.SendEmail(email, "Welcome!", "Welcome to our service!");
}
}
Understanding the Built-in DI Container in .NET Core
Registering Services
In your Program.cs
or Startup.cs
:
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddTransient<IEmailService, EmailService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<IConfiguration, Configuration>();
Service Lifetimes Explained
- Transient (AddTransient)
- Created every time they’re requested
- Best for lightweight, stateless services
builder.Services.AddTransient<IMyService, MyService>();
- Scoped (AddScoped)
- Created once per client request (in web applications)
- Perfect for services that should be shared within a request
builder.Services.AddScoped<IOrderService, OrderService>();
- Singleton (AddSingleton)
- Created only once and reused for all requests
- Use for services that should be shared across the application
builder.Services.AddSingleton<ICacheService, CacheService>();
Real-World Example: Building a Book Library System
// Interfaces
public interface IBookRepository
{
List<Book> GetAllBooks();
Book GetBookById(int id);
void AddBook(Book book);
}
public interface ILibraryService
{
List<Book> GetAvailableBooks();
bool BorrowBook(int bookId, string userId);
}
// Implementations
public class BookRepository : IBookRepository
{
private readonly ILogger<BookRepository> _logger;
private readonly DatabaseContext _context;
public BookRepository(
ILogger<BookRepository> logger,
DatabaseContext context)
{
_logger = logger;
_context = context;
}
public List<Book> GetAllBooks()
{
_logger.LogInformation("Fetching all books");
return _context.Books.ToList();
}
// Other implementation methods...
}
public class LibraryService : ILibraryService
{
private readonly IBookRepository _bookRepository;
private readonly IUserService _userService;
public LibraryService(
IBookRepository bookRepository,
IUserService userService)
{
_bookRepository = bookRepository;
_userService = userService;
}
public List<Book> GetAvailableBooks()
{
return _bookRepository
.GetAllBooks()
.Where(b => b.IsAvailable)
.ToList();
}
// Other implementation methods...
}
// Registration in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<ILibraryService, LibraryService>();
builder.Services.AddScoped<IUserService, UserService>();
Common Patterns with DI
Constructor Injection (Recommended Pattern)
This is the primary and recommended way to implement dependency injection in .NET Core:
public class OrderService
{
private readonly IRepository _repository;
private readonly ILogger<OrderService> _logger;
public OrderService(IRepository repository, ILogger<OrderService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
}
Method Injection (Special Cases)
Method injection in .NET Core is primarily used in framework-specific scenarios, particularly in ASP.NET Core:
- Action Injection in Controllers:
public class WeatherController : ControllerBase { // Regular constructor injection private readonly ILogger<WeatherController> _logger; public WeatherController(ILogger<WeatherController> logger) { _logger = logger; } // Method injection in action method public IActionResult Get([FromServices] IWeatherService weatherService) { var forecast = weatherService.GetForecast(); return Ok(forecast); } }
- Minimal API Endpoints:
app.MapGet("/weather", ( [FromServices] IWeatherService weatherService, [FromServices] ILogger<Program> logger) => { logger.LogInformation("Fetching weather"); return weatherService.GetForecast(); });
- Razor Pages:
public class IndexModel : PageModel { public async Task OnGet([FromServices] IWeatherService weatherService) { // Use weatherService } }
❌ Don’t use Method Injection for:
// This won't work with .NET's DI container
public class OrderProcessor
{
public void ProcessOrder(Order order, IPaymentService paymentService)
{
paymentService.ProcessPayment(order); // paymentService won't be injected
}
}
✅ Instead, use Constructor Injection:
public class OrderProcessor
{
private readonly IPaymentService _paymentService;
public OrderProcessor(IPaymentService paymentService)
{
_paymentService = paymentService;
}
public void ProcessOrder(Order order)
{
_paymentService.ProcessPayment(order);
}
}
Best Practices
1. Constructor Injection
✅ Good:
public class UserService
{
private readonly IRepository _repository;
public UserService(IRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
}
2. Interface Segregation
✅ Good:
public interface IUserReader
{
User GetById(int id);
}
public interface IUserWriter
{
void Save(User user);
}
public class UserService : IUserReader, IUserWriter
{
// Implementation
}
3. Avoid Service Locator
❌ Bad:
public class UserService
{
public void DoSomething()
{
var service = ServiceLocator.Current.GetInstance<IEmailService>();
service.SendEmail();
}
}
✅ Good:
public class UserService
{
private readonly IEmailService _emailService;
public UserService(IEmailService emailService)
{
_emailService = emailService;
}
public void DoSomething()
{
_emailService.SendEmail();
}
}
Common Pitfalls and Solutions
1. Circular Dependencies
❌ Problem:
public class ServiceA
{
public ServiceA(ServiceB b) { }
}
public class ServiceB
{
public ServiceB(ServiceA a) { } // Circular dependency!
}
✅ Solution:
public class ServiceA
{
public ServiceA(IServiceB b) { }
}
public class ServiceB
{
public ServiceB(IServiceC c) { }
}
2. Service Locator Anti-pattern
❌ Problem:
public class MyService
{
public void DoWork()
{
var dependency = Container.Resolve<IDependency>();
}
}
✅ Solution:
public class MyService
{
private readonly IDependency _dependency;
public MyService(IDependency dependency)
{
_dependency = dependency;
}
}
Practical Exercise: Building a Weather Notification System
public interface IWeatherService
{
Task<WeatherInfo> GetWeatherAsync(string city);
}
public interface INotificationService
{
Task SendNotificationAsync(string user, string message);
}
public class WeatherNotifier
{
private readonly IWeatherService _weatherService;
private readonly INotificationService _notificationService;
private readonly ILogger<WeatherNotifier> _logger;
public WeatherNotifier(
IWeatherService weatherService,
INotificationService notificationService,
ILogger<WeatherNotifier> logger)
{
_weatherService = weatherService;
_notificationService = notificationService;
_logger = logger;
}
public async Task NotifyUserAboutWeatherAsync(string user, string city)
{
try
{
var weather = await _weatherService.GetWeatherAsync(city);
var message = $"Current weather in {city}: {weather.Temperature}°C, {weather.Condition}";
await _notificationService.SendNotificationAsync(user, message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify user about weather");
throw;
}
}
}
// Registration
builder.Services.AddScoped<IWeatherService, WeatherService>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<WeatherNotifier>();
Testing with Dependency Injection
public class WeatherNotifierTests
{
[Fact]
public async Task NotifyUserAboutWeather_Success()
{
// Arrange
var mockWeatherService = new Mock<IWeatherService>();
var mockNotificationService = new Mock<INotificationService>();
var mockLogger = new Mock<ILogger<WeatherNotifier>>();
mockWeatherService
.Setup(s => s.GetWeatherAsync(It.IsAny<string>()))
.ReturnsAsync(new WeatherInfo { Temperature = 20, Condition = "Sunny" });
var notifier = new WeatherNotifier(
mockWeatherService.Object,
mockNotificationService.Object,
mockLogger.Object);
// Act
await notifier.NotifyUserAboutWeatherAsync("user1", "London");
// Assert
mockNotificationService.Verify(
s => s.SendNotificationAsync(
"user1",
It.Is<string>(msg => msg.Contains("20°C") && msg.Contains("Sunny"))
),
Times.Once
);
}
}
Practice Exercises
Exercise 1: Basic DI Implementation
Create a simple console application that:
- Implements a logging system with DI
- Has different logging implementations (Console, File)
- Uses dependency injection to switch between implementations
Exercise 2: Building a Shopping Cart
Create a shopping cart system with:
- Product service
- Cart service
- Order service
- Payment service All properly implemented with DI
Exercise 3: Advanced Scenarios
Implement a system that uses:
- Multiple interface implementations
- Scoped and Singleton services
- Factory pattern with DI
Next Steps
After mastering these basics, explore:
- Advanced DI patterns
- DI with async/await
- DI in specific frameworks (ASP.NET Core MVC, Blazor)
- Third-party DI containers
Additional Resources
Remember: The key to understanding DI is practice. Start with simple implementations and gradually move to more complex scenarios. Don’t be afraid to experiment with different patterns and approaches!