C# Dependency Injection Explained With Real-World Examples


If you have worked with ASP.NET Core, you have probably seen code like this:

builder.Services.AddScoped<IOrderService, OrderService>();

and then:

public class OrdersController
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }
}

It looks simple.

But what exactly is happening here?

Why should we use IOrderService instead of simply creating new OrderService()?

And why has Dependency Injection become such an important part of modern C# and .NET development?

Let's understand Dependency Injection from the ground up using real-world examples, not just definitions.


What Is Dependency Injection?

Dependency Injection, commonly called DI, is a design technique where an object receives the dependencies it needs from an external source instead of creating those dependencies itself.

That sounds complicated, so let's simplify it.

Imagine you have an OrderService that needs to send an email.

A simple implementation might look like this:

public class OrderService
{
    public void PlaceOrder()
    {
        var emailService = new EmailService();

        emailService.SendEmail(
            "customer@example.com",
            "Order placed successfully");
    }
}

This code works.

But OrderService is now responsible for two things:

  1. Placing orders

  2. Creating and managing an email service

That creates a strong dependency between the two classes.

Instead, we can inject the dependency:

public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService)
    {
        _emailService = emailService;
    }

    public void PlaceOrder()
    {
        _emailService.SendEmail(
            "customer@example.com",
            "Order placed successfully");
    }
}

Now OrderService doesn't care how emails are sent.

It only knows that it has an IEmailService.

That's the core idea behind Dependency Injection.


A Real-World Analogy

Think about a restaurant.

A restaurant's chef needs ingredients.

The chef doesn't manufacture tomatoes, grow wheat, produce salt, and raise chickens.

Instead, those dependencies are supplied to the kitchen.

The chef simply says:

"I need tomatoes."

The supplier provides them.

Similarly, a C# class can say:

"I need an email service."

The Dependency Injection container provides it.

The class doesn't need to know how the dependency was created.


Without Dependency Injection

Let's start with a typical example.

Suppose we are building an e-commerce application.

We have:

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        var repository = new OrderRepository();

        repository.Save(order);
    }
}

At first glance, this looks perfectly reasonable.

But imagine that OrderRepository requires a database connection:

public class OrderRepository
{
    private readonly SqlConnection _connection;

    public OrderRepository()
    {
        _connection = new SqlConnection(
            "Server=localhost;Database=Orders;");
    }

    public void Save(Order order)
    {
        // Save order
    }
}

Now OrderService indirectly depends on:

  • OrderRepository

  • SQL Server

  • Connection configuration

This makes the code difficult to change and test.


The Problem With new

One of the biggest warning signs is excessive use of new inside business logic.

For example:

public class PaymentService
{
    public void ProcessPayment(decimal amount)
    {
        var logger = new FileLogger();
        var paymentGateway = new StripePaymentGateway();

        logger.Log("Processing payment");

        paymentGateway.Charge(amount);
    }
}

What happens if we want to switch to another payment provider?

Maybe tomorrow we want:

Stripe
   ↓
PayPal
   ↓
Adyen

We would have to modify PaymentService.

That's the problem.

The business logic knows too much about infrastructure.


Introducing Interfaces

We can introduce abstractions.

public interface IPaymentGateway
{
    void Charge(decimal amount);
}

Then create an implementation:

public class StripePaymentGateway : IPaymentGateway
{
    public void Charge(decimal amount)
    {
        Console.WriteLine($"Charging {amount} using Stripe");
    }
}

Now the service depends on the abstraction:

public class PaymentService
{
    private readonly IPaymentGateway _paymentGateway;

    public PaymentService(IPaymentGateway paymentGateway)
    {
        _paymentGateway = paymentGateway;
    }

    public void ProcessPayment(decimal amount)
    {
        _paymentGateway.Charge(amount);
    }
}

Notice what changed.

PaymentService no longer knows about Stripe.

It only knows about:

IPaymentGateway

That's a huge improvement.


What Is Actually Being Injected?

In this constructor:

public PaymentService(IPaymentGateway paymentGateway)
{
    _paymentGateway = paymentGateway;
}

IPaymentGateway is the dependency.

The PaymentService depends on it.

Instead of doing:

var paymentGateway = new StripePaymentGateway();

