ConsoleAppFramework v5 — Zero Overhead, Native AOT-compatible CLI Framework for C#

Yoshifumi Kawai
15 min readJun 14, 2024

--

We have released a completely new version of ConsoleAppFramework. It is a brand new framework that has been completely redesigned and reimplemented from scratch. With the design principles of “Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe”, it achieves overwhelming performance that outpaces others by a wide margin.

This benchmark is for cold startup without any warm-up, which we believe is the most relevant to actual usage in CLI applications. Compared to System.CommandLine, it’s 280 times faster! The amount of memory allocation is also 100 to 1000 times less than other frameworks (the 400B shown is almost entirely system allocation, so the framework itself is 0).

This performance is achieved by generating everything with Source Generators. For example, consider the following code:

using ConsoleAppFramework;

// args: ./cmd --foo 10 --bar 20
ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}"));

ConsoleAppFramework’s Source Generator analyzes the arguments of the lambda expression passed to Run and generates the Run method itself.

internal static partial class ConsoleApp
{
// Generate the Run method itself with arguments and body to match the lambda expression
public static void Run(string[] args, Action<int, int> command)
{
// code body
}
}

Normally, C#’s Source Generators are triggered by attributes given to classes or methods, but ConsoleAppFramework monitors method invocations and uses them as the key for generation. This idea is inspired by Rust’s macros. In Rust, there are classifications like Attribute-like macros and Function-like macros, and this approach can be considered a Function-like style.

The actual generated code in its entirety looks something like this:

internal static partial class ConsoleApp
{
public static void Run(string[] args, Action<int, int> command)
{
if (TryShowHelpOrVersion(args, 2, -1)) return;

var arg0 = default(int);
var arg0Parsed = false;
var arg1 = default(int);
var arg1Parsed = false;

try
{
for (int i = 0; i < args.Length; i++)
{
var name = args[i];

switch (name)
{
case "--foo":
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
arg0Parsed = true;
break;
}
case "--bar":
{
if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); }
arg1Parsed = true;
break;
}
default:
// omit...(case-insensitive compare codes)
ThrowArgumentNameNotFound(name);
break;
}
}
if (!arg0Parsed) ThrowRequiredArgumentNotParsed("foo");
if (!arg1Parsed) ThrowRequiredArgumentNotParsed("bar");

command(arg0!, arg1!);
}
catch (Exception ex)
{
Environment.ExitCode = 1;
if (ex is ValidationException or ArgumentParseFailedException)
{
LogError(ex.Message);
}
else
{
LogError(ex.ToString());
}
}
}

static partial void ShowHelp(int helpId)
{
Log("""
Usage: [options...] [-h|--help] [--version]

Options:
--foo <int> (Required)
--bar <int> (Required)
""");
}
}

It looks like straightforward and simple code without any twists, doesn’t it? That’s important! The simpler the code, the faster it is! Simple despite being a framework, that’s why it’s fast. There is no extraneous code, and all the processing is aggregated in the method body itself, achieving zero overhead as a framework and the same speed as optimized handwritten code.

CLI applications typically involve single-shot execution from a cold start, making dynamic code generation (IL.Emit or Expression.Compile) and caching (speeding up subsequent matching through ArrayPool or Dictionary generation) less effective. Creating those would add more overhead. On the other hand, using reflection directly is slow in itself. ConsoleAppFramework dramatically speeds up single-shot execution by inline-generating all the necessary processing.

With no reflection, it also has overwhelming affinity with Native AOT, eliminating any disadvantages of C# in terms of cold startup speed.

Another feature is that since everything, including the ConsoleApp class, is generated by Source Generators, there are absolutely no dependencies, including ConsoleAppFramework itself.

There are various situations for creating console applications. Sometimes it’s a large batch application with many dependencies, and other times it’s a tiny single-function command. When creating a small command, you wouldn’t want to add any additional dependencies at all. Adding a reference to Microsoft.Extensions.Hosting alone brings in dozens of dependent DLLs! With ConsoleAppFramework, there are zero dependencies, including itself.

