.NET Console Application with injectable commands
Published: 2023-03-07 04:11:57

---
title: .NET Console Application with injectable commands
canonical_url: https://keestalkstech.com/2023/03/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 ⚡.

2. [Goals](#goals)
3. [NuGet Packages](#nuget-packages)
4. [Project structure](#project-structure)
5. [Fake weather service](#fake-weather-service)
6. [Commands](#commands)  
   1. [Current Temperature Command](#current-temperature-command)  
   2. [Forecast Command](#forecast-command)
7. [Dependency injection](#dependency-injection)
8. [Final thoughts](#final-thoughts)

## [Goals](#goals)

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

* **System.CommandLine** — this is a [fairly new project](https://learn.microsoft.com/en-us/dotnet/standard/commandline/) by .NET that helps to create better CLI applications. It offers the ability to add _commands_, _arguments_ and _options_ to your application. It comes with a `--help` feature and it will do the command line argument mapping for you.
* **Dependency Injection** — why go anywhere without it? Dependency injection has made ASP.NET way more composable. [I wrote an entire article on how to add it to console applications as well](https://keestalkstech.com/2018/04/dependency-injection-with-ioptions-in-console-apps-in-net-core-2/). We'll be reusing some of the code.
* **Environment variable injection support** — some of the configuration should be overridable using environment variables.

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 fake 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](#nuget-packages)

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

```sh
Install-Package System.CommandLine -Version 2.0.0-beta4.22272.1  
Install-Package Microsoft.Extensions.Configuration -Version 7.0.0  
Install-Package Microsoft.Extensions.Configuration.EnvironmentVariables -Version 7.0.0  
Install-Package Microsoft.Extensions.DependencyInjection -Version 7.0.0  
Install-Package Microsoft.Extensions.DependencyInjection.Abstractions -Version 7.0.0  
Install-Package Microsoft.Extensions.Options -Version 7.0.1  
Install-Package Microsoft.Extensions.Options.ConfigurationExtensions -Version 7.0.0
```

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](https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/dotnet.csproj#L96).

## [Project structure](#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
```

## [Fake weather service](#fake-weather-service)

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

```cs
namespace MyCli.Services;

public class FakeWeatherServiceSettings
{
    public string DefaultCity { get; set; } = "Zwolle, NLD";

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

public class FakeWeatherService
{
    public FakeWeatherService(IOptions<FakeWeatherServiceSettings> settings)
    {
        Settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings));
    }

    public FakeWeatherServiceSettings Settings { get; }

    public Task<string> GetTemperature(string? city = null)
    {
        if (city == null) city = Settings.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 = Settings.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());
    }
}
```

## [Commands](#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](#dependency-injection)).

### [Current Temperature Command](#current-temperature-command)

To get our _current temperature_ command, we'll need to do the following:

* Call the base constructor with the _name_ and _description_ of the command. This will be used by the `--help` feature.
* Inject the `FakeWeatherService`, as it does the actual work.
* Use the `FakeWeatherService.Settings` to get the default value for the `--city` option.
* Map it all together using a `SetHandler`. The option in automatically mapped to the `city` parameter of the `Execute` method.

Now the implementation is very easy:

```cs
using MyCli.Services;
using System.CommandLine;

namespace MyCli.Commands;

class CurrentCommand : Command
{
    private readonly FakeWeatherService _weather;

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

        var cityOption = new Option<string>("--city", () => _weather.Settings.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](#forecast-command)

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

```cs
using Microsoft.Extensions.Options;
using MyCli.Services;
using System.CommandLine;

namespace MyCli.Commands;

class ForecastCommand : Command
{
    private readonly FakeWeatherService _weather;

    public ForecastCommand(FakeWeatherService weather) : base("forecast", "Get the forecast. Almost always wrong.")
    {
        _weather = weather ?? throw new ArgumentNullException(nameof(weather));

        var cityOption = new Option<string>("--city", ()=> _weather.Settings.DefaultCity, "The city.");
        var daysOption = new Option<int>("--days", () => _weather.Settings.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](#dependency-injection)

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

* Setup a `ServiceCollection` to store our dependencies.
* Setup the configuration to use environment variables and read them into our `WeatherServiceSettings` object.
* Add the commands `CurrentCommand` and `ForecastCommand` to the service collection.
* Add the `WeatherService` to the service collection.
* Create a `System.CommandLine.RootCommand` and tie it to the registered `Command` implementation.
* Invoke the root command with the given command line arguments.

This leads to the following`Program.cs` code:

```cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MyCli.Commands;
using MyCli.Services;
using System.CommandLine;

static void ConfigureServices(IServiceCollection services)
{
    // build config
    var configuration = new ConfigurationBuilder()
        .AddEnvironmentVariables()
        .Build();

    // settings
    services.Configure<FakeWeatherServiceSettings>(configuration.GetSection("Weather"));

    // add commands:
    services.AddTransient<Command, CurrentCommand>();
    services.AddTransient<Command, ForecastCommand>();

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

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

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

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

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](#final-thoughts)

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

I've added the code to GitHub, so check it out: [github.com/KeesCBakker/dotnet-cli-di-poc](https://github.com/KeesCBakker/dotnet-cli-di-poc)