My preferred .NET console stack

An opinionated view on the boilerplate starting point of any .NET console application

Published on Friday, 15 January 2021

There's type of application that has followed me since I learned to code in the mid-'80s, and that's the console application. For years they looked the same a Main(string[] args) and some naive inconsistent command line parser. That gradually improved with the adoption of various OSS helper libraries. In this post, I'll walk through what today is my alternative starting point to dotnet new console, a way that greatly reduces the boilerplate code needed for logging, parsing, and validation of arguments, letting me focus on the problem to solve and not the plumbing.

Templates

A convenient way to scaffold a new project is using the template function of .NET SDK CLI, it comes preloaded with several templates like console, classlib, etc., but beyond that, it's possible to create your own templates, which I've for my and your convenience created, so given .NET 5 SDK installed, easily yourself can try and take a look at everything discussed in this post.

Devlead Console Template

So let's get started with creating a new console application according to my opinionated recipe, .NET SDK Templates are distributed as NuGet packages and the canonical source for NuGet packages is NuGet.org, where I've published my template as Devlead.Console.Template. Templates are installed using the dotnet new command with --install packageId parameter, in this case:

dotnet new --install Devlead.Console.Template

dotnet new devleadconsole

With the template installed locally, we now have a new devleadconsole template at our disposal, to create our new console applications with according to me, essential dependencies and boilerplate code:

dotnet new devleadconsole -n MyConsoleApp

The above command will in the current directory result in the below folder structure

MyConsoleApp
    │   MyConsoleApp.csproj
    │   Program.cs
    │
    └───Commands
        │   ConsoleCommand.cs
        │
        ├───Settings
        │       ConsoleSettings.cs
        │
        └───Validation
                ValidateStringAttribute.cs

MyConsoleApp.csproj

The created project file will look something like below

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Spectre.Console" Version="0.37.0" />
    <PackageReference Include="Spectre.Cli.Extensions.DependencyInjection" Version="0.3.0" />
    <PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="5.0.0" />
    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
  </ItemGroup>
</Project>

let's step for step break it down

OutputType

OutputType with the value exe, indicates that this will be an executable.

TargetFramework

TargetFramework with the value net5.0, means that this will be compiled for/targeting .NET 5.

Nullable

Nullable with the value enable, enables the nullable reference types feature that was introduced with C# 8, making reference types non-nullable by default, basically moving many errors from being caught late at runtime, to be caught early at compile time.

TreatWarningsAsErrors

TreatWarningsAsErrors with the value true makes the compiler grumpier, it won't just break the build for compiler errors, but also for compiler warnings, combined with Nullable I personally believe code quality gets better from the start.

PackageReference(s)

Program.cs

The generated Program.cs uses the new C#9 Top-level statement pattern removing unnecessary ceremony code from the application, but what it does contain:

  1. using statements
  2. Creating dependency injection container
  3. Console logger registration
  4. Hooking up dependency injection container with Spectre.Console
  5. Spectre.Console command declaration
  6. Execute the application

Note: Top-level statement means as RunAsync returns a Task<int>, .NET 5 will automatically generate "Program" class and async Task<int> Main(string args) for you, removing the need to write a lot of boilerplate code.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Devlead.Console.Commands;
using Spectre.Console.Cli;
using Spectre.Cli.Extensions.DependencyInjection;

var serviceCollection = new ServiceCollection()
    .AddLogging(configure =>
            configure
                .AddSimpleConsole(opts => {
                    opts.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
                })
    );

using var registrar = new DependencyInjectionRegistrar(serviceCollection);
var app = new CommandApp(registrar);

app.Configure(
    config =>
    {
        config.ValidateExamples();

        config.AddCommand<ConsoleCommand>("console")
                .WithDescription("Example console command.")
                .WithExample(new[] { "console" });
    });

return await app.RunAsync(args);

ConsoleCommand.cs

ConsoleCommand.cs contains "just" your business code, Spectre.Console handles the heavy lifting of parsing and validating command-line arguments (based on provided settings class, more on that later in the post.), resolving constructor parameters using dependency injection, etc. Letting you focus on the domain and not the boilerplate code, resulting in a very similar experience to i.e. Azure Function or .NET Workers, enabling reuse of both patterns and code. Spectre.Console has support for both async and sync commands.

using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MyConsoleApp.Commands.Setting;
using Spectre.Console.Cli;

namespace MyConsoleApp.Commands
{
    public class ConsoleCommand : AsyncCommand<ConsoleSettings>
    {
        private ILogger Logger { get; }

        public override async Task<int> ExecuteAsync(CommandContext context, ConsoleSettings settings)
        {
            Logger.LogInformation("Mandatory: {Mandatory}", settings.Mandatory);
            Logger.LogInformation("Optional: {Optional}", settings.Optional);
            Logger.LogInformation("CommandOptionFlag: {CommandOptionFlag}", settings.CommandOptionFlag);
            Logger.LogInformation("CommandOptionValue: {CommandOptionValue}", settings.CommandOptionValue);
            return await Task.FromResult(0);
        }

        public ConsoleCommand(ILogger<ConsoleCommand> logger)
        {
            Logger = logger;
        }
    }
}

ConsoleSettings.cs

ConsoleSettings.cs contains the definition of what parameters each command has, if they're are mandatory/optional, positional and how they validated. It also contains metadata used for automatically generating help and error messages.

using System.ComponentModel;
using Devlead.Console.Commands.Validate;
using Spectre.Console.Cli;

namespace Devlead.Console.Commands.Setting
{
    public class ConsoleSettings : CommandSettings
    {
        [CommandArgument(0, "<mandatory>")]
        [Description("Mandatory argument")]
        public string Mandatory { get; set; } = string.Empty;

        [CommandArgument(1, "[optional]")]
        [Description("Optional argument")]
        public string? Optional { get; set; }

        [CommandOption("--command-option-flag")]
        [Description("Command option flag.")]
        public bool CommandOptionFlag { get; set; }

        [CommandOption("--command-option-value <value>")]
        [Description("Command option value.")]
        [ValidateString]
        public string? CommandOptionValue { get; set; }
    }
}

ValidateStringAttribute.cs

Spectre.Console can validate either by custom attributes on properties (see ConsoleSettings.CommandOptionValue for an example of that) or globally by overriding Validate() method on CommandSettings. The template ships with a sample ValidateStringAttribute that just validates the length of a string, but you can make it as advanced as you want.

using Spectre.Console;
using Spectre.Console.Cli;

namespace MyConsoleApp.Commands.Validation
{
    public class ValidateStringAttribute : ParameterValidationAttribute
    {
        public const int MinimumLength = 3;

        public ValidateStringAttribute() : base(errorMessage: null)
        {
        }

        public override ValidationResult Validate(ICommandParameterInfo parameterInfo, object? value)
            => (value as string) switch {
                { Length: >= MinimumLength }
                    => ValidationResult.Success(),

                { Length: < MinimumLength }
                    => ValidationResult.Error($"{parameterInfo?.PropertyName} ({value}) needs to be at least {MinimumLength} characters long."),

                _ => ValidationResult.Error($"Invalid {parameterInfo?.PropertyName} ({value}) specified.")
            };
    }
}

Result

GIF animation of Console experience

Conclusion

This is my opinionated happy path for doing .NET Console applications, feel free to let me know if you've got your own recipe for success, but must say I'm really happy how this combination lets me write console applications in the same way as I do my .NET workers, Azure Functions, ASP .NET Core, etc. ensuring consistency, less duplication and good reuse of both patterns and code. There's a LOT more to Spectre.Console than command-line parsing, to I hight recommend you check out all the other features it has to offer.

References