Skip to content

Instantly share code, notes, and snippets.

@azdanov
Created May 10, 2026 07:17
Show Gist options
  • Select an option

  • Save azdanov/4956c374fe0978654af224033e56444b to your computer and use it in GitHub Desktop.

Select an option

Save azdanov/4956c374fe0978654af224033e56444b to your computer and use it in GitHub Desktop.
Dependency injection and inversion of control in C#

Dependency Injection (DI) means you give an object the things it depends on, instead of letting it create them itself.

Inversion of Control (IoC) means object creation and wiring are moved out of your app code into another mechanism—often an IoC container like Microsoft.Extensions.DependencyInjection.

1) Basic principle: manual dependency injection

Start with an abstraction:

public interface IEngine
{
    void Start();
}

Two implementations:

public class Engine : IEngine
{
    public void Start()
    {
        Console.WriteLine("Standard engine started.");
    }
}
public class Car
{
    private readonly IEngine _engine;

    public Car(IEngine engine)
    {
        _engine = engine;
    }

    public void Drive()
    {
        _engine.Start();
        Console.WriteLine("Car is driving.");
    }
}

What matters here

Car depends on IEngine, not on Engine directly.

That is the core DI idea:

  • Car does not do new Engine()
  • someone else provides the engine

2) Manual wiring

IEngine engine = new Engine();
Car car = new Car(engine);

car.Drive();

This is manual dependency injection.

Why it's better than hardcoding

If Car looked like this:

public class Car
{
    private readonly Engine _engine = new Engine();

    public void Drive()
    {
        _engine.Start();
        Console.WriteLine("Car is driving.");
    }
}

then:

  • Car is tightly coupled to Engine
  • hard to replace with another engine
  • harder to test

With DI:

  • you can swap implementations
  • Car becomes easier to test
  • responsibilities are cleaner

3) Add more implementations: ElectricEngine and DieselEngine

public class ElectricEngine : IEngine
{
    public void Start()
    {
        Console.WriteLine("Electric engine powered on silently.");
    }
}
public class DieselEngine : IEngine
{
    public void Start()
    {
        Console.WriteLine("Diesel engine started with a rumble.");
    }
}

Now the same Car can use any of them:

Car electricCar = new Car(new ElectricEngine());
electricCar.Drive();

Car dieselCar = new Car(new DieselEngine());
dieselCar.Drive();

This shows the benefit clearly: the car code does not change.


4) CarShow example

Suppose a CarShow needs a Car.

public class CarShow
{
    private readonly Car _car;

    public CarShow(Car car)
    {
        _car = car;
    }

    public void Present()
    {
        Console.WriteLine("Welcome to the car show!");
        _car.Drive();
    }
}

Manual wiring now becomes:

IEngine engine = new ElectricEngine();
Car car = new Car(engine);
CarShow show = new CarShow(car);

show.Present();

Still fine for a small app.

But as the app grows:

  • CarShow depends on Car
  • Car depends on IEngine
  • other classes depend on other services

Then manual wiring becomes repetitive and messy.

That is where an IoC container helps.


5) What an IoC container does

An IoC container:

  • knows how to create objects
  • knows what dependencies they need
  • resolves them automatically

In .NET, the common built-in container is:

  • Microsoft.Extensions.DependencyInjection

So instead of this:

var show = new CarShow(new Car(new ElectricEngine()));

you register services once, and ask the container for CarShow.


6) Using Microsoft.Extensions.DependencyInjection

Install package if needed:

dotnet add package Microsoft.Extensions.DependencyInjection

Then:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddTransient<IEngine, Engine>();
services.AddTransient<Car>();
services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

var show = provider.GetRequiredService<CarShow>();
show.Present();

What happens here

  • container sees CarShow needs Car
  • container sees Car needs IEngine
  • container sees IEngine maps to Engine
  • it builds the whole object graph for you

This is IoC via container.


7) DI vs IoC

A simple way to distinguish them:

  • Dependency Injection = the technique
  • IoC container = a tool that automates the technique

So:

  • manual constructor injection = DI
  • ServiceCollection resolving dependencies = DI with an IoC container

8) Problem: multiple implementations of IEngine

Now suppose you register both:

services.AddTransient<IEngine, ElectricEngine>();
services.AddTransient<IEngine, DieselEngine>();

This creates an important question:

Which one should Car get?

By default, if Car asks for a single IEngine, the built-in container generally resolves the last registered one for single-service resolution.

That can be surprising and unclear.

So when you have multiple implementations, you usually choose one of these patterns:

  • inject IEnumerable<IEngine>
  • use a factory
  • use keyed/named resolution patterns
  • create separate abstractions if they are meaningfully different

9) Inject all engines with IEnumerable

Example CarShow wants to display all engine types:

public class CarShow
{
    private readonly IEnumerable<IEngine> _engines;

    public CarShow(IEnumerable<IEngine> engines)
    {
        _engines = engines;
    }

    public void Present()
    {
        Console.WriteLine("Available engines at the car show:");

        foreach (var engine in _engines)
        {
            engine.Start();
        }
    }
}

Registration:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddTransient<IEngine, ElectricEngine>();
services.AddTransient<IEngine, DieselEngine>();
services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

var show = provider.GetRequiredService<CarShow>();
show.Present();

This is good when you truly want all implementations.


10) Selecting one engine for Car using a factory

If one Car should use one chosen engine, a factory is cleaner.

Car stays simple

public class Car
{
    private readonly IEngine _engine;

    public Car(IEngine engine)
    {
        _engine = engine;
    }

    public void Drive()
    {
        _engine.Start();
        Console.WriteLine("Car is driving.");
    }
}

