Tutorial: Use dependency injection in .NET

This tutorial shows how to use dependency injection (DI) in .NET. With Microsoft Extensions, DI is a first-class citizen where services are added and configured in an IServiceCollection. The IHost interface exposes the IServiceProvider instance, which acts as a container of all the registered services.

In this tutorial, you learn how to:

  • Create a .NET console app that uses dependency injection
  • Build and configure a Generic Host
  • Write several interfaces and corresponding implementations
  • Use service lifetime and scoping for DI

Prerequisites

  • .NET Core 3.1 SDK or later.
  • Familiarity with creating new .NET applications and installing NuGet packages.

Create a new console application

Using either the dotnet new command or an IDE new project wizard, create a new .NET console application named ConsoleDI.Example. Add the Microsoft.Extensions.Hosting NuGet package to the project.

Add interfaces

Add the following interfaces to the project root directory:

IOperation.cs

namespace ConsoleDI.Example
{
    public interface IOperation
    {
        string OperationId { get; }
    }
}

The IOperation interface defines a single OperationId property.

ITransientOperation.cs

namespace ConsoleDI.Example
{
    public interface ITransientOperation : IOperation
    {
    }
}

IScopedOperation.cs

namespace ConsoleDI.Example
{
    public interface IScopedOperation : IOperation
    {
    }
}

ISingletonOperation.cs

namespace ConsoleDI.Example
{
    public interface ISingletonOperation : IOperation
    {
    }
}

All of the subinterfaces of IOperation name their intended service lifetime. For example, "Transient" or "Singleton".

Add default implementation

Add the following default implementation for the various operations:

DefaultOperation.cs

using static System.Guid;

namespace ConsoleDI.Example
{
    public class DefaultOperation :
        ITransientOperation,
        IScopedOperation,
        ISingletonOperation
    {
        public string OperationId { get; } = NewGuid().ToString()[^4..];
    }
}

The DefaultOperation implements all of the named marker interfaces and initializes the OperationId property to the last four characters of a new globally unique identifier (GUID).

Add service that requires DI

Add the following operation logger object, which acts as a service to the console app:

OperationLogger.cs

using System;

namespace ConsoleDI.Example
{
    public class OperationLogger
    {
        private readonly ITransientOperation _transientOperation;
        private readonly IScopedOperation _scopedOperation;
        private readonly ISingletonOperation _singletonOperation;

        public OperationLogger(
            ITransientOperation transientOperation,
            IScopedOperation scopedOperation,
            ISingletonOperation singletonOperation) =>
            (_transientOperation, _scopedOperation, _singletonOperation) =
                (transientOperation, scopedOperation, singletonOperation);

        public void LogOperations(string scope)
        {
            LogOperation(_transientOperation, scope, "Always different");
            LogOperation(_scopedOperation, scope, "Changes only with scope");
            LogOperation(_singletonOperation, scope, "Always the same");
        }
            

        private static void LogOperation<T>(T operation, string scope, string message)
            where T : IOperation =>
            Console.WriteLine(
                $"{scope}: {typeof(T).Name,-19} [ {operation.OperationId}...{message,-23} ]");
    }
}

The OperationLogger defines a constructor that requires each of the aforementioned marker interfaces, that is; ITransientOperation, IScopedOperation, and ISingletonOperation. The object exposes a single method that allows the consumer to log the operations with a given scope parameter. When invoked, the LogOperations method logs each operation's unique identifier with the scope string and message.

Register services for DI

Update Program.cs with the following code:

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ConsoleDI.Example
{
    class Program
    {
        static Task Main(string[] args)
        {
            IHost host = CreateHostBuilder(args).Build();

            ExemplifyScoping(host.Services, "Scope 1");
            ExemplifyScoping(host.Services, "Scope 2");

            return host.RunAsync();
        }

        static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((_, services) =>
                    services.AddTransient<ITransientOperation, DefaultOperation>()
                            .AddScoped<IScopedOperation, DefaultOperation>()
                            .AddSingleton<ISingletonOperation, DefaultOperation>()
                            .AddTransient<OperationLogger>());

        static void ExemplifyScoping(IServiceProvider services, string scope)
        {
            using IServiceScope serviceScope = services.CreateScope();
            IServiceProvider provider = serviceScope.ServiceProvider;

            OperationLogger logger = provider.GetRequiredService<OperationLogger>();
            logger.LogOperations($"{scope}-Call 1 .GetRequiredService<OperationLogger>()");

            Console.WriteLine("...");

            logger = provider.GetRequiredService<OperationLogger>();
            logger.LogOperations($"{scope}-Call 2 .GetRequiredService<OperationLogger>()");

            Console.WriteLine();
        }
    }
}

Each services.Add{SERVICE_NAME} extension method adds, and potentially configures, services. We recommended that apps follow this convention. Place extension methods in the Microsoft.Extensions.DependencyInjection namespace to encapsulate groups of service registrations. Including the namespace portion Microsoft.Extensions.DependencyInjection for DI extension methods also:

  • Allows them to be displayed in IntelliSense without adding additional using blocks.
  • Prevents excessive using statements in the Program or Startup classes where these extension methods are typically called.

The app:

Conclusion

The app displays output similar to the following example:

Scope 1-Call 1 .GetRequiredService<OperationLogger>(): ITransientOperation [ 80f4...Always different        ]
Scope 1-Call 1 .GetRequiredService<OperationLogger>(): IScopedOperation    [ c878...Changes only with scope ]
Scope 1-Call 1 .GetRequiredService<OperationLogger>(): ISingletonOperation [ 1586...Always the same         ]
...
Scope 1-Call 2 .GetRequiredService<OperationLogger>(): ITransientOperation [ f3c0...Always different        ]
Scope 1-Call 2 .GetRequiredService<OperationLogger>(): IScopedOperation    [ c878...Changes only with scope ]
Scope 1-Call 2 .GetRequiredService<OperationLogger>(): ISingletonOperation [ 1586...Always the same         ]

Scope 2-Call 1 .GetRequiredService<OperationLogger>(): ITransientOperation [ f9af...Always different        ]
Scope 2-Call 1 .GetRequiredService<OperationLogger>(): IScopedOperation    [ 2bd0...Changes only with scope ]
Scope 2-Call 1 .GetRequiredService<OperationLogger>(): ISingletonOperation [ 1586...Always the same         ]
...
Scope 2-Call 2 .GetRequiredService<OperationLogger>(): ITransientOperation [ fa65...Always different        ]
Scope 2-Call 2 .GetRequiredService<OperationLogger>(): IScopedOperation    [ 2bd0...Changes only with scope ]
Scope 2-Call 2 .GetRequiredService<OperationLogger>(): ISingletonOperation [ 1586...Always the same         ]

From the app output, you can see that:

  • Transient operations are always different, a new instance is created with every retrieval of the service.
  • Scoped operations change only with a new scope, but are the same instance within a scope.
  • Singleton operations are always the same, a new instance is only created once.

See also