Links
Introduction
If you start to search for Console App demos or starting points for .NET development in general you will stumble upon a lot of similar content. It sill show you now to file-new-project the app and then (depending on the actuality) either using writing your code into a main method or into a seemingly empty file. Then you hit F5 or whatever and there you go. Although this will work basically it is not the way you should approach this kind of apps when done professionally.
The next question that arises here is if console applications are still viable project types in 2023. Yes, I say. There plenty examples of apps I wrote in the past year. One example area is when we write additional toolings either for internal or public use. Another one are containerized console apps which are mostly providing some sort of task that runs for while and should terminate after its job is done. I just recently developed a console app with my team which lives as an Azure Container Instance and gets called by a Logic App several time a day to import data into a database.
You can skip the next section if you already are familiar with the old days. If you don’t want to follow me on the painstaking intermediate steps quickly jump to the section “First version”.
The wrong approach
After this introduction it is now time to show you what you most often will see in the wild. To make things a little bit more repeatable I will not rely on any specific IDE but show you dotnet CLI code to generate the template.
The most basic approch will be:
dotnet new console -n ConsoleApp
This will create a directory ConsoleApp
under the one your current session is in and this folder you’ll find 2 files and an obj
directory. The files are ConsoleApp.csproj
and Program.cs
. If you open the Program.cs
you’ll find:
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
So when you execute dotnet run
inside the ConsoleApp folder you’ll get the infamous output. Looking at Listing 2 a lot of people still get confused. There seems to be no type (aka class) in which this code is placed. Also no using directive tells our code where the type Console
can be found. Of cause nothing is weired here. Beginning with C# 9 Microsoft introduced a lot of “magic” to reduce what is called “boilerplate code”. This is code we learned to ignore after years of seeing it every day. The fact that there is no need for using System;
in Listing 2 gets clearer when we look into the ConsoleApp.csproj
. There is now a new option ImplicitUsings
which is set to enabled
. Certain namespaces are imported by hidden usings in the background. There are ways to add additional namespaces to this hidden agenda but this is not part of this talk.
The second mystery is the lack of the Program
class and the infamous Main()
method which showed us the starting point of the application formerly. This is called Top Level Statements. Microsoft claims that it now gets easier to learn the language and write stuff like function apps etc. I disagree on this because I think that it will get hard for people not knowing about the hidden switches for this. After compiling this you again get a program class and a main method of cause. This way a lot of people will get confused in the future when they get exceptions from types they never saw. But probably I’m wrong here an most people wouldn’t even notice 😄.
Anyways - it is easy to get back to the “old” days by changing Listing 1 to:
dotnet new console -n ConsoleApp --use-program-main
This brings back the known pattern:
namespace ConsoleApp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
Hosting
So here we are. We have a running program. We can call functions and use external code with Nuget and lib references and so on. What is wrong with this, you may ask. I think it is just completely different from other program ramp-ups. Take the complete stack of web applications. The templates we use in this are (like dotnet new webapp
) are all pre-configured with hosted approaches. By that I don’t mean that the results are hosted on servers. What I do mean is that there is a layer configured into our startup which
- Configures the application first
- Lets us have a place to ramp everything up clearly.
- Separates the process from the implementation.
- Comes with certain comforts like dependency injection and configuration system.
There are more conveniences but the point here is that those technologies are not bound to web projects at all. A typical first line in a web project looks like this:
var builder = WebApplication.CreateBuilder(args);
This line creates an instance of WebApplicationBuilder
. This is a logic that is capable of configuring and ramping up an application that is hosted by some kind of web server and responds to requests.
Good news is that this pattern can be achieved in Console Apps too. All you need to do is add a Nuget package with dotnet add package Microsoft.Extensions.Hosting
, add a using directive and then change your Main()
method:
var builder = Host.CreateDefaultBuilder(args);
var app = builder.Build();
app.Run();
Console.WriteLine("Done!");
When you run this app you will be disappointed a little bit because you will see the output “DONE” only when you cancel the program. What you see will remind you of web applications:
dotnet run
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/alex/samples/ConsoleApp
^Cinfo: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
Done!
What is going on here? Well we started the host and gave it nothing to do. The idea of this approach is that you create a type which implements the interface IHostedService
and let this thing run in the background like this:
namespace ConsoleApp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
class Program
{
static void Main(string[] args)
{
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddHostedService<MyService>();
});
var app = builder.Build();
app.Run();
Console.WriteLine("Done!");
}
}
class MyService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
Console.WriteLine("Starting...");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
Console.WriteLine("Stopping...");
return Task.CompletedTask;
}
}
Now you again get an application that you need to Ctrl
+ C
to stop it which is not what you probably wanted. But lets see the output when running Listing 8 first:
Starting...
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/alex/samples/ConsoleApp
^Cinfo: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
Stopping...
Done!
If you look closely you can see that StartAsync()
and StopAsync()
of your worker are getting called by the runtime. So what you have here is a long running operation that you can control in MyService
like you would do in web app background tasks (like watching and handling a queue).
But again this is too much. We just wanted a console app. So delete everything in Program.cs
and replace it with the following.
First iteration
namespace ConsoleApp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
class Program
{
static async Task Main(string[] args)
{
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddSingleton<MyApp>();
});
var app = builder.Build();
await app.Services.GetRequiredService<MyApp>().StartAsync();
Console.WriteLine("Done!");
}
}
class MyApp
{
public Task StartAsync()
{
Console.WriteLine("Hello World!");
return Task.CompletedTask;
}
}
Lets go through some of the changes stuff here.
- The class
MyApp
represents my application logic. It is now the one which controls the flow of my app starting withStartAsync()
. - In order to be able to start it I need to register
MyApp
as a singleton (the same instance is delivered during my app lifetime) in the dependency injection (DI) system of the host builder. - After my app is build (
builder.Build()
) I need to retrieve an instance ofMyApp
from the DI and can then finally callStartAsync()
on it.
At this point most people will agree that all I achieved was making an easy thing hard. If your complete logic of your console app consists out of Hello World!
that would be true. But in real-world-scenarios it seldom is that simple. Lets take an easy example. Lets say you want to access the current environment your running in in MyApp
. This code would do the thing without any other adjustment:
class MyApp
{
private IHostEnvironment _env;
public MyApp(IHostEnvironment env)
{
_env = env;
}
public Task StartAsync()
{
Console.WriteLine(_env.ContentRootPath);
return Task.CompletedTask;
}
}
So what you see here is that the DI “knows” how to obtain an instance of IHostEnvironment
and just does ist because we know from Listing 10 that we don’t simple create an instance of MyApp
by ourselves but asked the DI system to do this for us. That way this DI could infer the needed injections for us and did the job.
Another real nice thing is logging. The IHostBuilder
configured by us comes with pre-defined logging providers already prepared for us. So just simple changing our MyApp
does the job:
class MyApp
{
private ILogger<MyApp> _logger;
public MyApp(ILogger<MyApp> logger)
{
_logger = logger;
}
public Task StartAsync()
{
_logger.LogInformation("Hello World!");
return Task.CompletedTask;
}
}
will produce
info: ConsoleApp.MyApp[0]
Hello World!
Done!
Web-like config with appsettings.json
One nice thing about web apps is the easy to understand and use default configuration system. It uses a bunch of appsettings.*.json
files to give us default and environment-specific configurations. This can be done in console apps using IHostBuilder
too. Just add JSON file named appsettings.json
into your project directory and give it the following content:
{
"App": {
"Value1": "A",
"Value2": 1
}
}
Now open your ConsoleApp.csproj
file with an editor and add the following block before the </Project>
statement at the bottom:
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Finally change your MyApp
class to:
class MyApp
{
private ILogger<MyApp> _logger;
private IConfiguration _config;
public MyApp(ILogger<MyApp> logger, IConfiguration config)
{
_logger = logger;
_config = config;
}
public Task StartAsync()
{
_logger.LogInformation(_config["App:Value1"]);
return Task.CompletedTask;
}
}
Your output will now be
info: ConsoleApp.MyApp[0]
A
Done!
This built-in system even understands that if you add a file appsettings.Development.json
and setting an environment var DOTNET_ENVIRONMENT
to “Development” that it has to merge values from this file with the appsettings.json
without having you to write any code.
Custom DI
It should got clear to this point that DI is implemented like we are used from web apps. Now the last part would be to perform DI with our own types. For this lets create an interface and an implementing type:
public interface IMyLogic
{
void Say(string message);
}
public class MyLogic : IMyLogic
{
public void Say(string message)
{
Console.WriteLine(message);
}
}
Now lets tell our MyApp
to expect an instance if IMyLogic
like we did with the framework interfaces before:
class MyApp
{
private ILogger<MyApp> _logger;
private IConfiguration _config;
private IMyLogic _logic;
public MyApp(ILogger<MyApp> logger, IConfiguration config, IMyLogic logic)
{
_logger = logger;
_config = config;
_logic = logic;
}
public Task StartAsync()
{
_logger.LogInformation(_config["App:Value1"]);
_logic.Say("Hello World!");
return Task.CompletedTask;
}
}
and now lets tell our host builder how to resolve our interface:
var builder = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddTransient<IMyLogic, MyLogic>();
services.AddSingleton<MyApp>();
});
You only need to add line 4 to the already existing code. When you run your app now you should see “Hello World” again because your MyLogic
has written it to the console.
Conclusion
Yes, if you are not going to build an console app that runs in the wild, this is definitely too much. But the scope here is to talk about how to make console apps that are doing productive things. I hope I could show that it is not hard to get a sophisticated first design running.