inside the class, we provide it from outside.

That's why it's called Dependency Injection.

The dependency is injected into the object.


Constructor Injection

Constructor injection is the most common form of Dependency Injection in modern C# applications.

Example:

public class CustomerService
{
    private readonly ICustomerRepository _repository;

    public CustomerService(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public Customer GetCustomer(int id)
    {
        return _repository.GetById(id);
    }
}

The dependency is provided through the constructor.

This has an important advantage:

The object cannot be created without satisfying its required dependency.

For example:

var service = new CustomerService(repository);

The dependency is explicit.


ASP.NET Core and Dependency Injection

This is where Dependency Injection becomes especially useful.

ASP.NET Core has a built-in Dependency Injection container.

For example:

builder.Services.AddScoped<IOrderService, OrderService>();

This tells ASP.NET Core:

Whenever something asks for IOrderService, provide an OrderService.

Then a controller can simply request it:

public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }

    [HttpPost]
    public IActionResult Create(Order order)
    {
        _orderService.PlaceOrder(order);

        return Ok();
    }
}

You never wrote:

new OrderService()

ASP.NET Core creates it for you.


How the Dependency Injection Container Works

Think of the DI container as a factory.

You register:

builder.Services.AddScoped<IOrderService, OrderService>();

Later, ASP.NET Core sees:

public OrdersController(IOrderService orderService)

It looks at the registration:

IOrderService
      ↓
OrderService

Then it creates:

OrderService

and supplies it to the controller.

The flow looks like this:

HTTP Request
     ↓
OrdersController
     ↓
IOrderService
     ↓
OrderService
     ↓
IOrderRepository
     ↓
Database

The DI container is responsible for wiring these dependencies together.


Registering Multiple Dependencies

Real applications rarely have only one dependency.

Consider:

public class OrderService : IOrderService
{
    private readonly IOrderRepository _repository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEmailService _emailService;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository repository,
        IPaymentGateway paymentGateway,
        IEmailService emailService,
        ILogger<OrderService> logger)
    {
        _repository = repository;
        _paymentGateway = paymentGateway;
        _emailService = emailService;
        _logger = logger;
    }
}

The service has four dependencies.

We can register them:

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddScoped<IEmailService, EmailService>();

ASP.NET Core builds the dependency graph automatically.


Dependency Injection Lifetimes

One of the most important concepts in ASP.NET Core DI is service lifetime.

There are three commonly used lifetimes:

Transient
Scoped
Singleton

Let's understand them.


1. Transient

Register a transient service:

builder.Services.AddTransient<IEmailService, EmailService>();

A new instance is created each time the service is requested.

Think of it as:

Request A → EmailService #1
Request B → EmailService #2
Request C → EmailService #3

Transient is useful for lightweight, stateless services.


2. Scoped

Register:

builder.Services.AddScoped<IOrderService, OrderService>();

A scoped service generally has one instance per scope.

In an ASP.NET Core web application, a scope commonly corresponds to an HTTP request.

For example:

HTTP Request 1
    ↓
OrderService #1

HTTP Request 2
    ↓
OrderService #2

Scoped is commonly used for application services and database-related components.

For example:

builder.Services.AddScoped<IOrderRepository, OrderRepository>();

3. Singleton

Register:

builder.Services.AddSingleton<ICacheService, CacheService>();

Only one instance is created for the application's lifetime.

Conceptually:

Application
    ↓
CacheService #1
    ↓
Used by many requests

Singletons can be useful for carefully designed shared, thread-safe, stateless services or application-wide caches.

But singleton services require careful design because the same instance can be accessed concurrently.


A Common DI Mistake

Consider:

builder.Services.AddSingleton<MyService>();

and:

public class MyService
{
    private readonly DbContext _context;

    public MyService(DbContext context)
    {
        _context = context;
    }
}

This can create a lifetime mismatch if DbContext is registered as scoped.

A singleton should not directly depend on a scoped service.

The general rule is:

Don't make a longer-lived service depend directly on a shorter-lived service.

This is one of the most important DI concepts to understand in ASP.NET Core.


Dependency Injection Makes Testing Easier

This is one of the biggest practical benefits.

Suppose our service depends on:

IPaymentGateway

