Generic host based console application

March 4th 2022 .NET Dependency Injection

The new console application template in .NET 6 is as minimal as possible thanks to top-level statements. That's great for simple applications, but what if you want to create a large console application with a sophisticated command-line interface?

You certainly do not want to write code yourself to parse command line arguments. There are several NuGet packages that already do this. The package I like best is McMaster.Extensions.CommandLineUtils. It's well documented and although none of the examples use top-level statements, they should not be too difficult to customize if you want to.

Parsing command-line arguments is probably not the only feature you need in a large console application. There are other cross-cutting concerns that are well solved in other templates but are not available in the console application template, for example: dependency injection, logging, reading configuration files.

Fortunately, you can get them as well if you use the generic host. For this, you need to install 2 more NuGet packages: Microsoft.Extensions.Hosting and McMaster.Extensions.Hosting.CommandLine. Then you can change your Program.cs as follows:

Host.CreateDefaultBuilder()
    .ConfigureServices((context, services) =>
    {
        services.AddOptions<Config>()
            .Bind(context.Configuration.GetSection(nameof(Config)))
            .ValidateDataAnnotations();

        services
            .AddSingleton(provider => provider
                .GetRequiredService<IOptionsSnapshot<Config>>()
                .Value);
    })
    .RunCommandLineApplicationAsync<AppCommand>(args);

As you can see in the code snippet above, I can use the generic host to configure services for dependency injection as we are used to from other templates. In this case, I use it in combination with the options pattern to read the configuration from the appSettings.json file. When you add this file to the project, you need to include it as content in the build:

<ItemGroup>
  <Content Include="appSettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </Content>
</ItemGroup>

There are two details about the options configuration that are worth mentioning:

  1. I call the ValidateDataAnnotations extension method to validate the configuration based on the attributes applied to the model class:

    internal class Config
    {
        [Required]
        public string Option1 { get; set; } = string.Empty;
    }
    
  2. After configuring the options, I add the resulting configuration as a singleton so that I can inject the Config class directly instead of wrapped in one of the option interfaces. I learned this pattern from a book on design patterns by Carl-Hugo Marcotte.

The AppCommand class is the root command. In the library documentation, this is usually the Program class. However, when you use top-level statements, this class is automatically generated, so you need to create a separate class with the OnExecute method and the Command attribute that acts as the command:

[Command(Description = "Options reader")]
[Subcommand(typeof(ReadCommand))]
internal class AppCommand
{
    public int OnExecute(
        ILogger<AppCommand> logger,
        IConsole console,
        CommandLineApplication app)
    {
        logger.LogInformation("OnExecute called.");

        console.WriteLine("You must specify a subcommand.");
        console.WriteLine();
        app.ShowHelp();
        return 1;
    }
}

In command classes, dependencies can be injected either in the constructor or in the OnExecute method. I chose the latter because I need them only in this method, so I can avoid a constructor and fields for the dependencies.

The Subcommand attribute points to the second command class that implements the actual functionality:

[Command(Description = "Print out options.")]
internal class ReadCommand
{
    public int OnExecute(
        ILogger<ReadCommand> logger,
        IConsole console,
        Config config)
    {
        logger.LogInformation("OnExecute called.");

        var json = JsonSerializer.Serialize(config);
        console.WriteLine(json);
        return 0;
    }
}

With only a single subcommand, this does not make much sense. It would be better to include the functionality directly in the root command. I did this because my application will end up supporting multiple commands.

You can see that several dependencies are injected that I have not configured:

  • IConsole and CommandLineApplication are configured by the library and provide access to the Console class and the application entry point, respectively.
  • The default ILogger is provided by the generic host. It can be further configured using its ConfigureLogging extension method.

You can view the full source code for the sample application in my GitHub repository.

The new .NET 6 console application template is great for simple cases. If you need more functionality, you can always extend it to your liking. In this post, I showed how to use the NuGet package McMaster.Extensions.Hosting.CommandLine to parse command line arguments and how to use the generic host to get access to dependency injection, configuration, logging and other standard features of . NET.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License