The advantage of zero dependencies is obviously a smaller binary size. Especially with Native AOT, binary size is a concern, but with ConsoleAppFramework, the additional cost is nearly zero.

And of course, a single function is not enough for a framework, so the following features are implemented. The rich set of features should be on par with other frameworks.

  • SIGINT/SIGTERM(Ctrl+C) handling with gracefully shutdown via CancellationToken
  • Filter(middleware) pipeline to intercept before/after execution
  • Exit code management
  • Support for async commands
  • Registration of multiple commands
  • Registration of nested commands
  • Setting option aliases and descriptions from code document comment
  • System.ComponentModel.DataAnnotations attribute-based Validation
  • Dependency Injection for command registration by type and public methods
  • Microsoft.Extensions(Logging, Configuration, etc...) integration
  • High performance value parsing via ISpanParsable<T>
  • Parsing of params arrays
  • Parsing of JSON arguments
  • Help(-h|--help) option builder
  • Default show version(--version) option

The generated code is modularized and varies depending on the features used by the code, always generating the minimum code required to implement that feature. This allows it to balance functionality and performance. Additionally, every feature has been carefully tuned to run at the fastest possible speed, so even with all features enabled, it remains overwhelmingly fast compared to others.

As an aside, delegates do have an allocation for delegate generation. In other words, it’s not truly zero allocation and zero overhead. However, ConsoleAppFramework does provide a mechanism to achieve true zero allocation. Pass a static function as a function pointer as follows:

unsafe
{
ConsoleApp.Run(args, &Sum);
}

static void Sum(int x, int y) => Console.Write(x + y);

Then it generates a method body with a delegate* managed<> argument (it may not be familiar, but C# has a language feature called managed function pointers).

public static unsafe void Run(string[] args, delegate* managed<int, int, void> command)

Now it’s completely and indisputably zero allocation and zero overhead!

High-performance Value Conversion

What is the fastest way to convert a string to a C# value? For int, it’s int.TryParse, right? What about others? Int is hardcoded, so it's easy, but how do you make string -> T (or object) generic? It becomes a bit tricky, and in the past, TypeConverter was used. Of course, the performance is poor.

Alternatively, since JsonSerializer is now built-in, you could delegate it to that. Of course, the performance is not particularly good. Especially when considering cold startup, JsonSerializer requires caching, adding significant overhead for single-shot execution.

ConsoleAppFramework adopts IParsable and ISpanParsable. These were added in .NET 7 and use the static abstract interface added in C# 11.

public interface IParsable<TSelf> where TSelf : IParsable<TSelf>?
{
static abstract TSelf Parse(string s, IFormatProvider? provider);
static abstract bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(returnValue: false)] out TSelf result);
}

Finally, with C# 11, a generic “string -> value” conversion mechanism has been realized! ConsoleAppFramework adopts it without question as .NET 8/C# 12 is the minimum runtime requirement. New types introduced in .NET 8 such as Half and Int128, as well as user-defined types that implement IParsable<T>, can be used for high-performance processing!

However, for basic types like int, the Source Generator already knows it’s an int, so it directly executes int.TryParse.

As for value binding, it also supports params arrays and default values.

ConsoleApp.Run(args, (
[Argument]DateTime dateTime, // Argument
[Argument]Guid guidvalue, //
int intVar, // required
bool boolFlag, // flag
MyEnum enumValue, // enum
int[] array, // array
MyClass obj, // object
string optional = "abcde", // optional
double? nullableValue = null, // nullable
params string[] paramsArray
) => { });

C# 12 has just added the ability to use default values and params in lambda expressions, which is reflected here.

Defining with Document Comments

In the past, or in other frameworks, adding Description and Alias was done using attributes. However, assigning attributes to each parameter of a method, especially with quite long strings, makes the method significantly less readable.

So ConsoleAppFramework decided to utilize document comments.

class Commands
{
/// <summary>
/// Display Hello.
/// </summary>
/// <param name="message">-m, Message to show.</param>
public static void Hello(string message) => Console.Write($"Hello, {message}");
}

This becomes a command like:

