ConsoleAppFramework v5.3.0 — Enhanced DI Integration through Auto-generated Methods from NuGet References, and More

Yoshifumi Kawai
4 min readDec 17, 2024

--

I’ve made a relatively significant update to ConsoleAppFramework v5! For details about v5 itself, please refer to my previous article ConsoleAppFramework v5 — Zero Overhead, Native AOT-compatible CLI Framework for C#. While v5 introduced some interesting concepts that were well-received, it did sacrifice some usability aspects. This update addresses those issues, and I believe it has significantly improved the overall user experience!

Disabling Automatic Name Conversion

By default, command names and option names are automatically converted to kebab-case. While this follows standard command-line tool naming conventions, it might feel cumbersome when using the framework for internal applications or batch file creation. Therefore, we’ve added the ability to disable this conversion at the assembly level.

using ConsoleAppFramework;

[assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]

var app = ConsoleApp.Create();
app.Add<MyProjectCommand>();
app.Run(args);

public class MyProjectCommand
{
public void Execute(string fooBarBaz)
{
Console.WriteLine(fooBarBaz);
}
}

The automatic conversion is disabled by [assembly: ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion = true)]. In this example, the command becomes ExecuteCommand --fooBarBaz.

From an implementation perspective, while many Source Generators use AdditionalFiles with JSON or custom format files (like BannedSymbols.txt in BannedApiAnalyzers) to provide configuration, using files can be quite cumbersome. For setting just one or two boolean values, using assembly attributes is the most straightforward approach.

The implementation can pull this from CompilationProvider using Assembly.GetAttributes:

var generatorOptions = context.CompilationProvider.Select((compilation, token) =>
{
foreach (var attr in compilation.Assembly.GetAttributes())
{
if (attr.AttributeClass?.Name == "ConsoleAppFrameworkGeneratorOptionsAttribute")
{
var args = attr.NamedArguments;
var disableNamingConversion = args.FirstOrDefault(x => x.Key == "DisableNamingConversion").Value.Value as bool? ?? false;
return new ConsoleAppFrameworkGeneratorOptions(disableNamingConversion);
}
}

return new ConsoleAppFrameworkGeneratorOptions(DisableNamingConversion: false);
});

By combining this with Source from other SyntaxProviders, we can reference the attribute values during generation.

ConfigureServices/Logging/Configuration

ConsoleAppFramework v5 had a constraint where it couldn’t generate code dependent on specific libraries due to its zero-dependency principle. This meant that integrating with DI required manually building the ServiceProvider, adding an extra step for users. To address this, we’ve added functionality that analyzes NuGet DLL references and makes the ConfigureServices method available on ConsoleAppBuilder when Microsoft.Extensions.DependencyInjectionis referenced.

var app = ConsoleApp.Create()
.ConfigureServices(service =>
{
service.AddTransient<MyService>();
});

app.Add("", ([FromServices] MyService service, int x, int y) => Console.WriteLine(x + y));

app.Run(args);

This provides a new experience where the framework itself maintains zero dependencies while still being able to generate library-dependent code. This is achieved by pulling from MetadataReferencesProvider and feeding it into the generation process:

var hasDependencyInjection = context.MetadataReferencesProvider
.Collect()
.Select((xs, _) =>
{
var hasDependencyInjection = false;

foreach (var x in xs)
{
var name = x.Display;
if (name == null) continue;

if (!hasDependencyInjection && name.EndsWith("Microsoft.Extensions.DependencyInjection.dll"))
{
hasDependencyInjection = true;
continue;
}

// etc...
}

return new DllReference(hasDependencyInjection, hasLogging, hasConfiguration, hasJsonConfiguration, hasHost);
});

context.RegisterSourceOutput(hasDependencyInjection, EmitConsoleAppConfigure);

Reference analysis is performed for multiple dependencies. For example, if Microsoft.Extensions.Loggingis referenced, ConfigureLogging becomes available. This allows for clean integration with ZLogger:

// Package Import: ZLogger
var app = ConsoleApp.Create()
.ConfigureLogging(x =>
{
x.ClearProviders();
x.SetMinimumLevel(LogLevel.Trace);
x.AddZLoggerConsole();
x.AddZLoggerFile("log.txt");
});

app.Add<MyCommand>();
app.Run(args);

// inject logger to constructor
public class MyCommand(ILogger<MyCommand> logger)
{
public void Echo(string msg)
{
logger.ZLogInformation($"Message is {msg}");
}
}

Loading configuration from appsettings.json is now a common pattern, and when Microsoft.Extensions.Configuration.Json is referenced, ConfigureDefaultConfiguration becomes available. This automatically performs SetBasePath(System.IO.Directory.GetCurrentDirectory()) and AddJsonFile("appsettings.json", optional: true) (additional configuration via Action is possible, and ConfigureEmptyConfiguration is also available).

This makes it simple to read configuration, bind it to classes, and inject it into commands:

// Package Import: Microsoft.Extensions.Configuration.Json
var app = ConsoleApp.Create()
.ConfigureDefaultConfiguration()
.ConfigureServices((configuration, services) =>
{
// Package Import: Microsoft.Extensions.Options.ConfigurationExtensions
services.Configure<PositionOptions>(configuration.GetSection("Position"));
});

app.Add<MyCommand>();
app.Run(args);

// inject options
public class MyCommand(IOptions<PositionOptions> options)
{
public void Echo(string msg)
{
ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}");
}
}

For those wanting to build with Microsoft.Extensions.Hosting, ToConsoleAppBuilder becomes available when Microsoft.Extensions.Hosting is referenced:

// Package Import: Microsoft.Extensions.Hosting
var app = Host.CreateApplicationBuilder()
.ToConsoleAppBuilder();

Additionally, the configured IServiceProvider is now automatically disposed after Run or RunAsync completes.

RegisterCommands from Attribute

While commands previously required Add or Add<T>, we've added functionality to automatically add commands through class attributes:

[RegisterCommands]
public class Foo
{
public void Baz(int x)
{
Console.Write(x);
}
}

[RegisterCommands("bar")]
public class Bar
{
public void Baz(int x)
{
Console.Write(x);
}
}

These are automatically added:

var app = ConsoleApp.Create();

// Commands:
// baz
// bar baz
app.Run(args);

You can still use Add and Add<T> alongside these attribute-based registrations.

Initially, we planned to allow arbitrary attributes, but due to IncrementalGenerator API limitations, we're restricted to the fixed RegisterCommands attribute. Inheritance is also not supported.

Since the v5 release, we’ve continued making improvements, including allowing filters to be defined in external assemblies and optimizing the Incremental Generator implementation for better performance. The framework has evolved into an excellent solution!

By the way, regarding System.CommandLine, they announced Resetting System.CommandLine in March due to ongoing issues. As expected, there hasn’t been much progress. This was predictable, and it’s better not to have high expectations. Using ConsoleAppFramework is a solid choice moving forward.

--

--

Yoshifumi Kawai
Yoshifumi Kawai

Written by Yoshifumi Kawai

a.k.a. neuecc. Creator of UniRx, UniTask, MessagePack for C#, MagicOnion etc. Microsoft MVP for C#. CEO/CTO of Cysharp Inc. Live and work in Tokyo, Japan.

No responses yet