During production, we use:

StripePaymentGateway

During testing, we can use:

FakePaymentGateway

For example:

public class FakePaymentGateway : IPaymentGateway
{
    public bool PaymentProcessed { get; private set; }

    public void Charge(decimal amount)
    {
        PaymentProcessed = true;
    }
}

Now we can test:

var gateway = new FakePaymentGateway();

var service = new PaymentService(gateway);

service.ProcessPayment(100);

Assert.True(gateway.PaymentProcessed);

No real payment provider.

No network call.

No credit card.

No external dependency.

That's the power of Dependency Injection.


Dependency Injection and Mocking

DI also works nicely with mocking frameworks.

For example, a test could provide a mocked repository:

var repository = new Mock<IOrderRepository>();

repository
    .Setup(x => x.GetById(10))
    .Returns(new Order
    {
        Id = 10
    });

Then:

var service = new OrderService(repository.Object);

The service doesn't know whether it received:

Real Repository
       OR
Mock Repository
       OR
Fake Repository

It only knows about:

IOrderRepository

Dependency Injection Is More Than Just Using Interfaces

This is an important distinction.

You might see code like:

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
}

and conclude:

"DI means using interfaces."

Not exactly.

Interfaces help create abstractions, but Dependency Injection is fundamentally about providing dependencies from outside rather than constructing them internally.

You could technically inject a concrete class:

public OrderService(OrderRepository repository)
{
    _repository = repository;
}

That is still dependency injection.

However, depending on abstractions can provide additional flexibility and can support cleaner architectural boundaries.


Real-World Example: Notification Service

Imagine an application that sends notifications.

Today we use email.

Tomorrow we may need:

  • Email

  • SMS

  • Push notifications

  • WhatsApp

Instead of tightly coupling our business logic to one provider, define:

public interface INotificationService
{
    Task SendAsync(string recipient, string message);
}

Email implementation:

public class EmailNotificationService : INotificationService
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"Sending email to {recipient}");

        return Task.CompletedTask;
    }
}

SMS implementation:

public class SmsNotificationService : INotificationService
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"Sending SMS to {recipient}");

        return Task.CompletedTask;
    }
}

Our business service doesn't care which implementation is being used:

public class AccountService
{
    private readonly INotificationService _notificationService;

    public AccountService(INotificationService notificationService)
    {
        _notificationService = notificationService;
    }

    public async Task CreateAccountAsync(string email)
    {
        // Create account...

        await _notificationService.SendAsync(
            email,
            "Your account has been created.");
    }
}

Now we can change the notification mechanism without changing AccountService.

That's loose coupling.


The Dependency Inversion Principle

Dependency Injection is closely related to the Dependency Inversion Principle, one of the SOLID principles.

The basic idea is:

High-level business logic should not depend directly on low-level implementation details.

Instead:

Business Logic
      ↓
Abstraction
      ↓
Infrastructure

For example:

OrderService
     ↓
IOrderRepository
     ↓
SqlOrderRepository

Rather than:

OrderService
     ↓
SqlOrderRepository

The first design makes it easier to replace infrastructure without rewriting business logic.


DI Helps You Change Technology

This becomes extremely valuable in real projects.

Suppose your application initially uses:

SQL Server

Later, part of the application needs:

PostgreSQL

If your business logic directly creates SQL-specific classes everywhere, the migration becomes painful.

With abstractions:

public interface ICustomerRepository
{
    Task<Customer?> GetAsync(int id);
}

you can have:

SqlCustomerRepository

or:

PostgresCustomerRepository

The application service can remain unchanged.


DI Can Also Improve Architecture

A well-designed application might look like:

API
 │
 ▼
Application Services
 │
 ▼
Interfaces
 │
 ├── Database
 ├── Payment Provider
 ├── Email Provider
 └── External APIs

The business logic sits in the middle.

Infrastructure details stay at the edges.

This is one reason Dependency Injection is commonly used in architectures such as:

  • Clean Architecture

  • Hexagonal Architecture

  • Onion Architecture

  • Domain-Driven Design applications


When Should You Use Dependency Injection?

DI is particularly useful when a class depends on:

  • Database repositories

  • External APIs

  • Payment providers

  • Email services

  • File storage

  • Logging

  • Caching

  • Message queues

  • Configuration

  • Time providers

  • Authentication services

