Skip to main content

Dependency Injection in C#: A Complete Tutorial (2026)

Learn dependency injection in C# from scratch. Build loosely coupled, testable .NET code with runnable examples, service lifetimes and best practices. Start now!

If you've opened an ASP.NET Core project, you've already used dependency injection in C#, even if you didn't know it. Controllers get services "for free," ILogger<T> just shows up, and Program.cs is full of AddScoped and AddSingleton calls. Many developers copy these lines without knowing why they work. In this tutorial we build dependency injection from scratch. We start with tightly coupled code, fix it by hand, write a small IoC container of our own, and then move to the built-in Microsoft.Extensions.DependencyInjection container. By the end you'll know how DI works, when to use each service lifetime, and how it makes your code much easier to unit test.

What Is Dependency Injection in C#?

Dependency injection (DI) is a design pattern. A class receives the objects it depends on from the outside instead of creating them itself. Those objects are its dependencies. It's the most common way to apply the Dependency Inversion Principle, the "D" in SOLID: high-level code should depend on abstractions such as interfaces, not on concrete implementations.

Here's the main point: when a class uses new to create a dependency, it also decides which implementation to use. You can't change that choice later without editing the class. DI moves that decision somewhere else, usually to your application's startup code, which is called the composition root.

The Problem: Tightly Coupled Code

Look at this typical order service:

public record Order(int Id, string CustomerEmail, decimal Total);

public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository();
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.myshop.com");

    public void PlaceOrder(Order order)
    {
        if (order.Total <= 0)
            throw new ArgumentException("Order total must be positive.");

        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed", $"Order #{order.Id} received.");
    }
}

This code compiles and runs, but it causes several problems:

  • You can't unit test it. Every test of PlaceOrder hits a real database and sends a real email.
  • It's rigid. Switching from SMTP to SendGrid means editing OrderService, even though the business logic hasn't changed.
  • Its dependencies are hidden. The constructor doesn't tell you the class needs a database and a mail server. You only find out when it fails at runtime.
  • Configuration is hard-coded. The SMTP host is buried inside a business class.

Step 1: Constructor Injection in C# (Pure DI)

The fix needs no framework. First, put interfaces in front of the dependencies:

public interface IOrderRepository
{
    void Save(Order order);
}

public interface IEmailSender
{
    void Send(string to, string subject, string body);
}

Then have OrderService take those interfaces through its constructor. This is constructor injection, the most common and recommended form of DI in C#:

public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        _emailSender = emailSender ?? throw new ArgumentNullException(nameof(emailSender));
    }

    public void PlaceOrder(Order order)
    {
        if (order.Total <= 0)
            throw new ArgumentException("Order total must be positive.");

        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed", $"Order #{order.Id} received.");
    }
}

Now add some simple implementations and wire everything up by hand in Program.cs:

public class InMemoryOrderRepository : IOrderRepository
{
    private readonly List<Order> _orders = new();
    public void Save(Order order) => _orders.Add(order);
}

public class ConsoleEmailSender : IEmailSender
{
    public void Send(string to, string subject, string body) =>
        Console.WriteLine($"To: {to} | {subject} | {body}");
}

// Program.cs - the composition root
var service = new OrderService(new InMemoryOrderRepository(), new ConsoleEmailSender());
service.PlaceOrder(new Order(1, "jane@example.com", 49.99m));

This is called Pure DI, and it's real dependency injection. OrderService no longer knows or cares which implementations it gets. Its constructor now lists everything it needs. For small apps, manual wiring like this is perfectly fine.

Step 2: Build a Simple IoC Container from Scratch

As an app grows, wiring every object by hand gets tedious. An IoC (Inversion of Control) container does that wiring for you. It reads constructor parameters with reflection and builds the whole object graph. Writing a small one yourself is the quickest way to see there's nothing magic going on:

using System.Reflection;

public sealed class SimpleContainer
{
    private readonly Dictionary<Type, Func<object>> _registrations = new();
    private readonly Dictionary<Type, object> _singletons = new();

    public void RegisterTransient<TService, TImpl>() where TImpl : TService =>
        _registrations[typeof(TService)] = () => Create(typeof(TImpl));

    public void RegisterSingleton<TService, TImpl>() where TImpl : TService =>
        _registrations[typeof(TService)] = () =>
        {
            if (!_singletons.TryGetValue(typeof(TService), out var instance))
            {
                instance = Create(typeof(TImpl));
                _singletons[typeof(TService)] = instance;
            }
            return instance;
        };

