Develop Clean Command Line Applications with System.CommandLine. Clean CLI.
Oleksii Nikiforov
Posted on June 6, 2021
TL;DR
You can use System.CommandLine
to build console applications. The blog post explains how to build one on top of Clean Architecture solution. You can check out the sample, it contains more information and source code: https://github.com/NikiforovAll/clean-cli-todo-example.
As a developer, I quite often want to create a console application to try things out. Usually, it works well, but I always find myself in an inconvenient position. The Program.cs
bloats in messy monster with actual useful nuggets of codes in between of code that works with args
and some plumbing code. For some tasks, it works, but for others, I would like to suggest something more manageable and clean.
Introduction to System.CommandLine
System.CommandLine
gives you a great experience by providing essential functionality, such as parsing, invocation, and rendering. Let's see how we can write a simple "Todo List" application with it.
Here is the simple console application that prints "help" provided automatically by the System.CommandLine
.
// Program.cs
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
var root = new RootCommand("Root command description");
root.Handler = CommandHandler.Create(() => root.Invoke("-h"));
var builder = new CommandLineBuilder(root);
var parser = builder.UseDefaults().Build();
await parser.InvokeAsync(args);
And the output:
$ dotnet run
app-name
Root command description
Usage:
app-name [options]
Options:
--version Show version information
-?, -h, --help Show help and usage information
The Command
composition defines the structure and the way your app looks and feels. You can assign ICommandHandler
to the command. It is used during the invocation phase and provides you model-binding functionality. I suggest you to go through https://github.com/dotnet/command-line-api/blob/main/docs/How-To.md and https://github.com/dotnet/command-line-api/blob/main/docs/Features-overview.md to get yourself more comfortable with this awesome library.
Let's move forward and see a slightly different version built on top of .NET Core Generic Host.
The code below performs the following:
- Initializes and configures
CommandLineBuilder
. - Plugs
IHostBuilder
intoCommandLineBuilder
so it can be used later to build an invocation pipeline. - Builds
Parser
based on the configured instance ofCommandLineBuilder
. - Invokes
Parser
with arguments provided byProgram.Main
method.
var parser = BuildCommandLine()
.UseHost(_ => Host.CreateDefaultBuilder(args), (builder) =>
{
builder.ConfigureServices((hostContext, services) =>
{
var configuration = hostContext.Configuration;
// register other dependencies here
})
.UseCommandHandler<ExampleCommand, ExampleCommand.Handler>()
}).UseDefaults().Build();
return await parser.InvokeAsync(args);
static CommandLineBuilder BuildCommandLine()
{
var root = new RootCommand();
root.AddCommand(new ExampleCommand());
return new CommandLineBuilder(root);
}
The benefit of this style is that you can easily understand how CLI application is composed and what are the dependencies.
As you may notice, we hooked up the command and corresponding handler via UseCommandHandler
. It allows us to resolve dependencies for command handlers.
public class ExampleCommand : Command
{
public ExampleCommand() : base(name: "example", "Example description") {}
public new class Handler : ICommandHandler
{
private readonly IMediator meditor;
public Handler(IMediator meditor) =>
this.meditor = meditor ?? throw new ArgumentNullException(nameof(meditor));
public async Task<int> InvokeAsync(InvocationContext context)
{
await this.meditor.Send(new ExampleQuery{});
return 0;
}
}
}
With this knowledge in mind, let's move further with something more interesting functionality to implement. I would like to show you how to use "Clean Architecture" approach together with CLI applications, therefore Clean CLI. I will use https://github.com/jasontaylordev/CleanArchitecture as an example of Clean Architecture project, please investigate sample code based before continue reading the blog post https://github.com/NikiforovAll/clean-cli-todo-example.
System.CommandLine ➕ Clean Architecture = Clean CLI
Our goal is to build "Todo List" application. 📃📝
Design goals:
- To use a CLI as UI for a Clean Architecture based solution.
- To design an application that clearly communicates (through code) implemented functionality (commands) and structural composition in general.
- To provide a first-class CLI interface user experience. Luckily,
System.CommandLine
helps with things such as "help text" generation and autocompletion.
Before going deeper into source code let's examine how to consume the todo-cli.
$ dotnet run -- -h
CleanCli.Todo.Console
Usage:
CleanCli.Todo.Console [options] [command]
Options:
--silent Disables diagnostics output
--version Show version information
-?, -h, --help Show help and usage information
Commands:
todolist Todo lists management
todoitem Todo items management
migrate Migrates database
$ dotnet run -- todolist -h
todolist
Todo lists management
Usage:
CleanCli.Todo.Console [options] todolist [command]
Options:
--silent Disables diagnostics output
-?, -h, --help Show help and usage information
Commands:
create Creates todo list
delete <id> Deletes todo list
get <id> Gets a todo list
list Lists all todo lists in the system.
$ dotnet run -- todolist create -h
create
Creates todo list
Usage:
CleanCli.Todo.Console [options] todolist create
Options:
-t, --title <title> Title of the todo list
--dry-run Displays a summary of what would happen if the given command line were run.
--silent Disables diagnostics output
-?, -h, --help Show help and usage information
From the code perspective it looks like this (full version):
var runner = BuildCommandLine()
.UseHost(_ => CreateHostBuilder(args), (builder) => builder
.UseEnvironment("CLI")
.UseSerilog()
.ConfigureServices((hostContext, services) =>
{
services.AddCustomSerilog();
var configuration = hostContext.Configuration;
services.AddCli(); // Dependencies defined by CLI project (this).
services.AddApplication(); // Register "Application" project.
services.AddInfrastructure(configuration); // Register "Infrastructure" project.
})
.UseCommandHandler<CreateTodoListCommand, CreateTodoListCommand.Handler>()
.UseCommandHandler<DeleteTodoListCommand, DeleteTodoListCommand.Handler>()
.UseCommandHandler<ListTodosCommand, ListTodosCommand.Handler>()
.UseCommandHandler<GetTodoListCommand, GetTodoListCommand.Handler>()
.UseCommandHandler<SeedTodoItemsCommand, SeedTodoItemsCommand.Handler>()
.UseCommandHandler<MigrateCommand, MigrateCommand.Handler>())
.UseDefaults().Build();
static CommandLineBuilder BuildCommandLine()
{
var root = new RootCommand();
root.AddCommand(BuildTodoListCommands());
root.AddCommand(BuildTodoItemsCommands());
root.AddCommand(new MigrateCommand());
root.AddGlobalOption(new Option<bool>("--silent", "Disables diagnostics output"));
root.Handler = CommandHandler.Create(() => root.Invoke("-h"));
return new CommandLineBuilder(root);
// omitted for brevity
}
As result, the project is plugged through DI to console application and all features provided by Application
project could be consumed from Console
project.
The application functionality is added as following:
// Application/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
return services;
}
}
// Infrastructure/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(ApplicationDbContext).Assembly.FullName)));
services.AddScoped<IApplicationDbContext>(provider => provider.GetService<ApplicationDbContext>());
services.AddScoped<IDomainEventService, DomainEventService>();
services.AddTransient<IDateTime, DateTimeService>();
return services;
}
}
Finally, we can add code that does actually something useful.
public class CreateTodoListCommand : Command
{
public IConsole Console { get; set; }
public CreateTodoListCommand() : base(name: "create", "Creates todo list")
{
this.AddOption(new Option<string>(new string[] { "--title", "-t" }, "Title of the todo list"));
}
public new class Handler : ICommandHandler
{
private readonly IMediator meditor;
public string Title { get; set; } // Conventional binding
public Handler(IMediator meditor) =>
this.meditor = meditor ?? throw new ArgumentNullException(nameof(meditor));
public async Task<int> InvokeAsync(InvocationContext context)
{
await this.meditor.Send(new CreateTodoListCommand { Title = this.Title });
return 0;
}
}
}
Underlying implementation from Application
project.
public class CreateTodoListCommand : IRequest<int>
{
public string Title { get; set; }
}
public class CreateTodoListCommandHandler : IRequestHandler<CreateTodoListCommand, int>
{
private readonly IApplicationDbContext context;
public CreateTodoListCommandHandler(IApplicationDbContext context) =>
this.context = context;
public async Task<int> Handle(
CreateTodoListCommand request,
CancellationToken cancellationToken)
{
var entity = new TodoList { Title = request.Title };
this.context.TodoLists.Add(entity);
await this.context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}
If you are interested in this approach, I encourage you to investigate the source code on your own.
Demo
Let's run migrate
command to create local todo.db and seed initial data.
Create todo list:
List commands:
See todo list details:
I hope you find this blog post useful. Take care!
Reference
Posted on June 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.