For example:

public class ReportService
{
    private readonly IReportRepository _repository;
    private readonly IFileStorage _storage;
    private readonly INotificationService _notification;

    public ReportService(
        IReportRepository repository,
        IFileStorage storage,
        INotificationService notification)
    {
        _repository = repository;
        _storage = storage;
        _notification = notification;
    }
}

Each dependency has a clear responsibility.


When Should You NOT Use DI?

Not every class needs to be registered in the DI container.

For example:

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

There is no meaningful external dependency here.

You don't necessarily need:

builder.Services.AddTransient<Calculator>();

You could simply use:

var calculator = new Calculator();

DI is not a goal by itself.

The goal is to create code that is easier to maintain, test, and evolve.


A Practical Rule

When you see:

var dependency = new Something();

inside a business service, ask yourself:

"Does this class really need to create this dependency?"

If the answer is no, consider injecting it.

For example:

public class ReportService
{
    public void Generate()
    {
        var repository = new SqlReportRepository();
        var email = new EmailService();

        // ...
    }
}

could become:

public class ReportService
{
    private readonly IReportRepository _repository;
    private readonly IEmailService _emailService;

    public ReportService(
        IReportRepository repository,
        IEmailService emailService)
    {
        _repository = repository;
        _emailService = emailService;
    }

    public void Generate()
    {
        // Business logic
    }
}

The second version is easier to test and change.


Dependency Injection in One Diagram

The entire concept can be summarized like this:

                 DI Container
                      │
          ┌───────────┼───────────┐
          │           │           │
          ▼           ▼           ▼
    OrderService  EmailService  Repository
          │
          ▼
    IOrderRepository
          │
          ▼
   SqlOrderRepository

The important part is that the application doesn't have to manually construct the entire object graph.

The container does the wiring.


Final Example

Let's put everything together.

Interfaces

public interface IOrderRepository
{
    Task SaveAsync(Order order);
}

public interface INotificationService
{
    Task SendAsync(string recipient, string message);
}

Implementations

public class OrderRepository : IOrderRepository
{
    public Task SaveAsync(Order order)
    {
        // Save to database

        return Task.CompletedTask;
    }
}
public class EmailNotificationService : INotificationService
{
    public Task SendAsync(string recipient, string message)
    {
        // Send email

        return Task.CompletedTask;
    }
}

Business Service

public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly INotificationService _notification;

    public OrderService(
        IOrderRepository repository,
        INotificationService notification)
    {
        _repository = repository;
        _notification = notification;
    }

    public async Task PlaceOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);

        await _notification.SendAsync(
            order.CustomerEmail,
            "Your order has been placed.");
    }
}

Registration

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<INotificationService, EmailNotificationService>();
builder.Services.AddScoped<OrderService>();

Now the application can construct the dependency graph:

OrderService
    │
    ├── IOrderRepository
    │       └── OrderRepository
    │
    └── INotificationService
            └── EmailNotificationService

The OrderService doesn't create any of these dependencies itself.

That's Dependency Injection.


Final Thoughts

Dependency Injection isn't just an ASP.NET Core feature.

It is a design approach that helps you build software where classes have clear responsibilities and don't become tightly coupled to implementation details.

The key idea is simple:

Without DI:

Class
 ├── creates dependency
 └── uses dependency


With DI:

DI Container
     │
     ▼
  Dependency
     │
     ▼
    Class

When used correctly, DI gives you:

  • Loose coupling

  • Better testability

  • Easier replacement of implementations

  • Cleaner architecture

  • Better separation of concerns

  • More maintainable code

If you're building ASP.NET Core applications, understanding Dependency Injection isn't optional—it is one of the fundamental concepts behind how the framework itself is designed.

And the next time you see:

builder.Services.AddScoped<IOrderService, OrderService>();

you'll know exactly what is happening behind the scenes.

Home

Comments

Popular posts from this blog

How to Install .NET 10 SDK on Windows, Linux, and macOS

Microsoft Agent Framework Explained: A .NET Developer’s Architecture Guide

Why Is Your ASP.NET Core API Using So Much Memory? 7 Causes and Fixes