Usage: [options...] [-h|--help] [--version]

Display Hello.

Options:
-m|--message <string> Message to show. (Required)

With document comments, it’s possible to maintain a natural appearance even with many arguments. Being able to take this approach is a strength of the Source Generator approach, as the .xml file is not needed and comments can be read directly from the code. (However, some hacks were needed to make document comments readable in all environments with Source Generators)

Adding Multiple Commands

ConsoleApp.Run is a shortcut for a single command, but it's also possible to add multiple commands and nested subcommands. For example, let's look at the generation example when the following configuration is made.

var app = ConsoleApp.Create();

app.Add("foo", () => { });
app.Add("foo bar", (int x, int y) => { });
app.Add("foo bar barbaz", (DateTime dateTime) => { });
app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { });

app.Run(args);

The Add in this code is expanded as follows. The Source Generator knows the types of all the lambda expressions being added, so it assigns them to fields with unique types.

partial struct ConsoleAppBuilder
{
Action command0 = default!;
Action<int, int> command1 = default!;
Action<global::System.DateTime> command2 = default!;
Func<string, global::System.Threading.CancellationToken, Task> command3 = default!;

partial void AddCore(string commandName, Delegate command)
{
switch (commandName)
{
case "foo":
this.command0 = Unsafe.As<Action>(command);
break;
case "foo bar":
this.command1 = Unsafe.As<Action<int, int>>(command);
break;
case "foo bar barbaz":
this.command2 = Unsafe.As<Action<global::System.DateTime>>(command);
break;
case "foo baz":
this.command3 = Unsafe.As<Func<string, global::System.Threading.CancellationToken, Task>>(command);
break;
default:
break;
}
}
}

This prevents the need for arrays to hold Delegates and the reflection/boxing overhead of invoking them as Delegates.

In Run, a switch with constant strings is embedded to select the command from string[] args.

partial void RunCore(string[] args)
{
if (args.Length == 0)
{
ShowHelp(-1);
return;
}
switch (args[0])
{
case "foo":
if (args.Length == 1)
{
RunCommand0(args, args.AsSpan(1), command0);
return;
}
switch (args[1])
{
case "bar":
if (args.Length == 2)
{
RunCommand1(args, args.AsSpan(2), command1);
return;
}
switch (args[2])
{
case "barbaz":
RunCommand2(args, args.AsSpan(3), command2);
break;
default:
RunCommand1(args, args.AsSpan(2), command1);
break;
}
break;
case "baz":
RunCommand3(args, args.AsSpan(2), command3);
break;
default:
RunCommand0(args, args.AsSpan(1), command0);
break;
}
break;
default:
ShowHelp(-1);
break;
}
}

The fastest way in C# to jump from a string to specific code is to use a switch with string constants. The expanded algorithm has been revised several times, and in C# 12, as Performance: faster switch over string objects · Issue #56374 · dotnet/roslyn, it first checks the length and then narrows down to a single character where the difference exists to match.

This is faster than matching from Dictionary<string, T> and has no initialization time or allocations, which is the strength of being able to leverage the C# compiler. Such processing can only be done with the Source Generator approach that outputs C# code itself. So it's absolutely the fastest.

DI, CancellationToken, and Lifetime

In addition to parameters that become valid as command parameters, arguments can also define types that you want to pass via DI (such as ILogger<T> or Option<T>) and special handling types such as ConsoleAppContext and CancellationToken.

Receiving via DI is effective in situations where the console application wants to share configuration files with ASP.NET projects. Forsuch cases, integration with Microsoft.Extensions.Hosting is possible.

Also, when CancellationToken is passed, lifetime management as a console application that hooks SIGINT/SIGTERM/SIGKILL (Ctrl+C) becomes active.

await ConsoleApp.RunAsync(args, async (int foo, CancellationToken cancellationToken) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
Console.WriteLine($"Foo: {foo}");
});

The above code is expanded as follows:

using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout);
var arg0 = posixSignalHandler.Token;

await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken);

Using PosixSignalRegistration added in .NET 6, it hooks SIGINT/SIGTERM/SIGKILL and cancels the CancellationToken. At the same time, it suppresses immediate termination (normally pressing Ctrl + C causes an immediate Abort, but it no longer Aborts).

This leaves room for the application to properly handle the CancellationToken.

However, if the CancellationToken is not handled, it simply ignores the termination command, which is troublesome in itself, so a forced termination timeout is set. By default, it is set to 5 seconds, but this can be freely changed with the ConsoleApp.Timeout property. If you want to turn off forced termination, specify ConsoleApp.Timeout = Timeout.InfiniteTimeSpan.

Task.WaitAsync is from .NET 6. In addition to passing a TimeSpan, it’s also possible to pass a CancellationToken, allowing conditions such as firing WaitAsync after PosixSignalRegistration fires, then after a timeout, rather than a simple few seconds later.

Filter Pipeline

ConsoleAppFramework adopts Filters as a mechanism to hook before and after execution. Also known as the middleware pattern, it’s a pattern often seen in languages that support async/await.

internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // ctor needs `ConsoleAppFilter next` and call base(next)
{
// implement InvokeAsync as filter body
public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
{
try
{
/* on before */
await Next.InvokeAsync(context, cancellationToken); // invoke next filter or command body
/* on after */
}
catch
{
/* on error */
throw;
}
finally
{
/* on finally */
}
}
}

This design pattern is truly excellent, and if you need to provide a mechanism to hook execution, I highly recommend adopting this pattern. If async/await existed in the GoF era, it would have been included as an important design pattern.

The README introduces logging execution time, customizing ExitCode, prohibiting multiple executions, and authentication processing as things that can be done with filters. The wonderfulness of being able to realize various processes with a single Task InvokeAsync.

There are various approaches to designing filters, but ConsoleAppFramework chose the method that yields the highest performance. By receiving Next in the constructor and determining all the filters to be used statically at code generation time (dynamic addition is not allowed), everything is embedded and assembled.

app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();

// The above code will generate the following code:

sealed class Command0Invoker(string[] args, Action command) : ConsoleAppFilter(null!)
{
public ConsoleAppFilter BuildFilter()
{
var filter0 = new NopFilter(this);
var filter1 = new NopFilter(filter0);
var filter2 = new NopFilter(filter1);
var filter3 = new NopFilter(filter2);
var filter4 = new NopFilter(filter3);
return filter4;
}

public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
{
return RunCommand0Async(context.Arguments, args, command, context, cancellationToken);
}
}

This avoids intermediate array allocations and lambda capture allocations, with only the number of filters + 1 (wrapping the method body) as the additional cost. Also, if the return value Task completes synchronously, something equivalent to Task.Completed is used, so there’s no need to make it a ValueTask.

Writing code that only receives Next in the constructor and passes it to base has become easy thanks to primary constructors in C# 12.

Command-line Argument Syntax

Apart from being passed to string[] args as space-separated, command-line arguments are completely free. It's somewhat assumed that -- or - are parameter identifiers, but in reality, anything goes, and in Windows, even / is often used.

That said, there are some standard rules to some extent. The most well-known are probably the POSIX standard and its extension, the GNU Coding Standards. ConsoleAppFramework also follows the POSIX standard to some extent and includes the --version and --help defined in the GNU Coding Standards as built-in options. The names are also --lower-kebab-case by default.

“To some extent” means that it doesn’t fully conform to the standard. Whether it’s standards or traditional conventions, not a few old rules are unacceptable from a modern perspective. For example, distinguishing between -x and -X to have different behaviors is an absolute no-no. Or even widely used practices like bundling, where -fdx is interpreted as -f, -d, -x, are not very good in my opinion. Bundling is also problematic in terms of performance as it complicates the parsing process.

Since ConsoleAppFramework prioritizes performance, it does not adopt rules that may cause performance issues. It is designed to not distinguish between uppercase and lowercase, but since case-insensitive matching is performed after lowercase matching first, there is no practical performance degradation.

Looking at Overview of System.CommandLine command-line syntax — .NET | Microsoft Learn, it’s clear that System.CommandLine allows for quite flexible syntax interpretation. That’s a very good thing! It’s a good thing, but if it causes performance degradation, it’s a problem. And in fact, as evident from the benchmark results, the performance of System.CommandLine is very poor. This is unacceptable.

The wandering System.CommandLine seems to be decomposed again and changing its implementation. With Resetting System.CommandLine, it aims to have a small core as a POSIX standard parser adopted as standard in .NET 9 or .NET 10.

Even if they are adopted as standard, from a performance perspective, they will absolutely never surpass ConsoleAppFramework.

Compatibility with v4

Breaking changes! Not shying away from breaking changes is a good thing, it doesn’t hinder innovation, it’s necessary to remain cutting-edge. Running at the forefront of C# is also part of Cysharp’s identity. At the same time, of course, it’s a huge inconvenience. This change from v4 to v5 is like the change from .NET Framework to .NET Core, or from ASP.NET to ASP.NET Core, so it can’t be helped, it was an absolutely necessary change…

However, in reality, it hasn’t changed that much. The name conversion logic (lower-kebab-case) uses the same logic, so there’s no concern about names going out of sync. It’s just a matter of mapping the method names that cause compile errors. That happens quite often, right?

var app = ConsoleApp.Create(args); app.Run(); -> var app = ConsoleApp.Create(); app.Run(args);
app.AddCommand/AddSubCommand -> app.Add(string commandName)
app.AddRootCommand -> app.Add("")
app.AddCommands<T> -> app.Add<T>
app.AddSubCommands<T> -> app.Add<T>(string commandPath)
app.AddAllCommandType -> NotSupported(use Add<T> manually)
[Option(int index)] -> [Argument]
[Option(string shortName, string description)] -> Xml Document Comment
ConsoleAppFilter.Order -> NotSupported(global -> class -> method declrative order)
ConsoleAppOptions.GlobalFilters -> app.UseFilter<T>

Overall, I think the specification changes can be considered as simplifications, in other words, “improvements”.

Also, not relying on Microsoft.Extensions.Hosting by default is a big difference, but it can be resolved by adding one line. Riding on top of Hosting means using the ServiceProvider generated by Hosting, that's all. In reality, there's also Lifetime management, but ConsoleAppFramework handles that on its own, so in practical terms, there's no difference as long as you pass the ServiceProvider for DI.

using var host = Host.CreateDefaultBuilder().Build(); // use using for host lifetime
ConsoleApp.ServiceProvider = host.ServiceProvider;

In v4, ConsoleAppBase had to be inherited, but in v5, POCO is sufficient. Instead, please receive ConsoleAppContext and CancellationToken via constructor injection. This has also become less troublesome thanks to primary constructors in C# 12. This is another reason for abandoning the mechanism that requires a base class.

True Incremental Generator

Incremental Generators, if you just create them without any consideration, don’t actually become Incremental.

The first thing to do is to make it visible whether it is Incremental or not. Normally, the internal state is completely invisible when running, so it’s important to make it possible to check the state in unit tests. For example, a unit test like this is written.

    [Fact]
public void RunLambda()
{
var step1 = """
using ConsoleAppFramework;

ConsoleApp.Run(args, int () => 0);
""";

var step2 = """
using ConsoleAppFramework;

ConsoleApp.Run(args, int () => 100); // body change

Console.WriteLine("foo"); // unrelated line
""";

var step3 = """
using ConsoleAppFramework;

ConsoleApp.Run(args, int (int x, int y) => 100); // change signature

Console.WriteLine("foo");
""";

var reasons = CSharpGeneratorRunner.GetIncrementalGeneratorTrackedStepsReasons("ConsoleApp.Run.", step1, step2, step3);

reasons[0][0].Reasons.Should().Be("New");
reasons[1][0].Reasons.Should().Be("Unchanged");
reasons[2][0].Reasons.Should().Be("Modified");

VerifySourceOutputReasonIsCached(reasons[1]);
VerifySourceOutputReasonIsNotCached(reasons[2]);
}

When you run the Driver with the trackIncrementalGeneratorSteps: true option for an Incremental Generator, the state of each step becomes visible. IncrementalStepRunReason has states like New, Unchanged, Modified, Cached, and Removed, and if the step before the final output is Unchanged or Cached, the output processing is skipped.

In the above unit test, step2 only has changes in parts that don’t affect the output code, so it’s Unchanged. So the final stage was Cached. step3 has changes that require regeneration, so it’s Modified and runs through the source code generation process.

IncrementalStepRunReason can be retrieved from TrackedSteps, but it's a bit too hard to read as is, so it's formatted to make it easier to check, which is the GetIncrementalGeneratorTrackedStepsReasons utility method.

public static (string Key, string Reasons)[][] GetIncrementalGeneratorTrackedStepsReasons(string keyPrefixFilter, params string[] sources)
{
var parseOptions = new CSharpParseOptions(LanguageVersion.CSharp12); // 12
var driver = CSharpGeneratorDriver.Create(
[new ConsoleAppGenerator().AsSourceGenerator()],
driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true))
.WithUpdatedParseOptions(parseOptions);

var generatorResults = sources
.Select(source =>
{
var compilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source, parseOptions));
driver = driver.RunGenerators(compilation);
return driver.GetRunResult().Results[0];
})
.ToArray();

var reasons = generatorResults
.Select(x => x.TrackedSteps
.Where(x => x.Key.StartsWith(keyPrefixFilter) || x.Key == "SourceOutput")
.Select(x =>
{
if (x.Key == "SourceOutput")
{
var values = x.Value.Where(x => x.Inputs[0].Source.Name?.StartsWith(keyPrefixFilter) ?? false);
return (
x.Key,
Reasons: string.Join(", ", values.SelectMany(x => x.Outputs).Select(x => x.Reason).ToArray())
);
}
else
{
return (
Key: x.Key.Substring(keyPrefixFilter.Length),
Reasons: string.Join(", ", x.Value.SelectMany(x => x.Outputs).Select(x => x.Reason).ToArray())
);
}
})
.OrderBy(x => x.Key)
.ToArray())
.ToArray();

return reasons;
}

It’s a mess and hard to understand, meaning that TrackedSteps itself is really hard to understand as is. Since TrackedSteps is an ImmutableDictionary, the enumeration order is random and hard to check, so I added numbering and sorting. Also, when multiple RegisterSourceOutputs are running (ConsoleAppFramework has two types: Run-based and Builder-based), it becomes confusing when they get mixed up, so I added filtering by keyPrefix.

Summary

Originally, ConsoleAppFramework was unique among Cysharp’s product lines in that it didn’t prioritize performance. It was built around the concept of integrating with Hosting, which was rare at the time, to create a CLI framework, and achieved some success. There were a few revisions that made Help richer and allowed writing in a Minimal API-like style, but the clunkiness became noticeable.

In particular, Cocona is a truly excellent library that was influenced by ConsoleAppFramework while offering more flexibility and powerful features. At this rate, ConsoleAppFramework would be just an inferior version, which was a concern. It’s painful to not be able to recommend it with confidence as the best. After all, the creator of Cocona is a colleague at Cysharp…

So this time, while taking influence from some of Cocona’s APIs (like [Argument]), I strived to make it a framework with a completely different character. As explained in the parsing section, ConsoleAppFramework v5 sacrifices some flexibility for performance, so if you need rich functionality, I recommend using System.CommandLine or Cocona.

Also, from a performance perspective, the longer the actual execution time, the less the framework overhead matters. If the processing takes 10 minutes, 1 minute, or even 10 seconds, whether the framework portion takes 1ms or 50ms is like a margin of error. This is true even for JIT compilation, but in recent times with complaints about Native AOT and cold startup speed, it’s not something that can be dismissed outright, and it’s certainly better to be faster.

While the advantages of performance and zero dependencies are obvious, I believe it has also become a unique and interesting framework in terms of approach and design. Please give it a try! Of course, it’s also extremely practical, so you could consider it an essential library without hesitation!

https://github.com/Cysharp/ConsoleAppFramework

--

--

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.