DEV Community

Cover image for Develop Clean Command Line Applications with System.CommandLine. Clean CLI.
Oleksii Nikiforov
Oleksii Nikiforov

Posted on • Originally published at nikiforovall.github.io

Develop Clean Command Line Applications with System.CommandLine. Clean CLI.

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Initializes and configures CommandLineBuilder.
  2. Plugs IHostBuilder into CommandLineBuilder so it can be used later to build an invocation pipeline.
  3. Builds Parser based on the configured instance of CommandLineBuilder.
  4. Invokes Parser with arguments provided by Program.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);
}
Enter fullscreen mode Exit fullscreen mode

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;
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ 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.
Enter fullscreen mode Exit fullscreen mode
$ 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
   }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
   }
}
Enter fullscreen mode Exit fullscreen mode

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;
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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;
   }
}
Enter fullscreen mode Exit fullscreen mode

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.

migrate-command

Create todo list:

create-todolist-command

List commands:

list-command

See todo list details:

list-command

I hope you find this blog post useful. Take care!


Reference

Top comments (0)