    public T Resolve<T>() => (T)Resolve(typeof(T));

    private object Resolve(Type type)
    {
        if (_registrations.TryGetValue(type, out var factory))
            return factory();

        // Allow resolving concrete classes that were never registered
        if (!type.IsAbstract && !type.IsInterface)
            return Create(type);

        throw new InvalidOperationException($"No registration found for {type.Name}.");
    }

    private object Create(Type type)
    {
        // Pick the constructor with the most parameters (a common convention)
        ConstructorInfo ctor = type.GetConstructors()
            .OrderByDescending(c => c.GetParameters().Length)
            .First();

        object[] args = ctor.GetParameters()
            .Select(p => Resolve(p.ParameterType))
            .ToArray();

        return ctor.Invoke(args);
    }
}

Here's how to use it:

var container = new SimpleContainer();
container.RegisterSingleton<IOrderRepository, InMemoryOrderRepository>();
container.RegisterTransient<IEmailSender, ConsoleEmailSender>();

var orderService = container.Resolve<OrderService>();
orderService.PlaceOrder(new Order(2, "sam@example.com", 120m));

In about 50 lines you get automatic recursive resolution and two lifetimes. What's missing tells you what a production container has to handle: thread safety, circular dependency detection (this version would overflow the stack), scoped lifetimes, disposal, and performance. Don't ship your own container. Use the one built into .NET.

Step 3: Dependency Injection in .NET Core with Microsoft.Extensions.DependencyInjection

The built-in container comes with ASP.NET Core, Worker Services, .NET MAUI and Blazor. In a plain console app, install it first:

// dotnet add package Microsoft.Extensions.DependencyInjection

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddTransient<IEmailSender, ConsoleEmailSender>();
services.AddTransient<OrderService>();

using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,   // catch lifetime mistakes
    ValidateOnBuild = true   // fail fast if a dependency is missing
});

using (IServiceScope scope = provider.CreateScope())
{
    var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
    orderService.PlaceOrder(new Order(3, "alex@example.com", 75m));
}

In ASP.NET Core you register services on builder.Services in Program.cs. The framework then creates a scope for each HTTP request and injects services into controllers, minimal API handlers, middleware and Razor components.

AddScoped vs AddTransient vs AddSingleton: Service Lifetimes Explained

Choosing the wrong lifetime causes many real-world DI bugs, so it helps to know exactly what each one does:

  • Transient (AddTransient): you get a new instance every time the service is requested. Best for lightweight, stateless services.
  • Scoped (AddScoped): one instance per scope. In a web app, a scope is one HTTP request. DbContext in Entity Framework Core is registered as scoped by default.
  • Singleton (AddSingleton): one instance for the whole life of the application. Use it for caches, configuration and HttpClient-style shared resources. It must be thread-safe.

The Captive Dependency Pitfall

A service must never depend on anything with a shorter lifetime than its own. If a singleton takes a scoped DbContext, that DbContext gets "captured" and lives forever. It's then shared across threads and requests, which leads to data corruption and ObjectDisposedException errors that are hard to track down:

public class ReportCache  // registered as Singleton
{
    // BUG: IOrderRepository is Scoped - it becomes a captive dependency
    public ReportCache(IOrderRepository repository) { }
}

With ValidateScopes = true, the container throws an exception when you resolve this. ASP.NET Core turns that check on automatically in the Development environment. If a singleton really does need scoped work, inject IServiceScopeFactory and create a short-lived scope each time you need one:

public class ReportCache(IServiceScopeFactory scopeFactory)
{
    public void Refresh()
    {
        using IServiceScope scope = scopeFactory.CreateScope();
        var repository = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
        // use repository, then the scope disposes it
    }
}

The example above uses a primary constructor (C# 12+). This short syntax works well for DI because the constructor parameters are available throughout the class.

Unit Testing with Dependency Injection

This is where DI pays off. Because OrderService depends on interfaces, you can pass in test doubles. No database, no SMTP server, and no mocking library needed:

using Xunit;

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject, string body) => SentTo.Add(to);
}

public class FakeOrderRepository : IOrderRepository
{
    public List<Order> Saved { get; } = new();
    public void Save(Order order) => Saved.Add(order);
}

