I’ve been using dependency injection while learning .NET, but I was always fuzzy about what an IoC container actually does behind the scenes. All I knew was that you register something and then get an instance in the constructor, without any clear idea of how it works under the hood. So, I decided to build a minimal IoC container myself, and doing so helped me understand a lot more.


First, let’s clarify what an IoC container actually does

It essentially boils down to three things:

  1. You tell the container that ILogger corresponds to ConsoleLogger. This is called registration.
  2. When you ask the container for an IUserService, it notices that its constructor requires IUserRepository and ILogger, so it recursively creates all those dependencies, assembles them, and returns the result to you. This is called resolution.
  3. Some objects should be singletons shared globally (like logging), while others should be instantiated fresh every time. This is called lifetime management.

Once you understand these three concepts, an IoC container isn’t so mysterious anymore.

Container Code

Here’s the code directly—it’s just one class:

using System;
using System.Collections.Generic;
using System.Linq;

public class MyContainer
{
    enum Lifetime { Transient, Singleton }

    Dictionary<Type, Type> _typeMap = new Dictionary<Type, Type>();
    Dictionary<Type, Lifetime> _lifetimeMap = new Dictionary<Type, Lifetime>();
    Dictionary<Type, object> _singletonMap = new Dictionary<Type, object>();

    public void AddTransient<TService, TImpl>() where TImpl : TService
    {
        _typeMap[typeof(TService)] = typeof(TImpl);
        _lifetimeMap[typeof(TService)] = Lifetime.Transient;
    }

    public void AddSingleton<TService, TImpl>() where TImpl : TService
    {
        _typeMap[typeof(TService)] = typeof(TImpl);
        _lifetimeMap[typeof(TService)] = Lifetime.Singleton;
    }

    public T Resolve<T>()
    {
        return (T)Resolve(typeof(T));
    }

    object Resolve(Type type)
    {
        if (!_typeMap.ContainsKey(type))
            throw new Exception("没有注册类型: " + type.Name);

        var implType = _typeMap[type];
        var lifetime = _lifetimeMap[type];

        // 单例的话看看有没有已经创建好的
        if (lifetime == Lifetime.Singleton)
        {
            if (_singletonMap.ContainsKey(type))
                return _singletonMap[type];
        }

        // 找构造函数,参数最多的那个
        var ctor = implType.GetConstructors()
            .OrderByDescending(c => c.GetParameters().Length)
            .First();

        // 把构造函数的每个参数都递归解析出来
        var paramInfos = ctor.GetParameters();
        var paramValues = new object[paramInfos.Length];
        for (int i = 0; i < paramInfos.Length; i++)
        {
            paramValues[i] = Resolve(paramInfos[i].ParameterType);
        }

        // 创建实例
        var obj = Activator.CreateInstance(implType, paramValues);

        if (lifetime == Lifetime.Singleton)
            _singletonMap[type] = obj;

        return obj;
    }
}

It's a bit messy. The three Dictionaries are stored separately, but they could have been combined into a single Tuple or an inner class. However, I didn't think about that at the time, and since it was working, I left it as is.

Interfaces and Implementation Classes for Testing

A three-layer dependency structure was established: UserService depends on both UserRepository and ILogger, while UserRepository depends on ILogger.

public interface ILogger
{
    void Log(string msg);
}

public class ConsoleLogger : ILogger
{
    public void Log(string msg)
    {
        Console.WriteLine("[LOG] " + msg);
    }
}

public interface IUserRepository
{
    string GetUser(int id);
}

public class UserRepository : IUserRepository
{
    ILogger _logger;

    public UserRepository(ILogger logger)
    {
        _logger = logger;
    }

    public string GetUser(int id)
    {
        _logger.Log("查询用户 " + id);
        return "User_" + id;
    }
}

public interface IUserService
{
    void PrintUser(int id);
}

public class UserService : IUserService
{
    IUserRepository _repo;
    ILogger _logger;

    public UserService(IUserRepository repo, ILogger logger)
    {
        _repo = repo;
        _logger = logger;
    }

    public void PrintUser(int id)
    {
        var user = _repo.GetUser(id);
        _logger.Log("找到了: " + user);
    }
}

Run it

static void Main(string[] args)
{
    var container = new MyContainer();

    container.AddSingleton<ILogger, ConsoleLogger>();
    container.AddTransient<IUserRepository, UserRepository>();
    container.AddTransient<IUserService, UserService>();

    var service = container.Resolve<IUserService>();
    service.PrintUser(1);

    // 验证单例
    var logger1 = container.Resolve<ILogger>();
    var logger2 = container.Resolve<ILogger>();
    Console.WriteLine("是同一个实例吗: " + ReferenceEquals(logger1, logger2));

    Console.ReadLine();
}

Output:

[LOG] 查询用户 1
[LOG] 找到了: User_1
是同一个实例吗: True

You only Resolved an IUserService; the container automatically created both UserRepository and ConsoleLogger, filling in the constructor parameters for you. The singleton pattern is also working correctly; two calls to Resolve<ILogger>() return the same object instance.

What Exactly Happens Here

When you call Resolve<IUserService>(), the container performs the following steps internally:

  1. IUserService → Finds the mapping to UserService
  2. Inspects the constructor of UserService, which requires IUserRepository and ILogger
  3. Resolves IUserRepository first → Maps to UserRepository → Its constructor requires ILogger
  4. Resolves ILogger → Maps to ConsoleLogger → Has a parameterless constructor, so it is created directly via Activator.CreateInstance
  5. Uses the ConsoleLogger instance to create UserRepository
  6. Resolves ILogger again; since it is registered as a singleton, it returns the same ConsoleLogger instance from before
  7. Uses both UserRepository and ConsoleLogger to create UserService

This is essentially recursion: it keeps recursing until it reaches leaf nodes with no dependencies, then builds back up layer by layer. The for loop in the Resolve method that recursively calls itself is the core of IoC; it really is that simple.

What This Demo Is Missing

Real-world IoC frameworks like Autofac or Microsoft.Extensions.DependencyInjection do much more on top of this foundation:

  • Scoped Lifetime: For example, sharing the same instance within a single HTTP request and destroying it when the request ends. I only have Transient and Singleton.
  • Thread Safety: None of my Dictionaries are locked; concurrent Resolve operations across multiple threads will cause issues.
  • Circular Dependency Detection: If A depends on B, and B depends on A, my code will result in a stack overflow. A proper framework would throw an explicit error.
  • IDisposable Management: When the container is disposed, it should also dispose of any IDisposable objects it created.

However, as a demo for understanding the principles, it is sufficient. The skeleton consists of just over 50 lines of code.

This content is automatically translated to English. View Original