# .NET Console Application with injectable commands

**Date:** 2023-03-07  
**Author:** Kees C. Bakker  
**Categories:** .NET / C#  
**Tags:** Console Application  
**Original:** https://keestalkstech.com/net-console-application-with-injectable-commands/

![.NET Console Application with injectable commands](https://keestalkstech.com/wp-content/uploads/2023/03/william-daigneault-oWrZoAVOBS0-unsplash.jpg)

---

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:

- **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?

```txt
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:

```sh
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 (v2.0.10 at the time of writing) is now stable and is [used by the .NET `dotnet` CLI](https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/dotnet.csproj#L96).

## Project structure

I'm using the following project structure:

```txt
.
├── src/
│   └── MyCli/
│       ├── Commands/
│       │   ├── CurrentCommand.cs
│       │   └── ForecastCommand.cs
│       ├── Services/
│       │   ├── WeatherService.cs
│       │   └── WeatherServiceOptions.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:

```cs
using Microsoft.Extensions.Options;

namespace MyCli.Services;

class WeatherService(IOptions<WeatherServiceOptions> opts)
{
    public WeatherServiceOptions Options { get; } = opts.Value;

    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 Celsius.";
        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 Celsius.";
            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](#dependency-injection)).

### 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 `WeatherService`, as it does the actual work.
- Use the `WeatherService.`Options to get the default value for the `--city` option.
- Wire it together using `SetAction`. Inside the handler, you extract option values from the `ParseResult` using `GetValue`.

Now the implementation is very easy:

```cs
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")
        {
            Description = "The city.",
            DefaultValueFactory = _ => _weather.Options.DefaultCity
        };

        Options.Add(cityOption);

        this.SetAction(async (parseResult, cancellationToken) =>
        {
            var city = parseResult.GetValue(cityOption);
            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 `current --help`, we get a nice description with the *actual injected value*:

```txt
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`.

```cs
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")
        {
            Description = "The city.",
            DefaultValueFactory = _ => _weather.Options.DefaultCity
        };
        var daysOption = new Option<int>("--days")
        {
            Description = "Number of days.",
            DefaultValueFactory = _ => _weather.Options.DefaultForecastDays
        };

        Options.Add(cityOption);
        Options.Add(daysOption);

        this.SetAction(async (parseResult, cancellationToken) =>
        {
            var city = parseResult.GetValue(cityOption);
            var days = parseResult.GetValue(daysOption);
            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:

- Setup a `ServiceCollection` to store our dependencies.
- Build configuration with `AddEnvironmentVariables()` and bind `WeatherServiceOptions` to it.
- 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` 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 configuration from environment variables
    var configuration = new ConfigurationBuilder()
        .AddEnvironmentVariables()
        .Build();

    // configure WeatherServiceOptions from environment variables
    services.Configure<WeatherServiceOptions>(configuration);

    // 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.");

foreach (var command in serviceProvider.GetServices<Command>())
{
    rootCommand.Subcommands.Add(command);
}

return rootCommand.Parse(args).Invoke();
```

To make dependency injection work, we register the commands with the service collection and use `GetServices()` to retrieve them and add them to the root command. The `WeatherServiceOptions` is bound to the configuration so environment variables like `DefaultCity=Zwolle` or `DefaultForecastDays=10` will automatically override the defaults.

## 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](https://github.com/KeesCBakker/keestalkstech-code-gallery/tree/main/04.command-line-di-poc).

## Changelog

- 2026-07-19: Updated to .NET 10 and System.CommandLine 2.0 stable. Added environment variable injection support.
- 2024-11-30: converted the code to .NET 8 and added it to the [gallery](https://github.com/KeesCBakker/keestalkstech-code-gallery).
- 2023-07-03: initial article.
