.NET Console Application with injectable commands

.NET Console Application with injectable commands

Console applications are alive and kicking. Setting them up might be a bit hard. In this article I'll explore how to create a .NET console application that provides commands using the new System.CommandLine package. This will provide arguments to command mapping out of the box. I'll be showing how to combine it with dependency injection for even more power ⚡.

[outline]

Goals

We want to create a CLI application with the following goals:

We're making a CLI, so what's a better way to describe it than showing what the --help should look like?

Description:
  Weather information using a very unreliable weather service.

Usage:
  MyCli [command] [options]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  current   Gets the current temperature.
  forecast  Get the forecast. Almost always wrong.

Note: if you want to use command line argument when executing a dotnet run, you can use -- to feed the arguments to the application instead of the .NET CLI (so dotnet run -- --help in this case).

NuGet Packages

If you say .NET, you say NuGet packages. We'll be using the following packages:

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Options
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions
dotnet add package System.CommandLine

The System.CommandLine package is still in beta preview. I expect it to be released soon, but things might still change. It is used by the .NET dotnet CLI.

Project structure

I'm using the following project structure:

.
├── src/
│   └── MyCli/
│       ├── Commands/
│       │   ├── CurrentCommand.cs
│       │   └── ForcastCommand.cs
│       ├── Services/
│       │   ├── FakeWeatherService.cs
│       │   └── FakeWeatherServiceSettings.cs
│       └── Program.cs
└── MyCli.sln

Weather service

What is injection without a good service? Let's create a weather service that returns the temperature based on a randomizer:

namespace MyCli.Services;

class WeatherService()
{
    public WeatherServiceOptions Options { get; } = new WeatherServiceOptions();

    public Task<string> GetTemperature(string? city = null)
    {
        if (city == null) city = Options.DefaultCity;

        var report = $"In {city} it is now {Random.Shared.Next(-20, 40)} degrees celcius.";
        return Task.FromResult(report);
    }

    public Task<string[]> Forecast(int days, string? city = null)
    {
        if (city == null) city = Options.DefaultCity;

        var reports = new List<string>
        {
            $"Report for {city} for the next {days} days:"
        };

        for (var i = 0; i<days; i++)
        {
            var date = DateTime.Now.AddDays(i + 1).ToString("yyyy-MM-dd");
            var report = $"- {date}: {Random.Shared.Next(-20, 40),3} degrees celcius.";
            reports.Add(report);
        }

        return Task.FromResult(reports.ToArray());
    }
}

class WeatherServiceOptions
{
    public string DefaultCity { get; set; } = "Amsterdam, NLD";

    public int DefaultForecastDays { get; set; } = 5;
}

Commands

Commands are implementations of the System.CommandLine.Command class. To make them injectable, we create classes that are derived from the Command class (see dependency injection section).

Current Temperature Command

To get our current temperature command, we'll need to do the following:

Now the implementation is very easy:

using MyCli.Services;
using System.CommandLine;

namespace MyCli.Commands;

class CurrentCommand : Command
{
    private readonly WeatherService _weather;

    public CurrentCommand(WeatherService weather) : base("current", "Gets the current temperature.")
    {
        _weather = weather ?? throw new ArgumentNullException(nameof(weather));

        var cityOption = new Option<string>("--city", () => _weather.Options.DefaultCity, "The city.");

        AddOption(cityOption);

        this.SetHandler(Execute, cityOption);
    }

    private async Task Execute(string city)
    {
        var report = await _weather.GetTemperature(city);
        Console.WriteLine(report);
    }
}

What I like about the setup is that we can add optional arguments with defaults. Here we get the default value from an object from our dependency injection. When we do a current --help, we can a nice description and the actual injected value:

Description:
  Gets the current temperature.

Usage:
  MyCli current [options]

Options:
  --city <city>   The city. [default: Amsterdam, NLD]
  -?, -h, --help  Show help and usage information

Forecast Command

The same goes for the forecast command, but now we have 2 options: --city and --days.

using MyCli.Services;
using System.CommandLine;

namespace MyCli.Commands;

class ForecastCommand : Command
{
    private readonly WeatherService _weather;

    public ForecastCommand(WeatherService weather) : base("forecast", "Get the forecast. Almost always wrong.")
    {
        _weather = weather;

        var cityOption = new Option<string>("--city", ()=> _weather.Options.DefaultCity, "The city.");
        var daysOption = new Option<int>("--days", () => _weather.Options.DefaultForecastDays, "Number of days.");

        AddOption(cityOption);
        AddOption(daysOption);

        this.SetHandler(Execute, cityOption, daysOption);
    }

    private async Task Execute(string city, int days)
    {
        var report = await _weather.Forecast(days, city);
        foreach (var item in report)
        {
            Console.WriteLine(item);
        }
    }
}

Dependency injection

Now, let's tie it all together using dependency injection. We need to do the following:

This leads to the following Program code:

using Microsoft.Extensions.DependencyInjection;
using MyCli.Commands;
using MyCli.Services;
using System.CommandLine;

static void ConfigureServices(IServiceCollection services)
{
    // add commands:
    services.AddTransient<Command, CurrentCommand>();
    services.AddTransient<Command, ForecastCommand>();

    // add services:
    services.AddTransient<WeatherService>();
}

// create service collection
var services = new ServiceCollection();
ConfigureServices(services);

// create service provider
using var serviceProvider = services.BuildServiceProvider();

// entry to run app
var rootCommand = new RootCommand("Weather information using a very unreliable weather service.");
serviceProvider
    .GetServices<Command>()
    .ToList()
    .ForEach(rootCommand.AddCommand);

await rootCommand.InvokeAsync(args);

To make dependency injection work, we do a GetServices to retrieve all the commands and add them to the root command.

Final thoughts

And that's all: now you have a CLI that supports commands and a --help feature out of the box!

The code is on GitHub, so check it out: code gallery / 4. .NET Console Application with injectable commands.

Changelog