public class OrderServiceTests
{
    [Fact]
    public void PlaceOrder_ValidOrder_SavesAndSendsEmail()
    {
        var repo = new FakeOrderRepository();
        var email = new FakeEmailSender();
        var sut = new OrderService(repo, email);

        sut.PlaceOrder(new Order(10, "test@example.com", 20m));

        Assert.Single(repo.Saved);
        Assert.Equal("test@example.com", email.SentTo.Single());
    }

    [Fact]
    public void PlaceOrder_ZeroTotal_ThrowsAndSendsNothing()
    {
        var repo = new FakeOrderRepository();
        var email = new FakeEmailSender();
        var sut = new OrderService(repo, email);

        Assert.Throws<ArgumentException>(() => sut.PlaceOrder(new Order(11, "x@example.com", 0m)));
        Assert.Empty(email.SentTo);
    }
}

These tests run in milliseconds and don't depend on anything outside your code. In larger codebases, libraries like NSubstitute or Moq can generate these fakes for you, but the principle is the same. Note that the tests don't use a container at all. That's intentional: unit tests should construct the class under test directly.

Advanced Dependency Injection in C#: Keyed Services and Factories

Sometimes you need several implementations of the same interface. Starting with .NET 8, the built-in container supports keyed services:

services.AddKeyedTransient<IEmailSender, ConsoleEmailSender>("console");
services.AddKeyedTransient<IEmailSender, SmtpEmailSender>("smtp");

public class NotificationService(
    [FromKeyedServices("smtp")] IEmailSender emailSender)
{
    public void Notify(string to) => emailSender.Send(to, "Hello", "Hi there!");
}

If an implementation needs runtime configuration, register it with a factory delegate:

services.AddSingleton<IEmailSender>(sp =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new SmtpEmailSender(config["Smtp:Host"]!);
});

You can also inject IEnumerable<IEmailSender> to get every registered implementation (keyed registrations aren't included). That's useful for plugin systems, validators and notification fan-out.

C# Dependency Injection Best Practices

  • Prefer constructor injection. It makes dependencies explicit and required, and it lets you use readonly fields. Property injection hides dependencies and allows objects to exist in a half-built state.
  • Keep the composition root in one place. Registrations belong in Program.cs or in extension methods such as services.AddOrdering(), not spread across business classes.
  • Depend on abstractions at your boundaries. You don't need an interface for every class. Add one where the implementation might change or where tests need a seam: databases, HTTP, file system, time (TimeProvider), email.
  • Turn on validation. ValidateOnBuild and ValidateScopes catch missing registrations and captive dependencies at startup instead of in production.
  • Keep constructors cheap. Don't do I/O or async work inside a constructor. Containers resolve services synchronously, and slow constructors make the whole app slow to start.
  • Let the container own disposal. Don't manually Dispose() an injected service. The container disposes the instances it created when their scope ends.

Common Dependency Injection Mistakes to Avoid

  • The Service Locator anti-pattern. If you inject IServiceProvider and call GetService throughout your business logic, you hide dependencies again. It's the same problem as calling new, just less obvious.
  • Constructor over-injection. A constructor with eight or more parameters usually means the class has too many responsibilities. Split the class; don't reach for property injection.
  • Calling BuildServiceProvider() inside ConfigureServices. This creates a second container with its own set of singletons. ASP.NET Core analyzers warn you about this for good reason.
  • Mutable state in singletons. Singletons are shared across threads, so any mutable state needs ConcurrentDictionary, locks or immutability.
  • Resolving scoped services from the root provider. Outside a request, always call CreateScope() first. This matters especially in BackgroundService classes.

Conclusion: Key Takeaways on Dependency Injection in C#

Dependency injection in C# comes down to one idea: a class should say what it needs, not build it. We started with a tightly coupled OrderService, fixed it with plain constructor injection, built a small reflection-based container to see how the wiring works, and then used the built-in .NET container for real applications.

  • DI moves object creation to a single composition root, which makes your code loosely coupled.
  • Constructor injection is the default choice because it's explicit, immutable and easy to test.
  • Know your lifetimes: Transient (every request), Scoped (per scope or HTTP request), Singleton (app lifetime). Never let a longer-lived service capture a shorter-lived one.
  • Turn on ValidateScopes and ValidateOnBuild so mistakes show up at startup.
  • Testability is the biggest benefit. Swapping in fakes gives you fast, reliable unit tests.
  • Avoid the Service Locator pattern, over-injection and stateful singletons.

Next step: open one of your own projects, find a class that calls new on a database, HTTP or email dependency, and refactor it to use constructor injection. Then write its first unit test.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...