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!