Register multiple engines and choose one in a factory

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddTransient<ElectricEngine>();
services.AddTransient<DieselEngine>();

services.AddTransient<Car>(sp =>
{
    // choose which engine to use
    var engine = sp.GetRequiredService<ElectricEngine>();
    return new Car(engine);
});

services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

var show = provider.GetRequiredService<CarShow>();
show.Present();

CarShow:

public class CarShow
{
    private readonly Car _car;

    public CarShow(Car car)
    {
        _car = car;
    }

    public void Present()
    {
        Console.WriteLine("Welcome to the car show!");
        _car.Drive();
    }
}

This is often the most straightforward way when a class needs one specific implementation.


11) Better abstraction for choosing engines

If engine choice depends on runtime input, make a factory interface.

public interface IEngineFactory
{
    IEngine Create(string engineType);
}
public class EngineFactory : IEngineFactory
{
    public IEngine Create(string engineType)
    {
        return engineType switch
        {
            "electric" => new ElectricEngine(),
            "diesel" => new DieselEngine(),
            _ => throw new ArgumentException("Unknown engine type")
        };
    }
}

Then:

public class CarShow
{
    private readonly IEngineFactory _engineFactory;

    public CarShow(IEngineFactory engineFactory)
    {
        _engineFactory = engineFactory;
    }

    public void Present(string engineType)
    {
        var car = new Car(_engineFactory.Create(engineType));
        Console.WriteLine($"Presenting a car with {engineType} engine:");
        car.Drive();
    }
}

Registration:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddSingleton<IEngineFactory, EngineFactory>();
services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

var show = provider.GetRequiredService<CarShow>();
show.Present("electric");
show.Present("diesel");

This works, though note that this particular factory manually creates engines with new. In bigger apps, you usually let the container help inside the factory too.


12) Factory using the container

public interface IEngineFactory
{
    IEngine Create(string engineType);
}
using Microsoft.Extensions.DependencyInjection;

public class EngineFactory : IEngineFactory
{
    private readonly IServiceProvider _serviceProvider;

    public EngineFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IEngine Create(string engineType)
    {
        return engineType switch
        {
            "electric" => _serviceProvider.GetRequiredService<ElectricEngine>(),
            "diesel" => _serviceProvider.GetRequiredService<DieselEngine>(),
            _ => throw new ArgumentException("Unknown engine type")
        };
    }
}

Registration:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddTransient<ElectricEngine>();
services.AddTransient<DieselEngine>();
services.AddSingleton<IEngineFactory, EngineFactory>();
services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

This streamlines resolution when there are several dependencies.


13) Even cleaner in modern .NET: keyed services

If you're using newer .NET versions, Microsoft.Extensions.DependencyInjection supports keyed services, which is a strong fit for “ElectricEngine vs DieselEngine”.

Registration example:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddKeyedTransient<IEngine, ElectricEngine>("electric");
services.AddKeyedTransient<IEngine, DieselEngine>("diesel");

services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

Resolve by key:

using Microsoft.Extensions.DependencyInjection;

public class CarShow
{
    private readonly IServiceProvider _serviceProvider;

    public CarShow(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Present(string engineType)
    {
        var engine = _serviceProvider.GetRequiredKeyedService<IEngine>(engineType);
        var car = new Car(engine);

        Console.WriteLine($"Presenting a car with {engineType} engine:");
        car.Drive();
    }
}

Usage:

var show = provider.GetRequiredService<CarShow>();
show.Present("electric");
show.Present("diesel");

This is often more maintainable than relying on registration order.


14) Simple mental model

Without DI

Car says:

I will create my own engine.

With DI

Car says:

I need an IEngine. Please give me one.

With IoC container

The container says:

I know how to build Car, CarShow, and the right IEngine.


15) Best practice summary

Use:

  • constructor injection as the default
  • interfaces like IEngine to decouple implementations
  • manual DI for very small examples/apps
  • Microsoft.Extensions.DependencyInjection when wiring grows
  • factory or keyed services when multiple implementations exist

Avoid:

  • creating dependencies directly with new inside business classes
  • depending on concrete classes unless there's a good reason
  • relying on “last registration wins” when multiple implementations exist

16) Final compact example

public interface IEngine
{
    void Start();
}
public class ElectricEngine : IEngine
{
    public void Start() => Console.WriteLine("Electric engine started.");
}
public class DieselEngine : IEngine
{
    public void Start() => Console.WriteLine("Diesel engine started.");
}
public class Car
{
    private readonly IEngine _engine;

    public Car(IEngine engine)
    {
        _engine = engine;
    }

    public void Drive()
    {
        _engine.Start();
        Console.WriteLine("Car is driving.");
    }
}
using Microsoft.Extensions.DependencyInjection;

public class CarShow
{
    private readonly IServiceProvider _serviceProvider;

    public CarShow(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Present(string engineType)
    {
        var engine = _serviceProvider.GetRequiredKeyedService<IEngine>(engineType);
        var car = new Car(engine);
        car.Drive();
    }
}
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddKeyedTransient<IEngine, ElectricEngine>("electric");
services.AddKeyedTransient<IEngine, DieselEngine>("diesel");
services.AddTransient<CarShow>();

var provider = services.BuildServiceProvider();

var show = provider.GetRequiredService<CarShow>();
show.Present("electric");
show.Present("diesel");

In one sentence

  • Dependency Injection is passing dependencies into classes.
  • IoC container is the framework that automatically creates and passes those dependencies for you.

If you want, I can also rewrite this as:

  1. a beginner-friendly explanation, or
  2. an interview-style answer, or
  3. a full compilable console app.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment