System.CommandLine is the official .NET library that provides common functionality for command-line applications. This includes features like argument parsing, automatic help text generation, tab autocomplete, suggestions, corrections, sub-commands, user cancellation, and much more. Many official .NET tools are built on top of System.CommandLine, including the .NET CLI, Kiota, Tye, numerous Azure tools, and other .NET additional tools.
Despite the fact that the library has been in preview for several years, its use in numerous .NET CLI programs inspires confidence regarding its maturity. The public C# API, though not final, is intuitive, the documentation is okay, and many examples and tests in the GitHub repository can assist you in getting started.
However, there is one aspect of System.CommandLine that I dislike: its lack of built-in support for dependency injection, like in any good modern .NET application. It makes it quite difficult to unit test and introduce decoupling in general.
To be fair, a service provider is indeed present within System.CommandLine. This provider implements IServiceProvider
and can be accessed through the InvocationContext.BindingContext.ServiceProvider
property. But upon closer inspection, you'll notice that this is not your typical, modern, fully-featured service provider. In fact, it's a pseudo service provider backed by a simple dictionary of factories (Dictionary<Type, Func<IServiceProvider, object?>>
). Its functionality is severely limited, and it's not exactly user-friendly when it comes to service registration. Don't take my word for it, make your own idea by reading the documentation.
Fortunately, System.CommandLine (in its current version, 2.0.0-beta4.22272.1
) is highly extensible. In this blog post, I'll show you how to integrate true dependency injection using the official Microsoft.Extensions.DependencyInjection NuGet package.
Please note that the System.CommandLine public C# API might change in the future, making this blog post eventually obsolete. I'll try to keep it up-to-date if that happens.
Implementing true dependency injection with middlewares
System.CommandLine comes with a built-in chain of responsibility pattern. This feature allows us to incorporate custom behavior before and after the execution of our commands. Several handy defaults are included out of the box, such as automatic help generation, suggestions, error handling, and parsing. Each of these features is managed by its respective middleware, arranged in a specific sequence. You can also insert your own middleware using the CommandLineBuilder.AddMiddleware(middleware, order?)
method.
// This is the list of the default, built-in middlewares:
// https://github.com/dotnet/command-line-api/blob/2.0.0-beta4.22272.1/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs#L266
public static CommandLineBuilder UseDefaults(this CommandLineBuilder builder)
{
return builder
.UseVersionOption()
.UseHelp()
.UseEnvironmentVariableDirective()
.UseParseDirective()
.UseSuggestDirective()
.RegisterWithDotnetSuggest()
.UseTypoCorrections()
.UseParseErrorReporting()
.UseExceptionHandler()
.CancelOnProcessTermination();
}
Our objective here is to insert a middleware that enables us to register dependencies using Microsoft.Extensions.DependencyInjection and then make them accessible through InvocationContext.BindingContext
. Later, your command handlers will be able to access the registered dependencies.
To begin, let's install these three NuGet packages: System.CommandLine, System.CommandLine.NamingConventionBinder, and Microsoft.Extensions.DependencyInjection. Now, let's delve into the code:
using System.CommandLine.Invocation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace System.CommandLine.Builder;
internal static class DependencyInjectionMiddleware
{
public static CommandLineBuilder UseDependencyInjection(this CommandLineBuilder builder, Action<ServiceCollection> configureServices)
{
return UseDependencyInjection(builder, (_, services) => configureServices(services));
}
// This overload allows you to conditionally register services based on the command line invocation context
// in order to improve startup time when you have a lot of services to register.
public static CommandLineBuilder UseDependencyInjection(this CommandLineBuilder builder, Action<InvocationContext, ServiceCollection> configureServices)
{
return builder.AddMiddleware(async (context, next) =>
{
// Register our services in the modern Microsoft dependency injection container
var services = new ServiceCollection();
configureServices(context, services);
var uniqueServiceTypes = new HashSet<Type>(services.Select(x => x.ServiceType));
services.TryAddSingleton(context.Console);
await using var serviceProvider = services.BuildServiceProvider();
// System.CommandLine's service provider is a "fake" implementation that relies on a dictionary of factories,
// but we can still make sure here that "true" dependency-injected services are available from "context.BindingContext".
// https://github.com/dotnet/command-line-api/blob/2.0.0-beta4.22272.1/src/System.CommandLine/Invocation/ServiceProvider.cs
context.BindingContext.AddService<IServiceProvider>(_ => serviceProvider);
foreach (var serviceType in uniqueServiceTypes)
{
context.BindingContext.AddService(serviceType, _ => serviceProvider.GetRequiredService(serviceType));
// Enable support for "context.BindingContext.GetServices<>()" as in the modern dependency injection
var enumerableServiceType = typeof(IEnumerable<>).MakeGenericType(serviceType);
context.BindingContext.AddService(enumerableServiceType, _ => serviceProvider.GetServices(serviceType));
}
await next(context);
});
}
}
The key here is to create a proper ServiceProvider
and then ensure every registered service is accessible through the pseudo service provider. Now, we can use our UseDependencyInjection
extension method in our main program:
var helloCommand = new Command("hello");
helloCommand.Handler = CommandHandler.Create<Dependency, IConsole>((dependency, console) =>
{
console.WriteLine($"Hello {dependency.GetName()}!");
});
var rootCommand = new RootCommand { helloCommand };
var builder = new CommandLineBuilder(rootCommand).UseDefaults().UseDependencyInjection(services =>
{
// True dependency injection using Microsoft.Extensions.DependencyInjection
services.AddSingleton<Dependency>();
});
return builder.Build().Invoke(args);
In this program, we use the CommandHandler.Create<TDep1, ... TDepN>((dep1, ... depN) => { })
generic method provided by the System.CommandLine.NamingConventionBinder package. This method lets us pull instances registered in the pseudo service provider with ease. Because we've "forwarded" our registered services into this pseudo service provider, they're now easily accessible.
Automatic command options class binding and dependency-injected command options handler
With true dependency injection now at our disposal, we can take things a step further. We can introduce a pattern for handling command options that is completely separate and easy to test. To do this, we'll bring in a few interfaces and a new generic base Command<,>
class:
using System.CommandLine.NamingConventionBinder;
using Microsoft.Extensions.DependencyInjection;
namespace System.CommandLine;
public interface ICommandOptions
{
}
public interface ICommandOptionsHandler<in TOptions>
{
Task<int> HandleAsync(TOptions options, CancellationToken cancellationToken);
}
public abstract class Command<TOptions, TOptionsHandler> : Command
where TOptions : class, ICommandOptions
where TOptionsHandler : class, ICommandOptionsHandler<TOptions>
{
protected Command(string name, string description)
: base(name, description)
{
this.Handler = CommandHandler.Create<TOptions, IServiceProvider, CancellationToken>(HandleOptions);
}
private static async Task<int> HandleOptions(TOptions options, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
// True dependency injection happening here
var handler = ActivatorUtilities.CreateInstance<TOptionsHandler>(serviceProvider);
return await handler.HandleAsync(options, cancellationToken);
}
}
The goal here is to draw a clear line between:
- The declaration of the command and its CLI arguments (like the
--to
argument in the next example), - The options that represent the parsed arguments as a simple POCO (created by System.CommandLine.NamingConventionBinder),
- The options handler, which can handle that POCO with first-class cancellation support and dependency injection in its constructor.
Let's consider an example with a command that says hello to someone:
public class HelloCommand : Command<HelloCommandOptions, HelloCommandOptionsHandler>
{
// Keep the hard dependency on System.CommandLine here
public HelloCommand()
: base("hello", "Say hello to someone")
{
this.AddOption(new Option<string>("--to", "The person to say hello to"));
}
}
public class HelloCommandOptions : ICommandOptions
{
// Automatic binding with System.CommandLine.NamingConventionBinder
public string To { get; set; } = string.Empty;
}
public class HelloCommandOptionsHandler : ICommandOptionsHandler<HelloCommandOptions>
{
private readonly IConsole _console;
// Inject anything here, no more hard dependency on System.CommandLine
public HelloCommandOptionsHandler(IConsole console)
{
this._console = console;
}
public Task<int> HandleAsync(HelloCommandOptions options, CancellationToken cancellationToken)
{
this._console.WriteLine($"Hello {options.To}!");
return Task.FromResult(0);
}
}
Here's what the program looks like now:
var rootCommand = new RootCommand
{
new HelloCommand()
};
var builder = new CommandLineBuilder(rootCommand).UseDefaults().UseDependencyInjection(services =>
{
// Register your services here and use them in your DI-activated command handlers
// [...]
});
return builder.Build().Invoke(args);
Conclusion
You now have a clear separation of concerns and decoupling between your CLI arguments and the actual work being done. You can separately test the binding of the CLI arguments to your options POCO, and the options handler with easy dependency mocking. Not only that, but your main program looks clean and lean, giving you a better view of the command hierarchy.
I'd love to hear your thoughts on this approach, and if you're already using System.CommandLine, please share how you're applying it in your projects!
Top comments (0)