Console Applications
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example that starts with
Option Strict On is a complete program that was built with warnings treated as errors and run in the .NET 10 SDK container (Linux, arm64),
and the output shown is what it printed. The projects have the root namespace ShelfApp and the assembly name shelf, so the program in
each listing is started as dotnet shelf.dll. The Windows-service commands at the end could not be run on this machine and are labelled
as such. The packages used are System.CommandLine 2.0.0 and Microsoft.Extensions.* 10.0.0.
A console application is the first program of nearly every Visual Basic book (the Quickstart Guide and Mastering VB.NET both start
with one), and it is still the right project type for command-line tools, batch jobs, background workers and services. This page
covers what is specific to Visual Basic: how the entry point is chosen, how the project differs from a C# console project, what is missing
(My, Async Main, top-level statements, the worker template), and how to build the same tools that a C# developer builds with the Generic
Host and System.CommandLine. The project file, the build and the dotnet commands are on
Getting Started and
Projects, MSBuild and the dotnet CLI; the Console class itself is
part of the base class library and is the same as in C# (see Getting Started with C#).
The console template
dotnet new console -lang VB creates a project of three files' worth of content: a project file and one source file. On the .NET 10 SDK the template
produced this, and nothing else:
Imports System
Module Program
Sub Main(args As String())
Console.WriteLine("Hello World!")
End Sub
End Module
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>c</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
(RootNamespace is the name of the folder the template was run in, here c.) Four things are worth noticing, because each differs from the C# template or from what older books show.
-
There is no
Option Strict Onin the generated file or the project. The SDK defaults areOptionExplicit On,OptionInfer On,OptionStrict OffandOptionCompare Binary; this reference setsOption Strict Onat the top of every file (see Option Statements) or<OptionStrict>On</OptionStrict>in the project. -
The program is a
Modulewith aSub Main. VB has no top-level statements: C# 9 and later letConsole.WriteLine("Hello");stand alone inProgram.cs; in Visual Basic the entry point is always a declared procedure. -
The project does not set
MyType, so it is the SDK defaultEmptyandMy.Computer,My.Applicationand the rest do not exist; see The My Namespace. -
Imports Systemis redundant: the SDK importsMicrosoft.VisualBasic,System,System.Collections,System.Collections.Generic,System.Diagnostics,System.Linq,System.Xml.LinqandSystem.Threading.Tasksinto every VB project (read withdotnet msbuild -getItem:Import). The listings here omit it, and add anImportsline only for other namespaces such asSystem.IOorSystem.Threading.
Older material is built on a different template. Visual Basic Quickstart Guide (Olmsted) uses the Visual Studio "Console (.NET Framework)" template, and Mastering VB.NET (Hussain and Hussain)
mixes ".NET Framework" and ".NET 6" projects. The .NET Framework template creates a Module Module1 with a My Project folder and an App.config. On .NET 10 the module name is free, the project has no
My Project folder, and configuration is explicit (see below).
The VB templates in the SDK are listed by dotnet new list -lang VB: console, classlib, mstest, nunit, xunit, winforms, winformslib, winformscontrollib, wpf, wpflib, wpfcustomcontrollib and wpfusercontrollib
(plus the item templates mstest-class and nunit-test). There is no worker, webapi, web or blazor template for Visual Basic:
$ dotnet new worker -lang VB
No templates found matching: 'worker', -lang='VB'.
Allowed values for '-lang' option are: 'C#', 'F#'.
Source: dotnet new
The "Worker Service" project type you see in C# material is therefore built by hand in VB, which is easy and is shown in the worker section below. The .NET 5 announcement from the Visual Basic team (2020) listed "Worker Service" and "ASP.NET Core Web API" among the application types that VB would support, but no VB template for them exists on the .NET 10 SDK, as the command above shows; the supported route is to write the code by hand, as below.
Sub Main: the entry point
The compiler picks the entry point from the procedures named Main in the project. The rules, all checked with the .NET 10 SDK, are:
| Declaration | Accepted as an entry point |
|---|---|
|
yes |
|
yes; |
|
yes; the return value is the process exit code |
|
yes |
|
no: BC30737 |
|
no: BC30737, VB has no asynchronous |
|
no: BC36934, |
|
yes |
|
yes (the class does not have to be a module); name it in |
two procedures named |
BC30738 unless |
no |
BC30420 |
The first, third and fourth lines of the table are the C# static void Main, static int Main and static int Main(string[] args); the C# forms static async Task Main and top-level statements have no VB counterpart.
The compiler messages for the failing rows are:
Function Main(args As String()) As String
vbc : error BC30737: No accessible 'Main' method with an appropriate signature was found in 'shelf'.
Async Function Main(args As String()) As Task(Of Integer)
vbc : error BC30737: No accessible 'Main' method with an appropriate signature was found in 'shelf'.
Async Sub Main(args As String())
AsyncSub.vb(4,15): error BC36934: The 'Main' method cannot be marked 'Async'.
Module First / Module Second, each with a Sub Main, no StartupObject
vbc : error BC30738: 'Sub Main' is declared more than once in 'shelf': ShelfApp.First.Main(), ShelfApp.Second.Main()
a Module with no Main
vbc : error BC30420: 'Sub Main' was not found in 'shelf'.
Source: Main procedure in Visual Basic
StartupObject is a project property: <StartupObject>ShelfApp.Launcher</StartupObject> (the root namespace is part of the name) selects the module or class whose Main runs. The SDK-generated Windows Forms template
writes <StartupObject>Sub Main</StartupObject> (the value Sub Main is accepted by the compiler and was built for this page with a single Main in a module), which keeps the project on the Sub Main that you wrote instead of the Main that the Application Framework would generate
(see Windows Forms Applications). A procedure called Main need not be in a module named Program: the name Program is a template convention.
The program below prints its arguments and shows the three ways to see them. It is a function, so the exit code is its return value:
Option Strict On
Module ArgsAndExit
' Function Main returns the process exit code.
Function Main(args As String()) As Integer
Console.WriteLine("Arguments: " & args.Length.ToString())
For i = 0 To args.Length - 1
Console.WriteLine($" args({i}) = {args(i)}")
Next
' Environment.GetCommandLineArgs() includes the program itself as element 0.
Dim all = Environment.GetCommandLineArgs()
Console.WriteLine("GetCommandLineArgs().Length = " & all.Length.ToString())
' Command() is the Microsoft.VisualBasic.Interaction function: the arguments as one string.
Console.WriteLine("Command() = [" & Microsoft.VisualBasic.Interaction.Command() & "]")
If args.Length = 0 Then
Console.Error.WriteLine("usage: shelf <title>")
Return 2
End If
Return 0
End Function
End Module
Run with no arguments and then with three:
$ dotnet shelf.dll
Arguments: 0
GetCommandLineArgs().Length = 1
Command() = []
usage: shelf <title>
[exit code 2]
$ dotnet shelf.dll Dune Gödel, Escher, Bach --year=1979
Arguments: 3
args(0) = Dune
args(1) = Gödel, Escher, Bach
args(2) = --year=1979
GetCommandLineArgs().Length = 4
Command() = [Dune "Gödel, Escher, Bach" --year=1979]
[exit code 0]
Source: Interaction.Command
args excludes the program; Environment.GetCommandLineArgs() includes it as element 0 (so its length is one more). Command() is the Visual Basic function that returns the arguments as a single string, with quotes put
back around the arguments that contain spaces; it is a relic of VB6 and is only useful for porting. The shell removes the quotes before the program sees "Gödel, Escher, Bach", which is why args(1) has none. Note that Windows and Unix shells differ in quoting
and globbing: a * on a Unix command line is expanded by the shell before Main is called, on Windows it is not.
Another shape the SDK accepts is a class:
Option Strict On
Public Class Launcher
' A Shared Sub Main in a class also works; select it with StartupObject.
Public Shared Sub Main()
Console.WriteLine("Main in a class")
End Sub
End Class
which prints Main in a class when StartupObject is ShelfApp.Launcher.
Exit codes
The exit code is how a program tells a script, a scheduler or a CI job whether it worked. The conventions are shared with every other .NET language:
| Code | Usual meaning |
|---|---|
|
success |
|
a general failure (for example, nothing found) |
|
a usage error: bad arguments (this page’s examples use it) |
other values |
anything the tool documents; on Windows the exit code is a 32-bit integer, on Unix only the low 8 bits (0 to 255) are visible to the parent |
There are four ways to produce one, and they interact:
-
Function Main(...) As Integerreturns it. This is the cleanest and the one to use in new code. -
Environment.ExitCode = nsets the code that a normal return fromMainwill use, so it works from aSub Mainand from anywhere in the program. -
Environment.Exit(n)ends the process immediately:Finallyblocks andUsingdisposals of the calling frames do not run. -
An exception that nobody catches ends the process with a runtime-specific non-zero code, after printing the exception.
Option Strict On
Module ExitCodes
Sub Main(args As String())
' Environment.ExitCode is used when Main is a Sub (or returns normally).
Environment.ExitCode = 3
Console.WriteLine("ExitCode is now " & Environment.ExitCode.ToString())
If args.Length > 0 AndAlso args(0) = "exit" Then
Try
Console.WriteLine("calling Environment.Exit(5)")
Environment.Exit(5)
Finally
Console.WriteLine("this Finally block never runs")
End Try
End If
Console.WriteLine("returning from Sub Main")
End Sub
End Module
$ dotnet shelf.dll
ExitCode is now 3
returning from Sub Main
[exit code 3]
$ dotnet shelf.dll exit
ExitCode is now 3
calling Environment.Exit(5)
[exit code 5]
The second run never prints "this Finally block never runs": Environment.Exit does not unwind the stack. Use it only for the fatal "cannot continue" case; for everything else return from Main so that cleanup happens.
An unhandled exception prints the type, the message and the stack trace to standard error, and ends the process abnormally. The AppDomain.CurrentDomain.UnhandledException event is a last chance to log; it cannot stop the process:
Option Strict On
Module Unhandled
Sub Main(args As String())
AddHandler AppDomain.CurrentDomain.UnhandledException,
Sub(sender As Object, e As UnhandledExceptionEventArgs)
Console.Error.WriteLine("handler saw: " & DirectCast(e.ExceptionObject, Exception).Message)
End Sub
Console.WriteLine("about to fail")
Throw New InvalidOperationException("The catalogue file is missing.")
End Sub
End Module
$ dotnet shelf.dll
about to fail
handler saw: The catalogue file is missing.
Unhandled exception. System.InvalidOperationException: The catalogue file is missing.
at ShelfApp.Unhandled.Main(String[] args) in ./Unhandled.vb:line 10
[exit code 134]
On Linux the exit code was 134 (the process aborted, 128 plus signal 6); on Windows an unhandled .NET exception gives a different, large non-zero value. A script should test for "not zero", not for a particular value. The trace shows ./Unhandled.vb:line 10
because the project was built with a path map; a normal build shows the full path of the source file.
Source: Environment.ExitCode
For the exception hierarchy, Try/Catch and Err, see
Structured Exception Handling. A common pattern is a single Try/Catch in Main that maps exceptions to exit codes
(Catch ex As FormatException returns 2, Catch ex As IOException returns 3, anything else returns 1) after printing ex.Message to Console.Error, so that users see a message rather than a stack trace.
Console input and output
Console is a Shared class of System, so all of its members are called without an instance: Console.WriteLine, Console.Write, Console.ReadLine, Console.Error, Console.In, Console.Out, Console.OutputEncoding, Console.ForegroundColor,
and so on.
| Member | Notes |
|---|---|
|
standard output; |
`+Console.WriteLine("{0,-12} |
{1,6:N1} |
", a, b)+` |
composite formatting: |
`+$"{name,-12} |
{year,6} |
"+` |
an interpolated string; the same alignment and format specifiers (see Strings and Text) |
|
standard error: use it for messages that must not be mixed with the program’s output when it is redirected or piped |
|
one line from standard input, or |
|
all of standard input |
|
one key without echo; throws |
|
tell whether the stream is a terminal; use them to turn off colours, prompts and progress bars when a program is piped |
|
the encoding of standard output; set |
|
terminal features; some throw or return 0 when output is redirected |
Source: Console class
The next program prints a heading, aligns two columns, and then reads integers until the input ends, skipping the lines that are not integers. Integer.TryParse is the safe way to turn text into a number: it returns False
instead of throwing FormatException, and it never needs a Try block. Console.ReadLine() returns Nothing at the end of the input, so the loop tests for it before using the value:
Option Strict On
Module ConsoleIO
Sub Main()
Console.OutputEncoding = System.Text.Encoding.UTF8
Console.WriteLine("stdin redirected: " & Console.IsInputRedirected.ToString())
Console.WriteLine("stdout redirected: " & Console.IsOutputRedirected.ToString())
Console.WriteLine("stderr redirected: " & Console.IsErrorRedirected.ToString())
' Write, WriteLine, composite format and interpolation
Console.Write("Title: ")
Console.WriteLine("Gödel, Escher, Bach")
Console.WriteLine("{0,-12}|{1,6:N1}|", "Dune", 12.5)
Dim year = 1965
Console.WriteLine($"{"Dune",-12}|{year,6}|")
' ReadLine returns Nothing at end of input; never call .Trim() on it blindly.
Dim total = 0
Dim line As String = Console.ReadLine()
While line IsNot Nothing
Dim value As Integer
If Integer.TryParse(line, value) Then
total += value
Console.WriteLine($" read {value}")
Else
Console.Error.WriteLine($" skipped '{line}': not an integer")
End If
line = Console.ReadLine()
End While
Console.WriteLine("total = " & total.ToString())
End Sub
End Module
Input comes through a pipe (printf '10\nabc\n32\n' | dotnet shelf.dll). In the first run both output streams were captured together; in the second the error stream is thrown away (2> /dev/null):
stdin redirected: True
stdout redirected: True
stderr redirected: True
Title: Gödel, Escher, Bach
Dune | 12.5|
Dune | 1965|
read 10
skipped 'abc': not an integer
read 32
total = 42
stdin redirected: True
stdout redirected: True
stderr redirected: True
Title: Gödel, Escher, Bach
Dune | 12.5|
Dune | 1965|
read 10
read 32
total = 42
The order of the lines that come from two redirected streams is not guaranteed: in the run above the skipped 'abc' line is where the calls put it, but in an earlier run of this same program that line came out after total = 42. Print to standard error only what belongs there, and never rely on the relative order of the two streams when both are captured.
The three IsRedirected properties are True here because the program ran with its input and output attached to pipes.
Reading with Option Strict On has one trap that the older books never mention, because they did not use Option Strict On:
Option Strict On
Module Input
Sub Main()
Dim n As Integer = Console.ReadLine()
Dim m As Integer = Integer.Parse(Console.ReadLine())
Console.WriteLine(n + m)
End Sub
End Module
Input.vb(5,28): error BC30512: Option Strict On disallows implicit conversions from 'String' to 'Integer'.
Console.ReadLine() returns String; with Option Strict Off it was silently converted (and threw InvalidCastException for text that is not a number). Write Integer.TryParse for user input and Integer.Parse only for values that cannot be wrong.
MsgBox and InputBox are not console functions
Visual Basic Quickstart Guide shows MsgBox and InputBox as ways to talk to the user, next to Console. Both are part of the VB runtime, and on .NET they need Windows Forms. In a console program they compile and then fail at run time:
Option Strict On
Module MsgBoxInConsole
Sub Main()
Try
MsgBox("Hello")
Catch ex As Exception
Console.WriteLine(ex.GetType().Name & ": " & ex.Message)
End Try
Try
Dim s = InputBox("Name?")
Catch ex As Exception
Console.WriteLine(ex.GetType().Name & ": " & ex.Message)
End Try
End Sub
End Module
PlatformNotSupportedException: Method requires System.Windows.Forms.
PlatformNotSupportedException: Method requires System.Windows.Forms.
Use Console.WriteLine and Console.ReadLine; reserve MsgBox for Windows Forms programs (see The My Namespace).
Colours and terminal control
Console.ForegroundColor and BackgroundColor take ConsoleColor values; set them in a Try/Finally that restores the old colour so that an exception does not leave the terminal in the wrong colour. ANSI escape sequences (ChrW(27) followed by [1;32m) work in Windows Terminal, on modern Windows consoles and on Unix terminals, which is
the portable way to colour output without the Console colour properties. Things that need a real terminal behave differently when output is redirected:
Option Strict On
Module Colors
Sub Main()
Dim saved = Console.ForegroundColor
Try
Console.ForegroundColor = ConsoleColor.Yellow
Console.WriteLine("warning text (yellow on a terminal)")
Finally
Console.ForegroundColor = saved
End Try
Console.ResetColor()
' ANSI escape sequences work on Windows Terminal, conhost with VT enabled and Unix terminals.
Dim esc = ChrW(27)
Console.WriteLine(esc & "[1;32mOK" & esc & "[0m done")
' Terminal-only members behave differently when output is redirected: WindowWidth is 0
' on Unix and can throw an IOException on Windows; ReadKey throws everywhere.
Console.WriteLine("WindowWidth = " & Console.WindowWidth.ToString())
Try
Dim key = Console.ReadKey(True)
Console.WriteLine(key.Key.ToString())
Catch ex As InvalidOperationException
Console.WriteLine("ReadKey: " & ex.GetType().Name & ": " & ex.Message)
End Try
End Sub
End Module
The program was run with input and output redirected, and the output was passed through cat -v so that the escape character shows as ^[:
warning text (yellow on a terminal)
^[[1;32mOK^[[0m done
WindowWidth = 0
ReadKey: InvalidOperationException: Cannot read keys when either application does not have a console or when console input has been redirected. Try Console.Read.
With redirected output ConsoleColor changes emit nothing, the ANSI sequence is written as is (so redirecting to a file puts escape codes in the file: check Console.IsOutputRedirected first), WindowWidth is 0 on Unix (on Windows it can throw IOException),
and ReadKey throws. A well-behaved tool tests Console.IsOutputRedirected before it uses colour, cursor movement or a progress bar.
Ctrl+C and graceful shutdown
Ctrl+C is the user’s way to stop a console program, and a service host sends SIGTERM. By default the process ends at once. A program that holds files, locks or a database transaction should catch both signals, cancel its work through a
CancellationToken and clean up. Console.CancelKeyPress covers Ctrl+C and Ctrl+Break; PosixSignalRegistration (since .NET 6) covers SIGTERM, SIGHUP, SIGQUIT and on Windows the matching events:
Option Strict On
Imports System.Runtime.InteropServices
Imports System.Threading
Module Cancel
Sub Main()
Using cts As New CancellationTokenSource()
' Ctrl+C (SIGINT) and SIGTERM/Ctrl+Break both end up as a cancelled token.
AddHandler Console.CancelKeyPress,
Sub(sender As Object, e As ConsoleCancelEventArgs)
e.Cancel = True ' keep the process alive so that it can clean up
Console.WriteLine("Ctrl+C received")
cts.Cancel()
End Sub
Using sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM,
Sub(context As PosixSignalContext)
context.Cancel = True
Console.WriteLine("SIGTERM received")
cts.Cancel()
End Sub)
Console.WriteLine("working; waiting for a signal")
Try
Task.Delay(Timeout.Infinite, cts.Token).GetAwaiter().GetResult()
Catch ex As OperationCanceledException
Console.WriteLine("cleaning up")
End Try
End Using
End Using
Console.WriteLine("bye")
End Sub
End Module
Started and then stopped with each signal (timeout sends the named signal after three seconds; --preserve-status makes it return the program’s own exit code):
$ timeout --preserve-status -s INT 3 dotnet shelf.dll
working; waiting for a signal
Ctrl+C received
cleaning up
bye
[exit code 0]
$ timeout --preserve-status -s TERM 3 dotnet shelf.dll
working; waiting for a signal
SIGTERM received
cleaning up
bye
[exit code 0]
Source: Console.CancelKeyPress and PosixSignalRegistration
Setting e.Cancel = True keeps the process running so that Main can finish normally and return its exit code. The Generic Host (below) does all of this for you: it
cancels the token it passes to every BackgroundService on Ctrl+C and on SIGTERM.
Async code in a console application
Visual Basic has no Async Main. Async Function Main is BC30737 and Async Sub Main is BC36934 (see above). The pattern is a synchronous Main that calls an Async Function and
blocks once, at the very top, where there is no synchronization context to deadlock on:
Option Strict On
Module RunAsyncExample
' VB has no Async Main: keep Main synchronous and block once, at the very top.
Function Main(args As String()) As Integer
Return RunAsync(args).GetAwaiter().GetResult()
End Function
Async Function RunAsync(args As String()) As Task(Of Integer)
Console.WriteLine("start on thread pool: " & Threading.Thread.CurrentThread.IsThreadPoolThread.ToString())
Await Task.Delay(20)
Console.WriteLine("after the await")
If args.Length > 0 Then Throw New InvalidOperationException("bad argument " & args(0))
Return 0
End Function
End Module
$ dotnet shelf.dll
start on thread pool: False
after the await
[exit code 0]
$ dotnet shelf.dll x
start on thread pool: False
after the await
Unhandled exception. System.InvalidOperationException: bad argument x
at ShelfApp.RunAsyncExample.RunAsync(String[] args) in ./RunAsync.vb:line 13
at ShelfApp.RunAsyncExample.Main(String[] args) in ./RunAsync.vb:line 6
[exit code 134]
GetAwaiter().GetResult() rather than .Result or .Wait() rethrows the original exception, not an AggregateException, so the failing run above shows the real InvalidOperationException and exit code 134. Everything inside RunAsync uses
Await normally (see Async and Await). In a console application there is no UI synchronization context, so continuations run on thread-pool threads, and Task.Run is not needed to keep a UI alive.
Parsing the command line
Three levels of effort are common.
Positional arguments and a hand-written parser
For a tool with a handful of options, a Select Case over args is enough and has no dependencies. The program below is a small shelf list command with --year, --title and --verbose
(or -v), usage text, and exit codes 0, 1 and 2. It validates before it acts and writes usage errors to Console.Error:
Option Strict On
' A hand-written command-line parser: no packages, exit code 0 = success, 1 = failure, 2 = usage error.
Module ShelfCli
Private Const UsageText As String =
"usage: shelf list [--year <n>] [--title <text>] [--verbose]" & vbLf &
" shelf --help"
Private ReadOnly Titles As (Title As String, Year As Integer)() = {
("Dune", 1965), ("Neuromancer", 1984), ("Snow Crash", 1992), ("Dune Messiah", 1969)}
Function Main(args As String()) As Integer
If args.Length = 0 OrElse args(0) = "--help" OrElse args(0) = "-h" Then
Console.WriteLine(UsageText)
Return If(args.Length = 0, 2, 0)
End If
If args(0) <> "list" Then
Console.Error.WriteLine($"shelf: unknown command '{args(0)}'")
Console.Error.WriteLine(UsageText)
Return 2
End If
Dim year As Integer? = Nothing
Dim title As String = Nothing
Dim verbose = False
Dim i = 1
While i < args.Length
Select Case args(i)
Case "--year"
i += 1
Dim parsed As Integer
If i >= args.Length OrElse Not Integer.TryParse(args(i), parsed) Then
Console.Error.WriteLine("shelf: --year needs an integer")
Return 2
End If
year = parsed
Case "--title"
i += 1
If i >= args.Length Then
Console.Error.WriteLine("shelf: --title needs a value")
Return 2
End If
title = args(i)
Case "--verbose", "-v"
verbose = True
Case Else
Console.Error.WriteLine($"shelf: unknown option '{args(i)}'")
Return 2
End Select
i += 1
End While
Dim shown = 0
For Each entry In Titles
If year.HasValue AndAlso entry.Year <> year.Value Then Continue For
If title IsNot Nothing AndAlso Not entry.Title.Contains(title, StringComparison.OrdinalIgnoreCase) Then Continue For
Console.WriteLine(If(verbose, $"{entry.Title} ({entry.Year})", entry.Title))
shown += 1
Next
If verbose Then Console.WriteLine($"{shown} title(s)")
Return If(shown > 0, 0, 1)
End Function
End Module
$ dotnet shelf.dll
usage: shelf list [--year <n>] [--title <text>] [--verbose]
shelf --help
[exit code 2]
$ dotnet shelf.dll --help
usage: shelf list [--year <n>] [--title <text>] [--verbose]
shelf --help
[exit code 0]
$ dotnet shelf.dll list
Dune
Neuromancer
Snow Crash
Dune Messiah
[exit code 0]
$ dotnet shelf.dll list --year 1965 --verbose
Dune (1965)
1 title(s)
[exit code 0]
$ dotnet shelf.dll list --title dune -v
Dune (1965)
Dune Messiah (1969)
2 title(s)
[exit code 0]
$ dotnet shelf.dll list --year abc
shelf: --year needs an integer
[exit code 2]
$ dotnet shelf.dll list --title zzz
[exit code 1]
$ dotnet shelf.dll remove
shelf: unknown command 'remove'
usage: shelf list [--year <n>] [--title <text>] [--verbose]
shelf --help
[exit code 2]
Points to copy: every option that takes a value checks that the value exists and parses (Integer.TryParse), Continue For skips non-matching titles without nesting, the exit code distinguishes "usage error" (2) from "no results" (1), and Return leaves Main so Using blocks and Finally run. The array of named tuples
(Title As String, Year As Integer)() is a compact in-memory catalogue; see
Tuples and Anonymous Types.
System.CommandLine
When a tool grows subcommands, --help, tab completion or typed options, use the System.CommandLine package. Its 2.0.0 release has a different API from the long-running beta that most blog posts show:
SetAction replaced SetHandler, options and subcommands are added to the Options and Subcommands collections, and the result is read with parseResult.GetValue(option). VB needs one extra thing: Option is a Visual Basic
keyword, so the type is written [Option](Of Integer).
Option Strict On
Imports System.CommandLine
' The System.CommandLine package (2.0.0) builds the parser, --help, --version, errors and exit codes.
Module SystemCommandLine
Function Main(args As String()) As Integer
Dim titles = {("Dune", 1965), ("Neuromancer", 1984), ("Snow Crash", 1992), ("Dune Messiah", 1969)}
Dim yearOption As New [Option](Of Integer)("--year") With {
.Description = "Only titles published in this year"}
Dim containsOption As New [Option](Of String)("--title", "-t") With {
.Description = "Only titles that contain this text"}
Dim verboseOption As New [Option](Of Boolean)("--verbose", "-v") With {
.Description = "Also print the year"}
Dim listCommand As New Command("list", "List the titles in the catalogue")
listCommand.Options.Add(yearOption)
listCommand.Options.Add(containsOption)
listCommand.Options.Add(verboseOption)
listCommand.SetAction(
Function(parseResult As ParseResult) As Integer
Dim year = parseResult.GetValue(yearOption)
Dim text = parseResult.GetValue(containsOption)
Dim verbose = parseResult.GetValue(verboseOption)
Dim shown = 0
For Each entry In titles
If year <> 0 AndAlso entry.Item2 <> year Then Continue For
If text IsNot Nothing AndAlso Not entry.Item1.Contains(text, StringComparison.OrdinalIgnoreCase) Then Continue For
Console.WriteLine(If(verbose, $"{entry.Item1} ({entry.Item2})", entry.Item1))
shown += 1
Next
Return If(shown > 0, 0, 1)
End Function)
Dim root As New RootCommand("Shelf catalogue tool")
root.Subcommands.Add(listCommand)
Return root.Parse(args).Invoke()
End Function
End Module
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0" />
</ItemGroup>
The library produces help, a version option and error messages by itself:
$ dotnet shelf.dll --help
Description:
Shelf catalogue tool
Usage:
shelf [command] [options]
Options:
-?, -h, --help Show help and usage information
--version Show version information
Commands:
list List the titles in the catalogue
[exit code 0]
$ dotnet shelf.dll list --help
Description:
List the titles in the catalogue
Usage:
shelf list [options]
Options:
--year <year> Only titles published in this year
-t, --title <title> Only titles that contain this text
-v, --verbose Also print the year
-?, -h, --help Show help and usage information
[exit code 0]
$ dotnet shelf.dll list --year 1965 -v
Dune (1965)
[exit code 0]
$ dotnet shelf.dll list -t dune
Dune
Dune Messiah
[exit code 0]
$ dotnet shelf.dll list --year abc
Cannot parse argument 'abc' for option '--year' as expected type 'System.Int32'.
Description:
List the titles in the catalogue
Usage:
shelf list [options]
Options:
--year <year> Only titles published in this year
-t, --title <title> Only titles that contain this text
-v, --verbose Also print the year
-?, -h, --help Show help and usage information
[exit code 1]
$ dotnet shelf.dll remove
Required command was not provided.
Unrecognized command or argument 'remove'.
Description:
Shelf catalogue tool
Usage:
shelf [command] [options]
Options:
-?, -h, --help Show help and usage information
--version Show version information
Commands:
list List the titles in the catalogue
[exit code 1]
$ dotnet shelf.dll --version
1.0.0
[exit code 0]
Source: System.CommandLine overview
Beta-era code does not compile against 2.0.0; the compiler messages for the old calls are:
Option Strict On
Imports System.CommandLine
Module OldApi
Function Main(args As String()) As Integer
Dim verbose As New [Option](Of Boolean)("--verbose")
Dim root As New RootCommand("Shelf")
root.AddOption(verbose)
root.SetHandler(Sub(v) Console.WriteLine(v), verbose)
Return root.Invoke(args)
End Function
End Module
OldApi.vb(10,9): error BC30456: 'SetHandler' is not a member of 'RootCommand'.
OldApi.vb(11,16): error BC30456: 'Invoke' is not a member of 'RootCommand'.
OldApi.vb(9,9): error BC30456: 'AddOption' is not a member of 'RootCommand'.
VB specifics for System.CommandLine: write the action as Function(parseResult As ParseResult) As Integer when it returns an exit code, or Sub(parseResult As ParseResult) when it does not (both forms compile; a Sub gives exit code 0); Option(Of T) with a value type is not nullable, so parseResult.GetValue(yearOption) is 0 when the option is absent
(use a default value factory or Option(Of Integer?) if zero is a legal value); the parser reports the usage errors itself and returns exit code 1, which is why the earlier listing’s 2 differs.
Other parsing libraries
Hand-written parsing and System.CommandLine are the only ones used here. Any .NET library works from Visual Basic, as the languages share the runtime, but libraries that depend on attributes over properties or on records and init accessors may be awkward in VB (VB cannot declare init-only properties; see
Classes and Objects).
Configuration
A real tool takes its settings from several places. Microsoft.Extensions.Configuration combines them in layers, where later providers override earlier ones: usually JSON files, then environment variables, then the command line.
| Provider (extension method) | Package | Key format |
|---|---|---|
|
|
nested objects become |
|
|
the prefix is removed and |
|
|
|
|
|
tests and defaults |
User secrets |
|
development-only secrets outside the repository |
The example reads a JSON file, an environment variable and the command line, binds the Shelf section to a class, and reads one raw key. Properties of the options class use plain Public Property with defaults, which the binder fills in; Get(Of ShelfOptions)() creates the object:
Option Strict On
Imports System.IO
Imports Microsoft.Extensions.Configuration
Public Class ShelfOptions
Public Property DataFile As String = "shelf.json"
Public Property PageSize As Integer = 20
Public Property Verbose As Boolean
Public Property Libraries As New List(Of String)
End Class
Module ConfigDemo
Sub Main(args As String())
Dim folder = Path.Combine(Path.GetTempPath(), "shelf-config-demo")
Directory.CreateDirectory(folder)
File.WriteAllText(Path.Combine(folder, "appsettings.json"),
"{ ""Shelf"": { ""DataFile"": ""catalog.json"", ""PageSize"": 50, ""Libraries"": [ ""Central"", ""North"" ] } }")
' Later providers override earlier ones: file, then environment, then command line.
Environment.SetEnvironmentVariable("SHELF_Shelf__PageSize", "75")
Dim configuration = New ConfigurationBuilder() _
.SetBasePath(folder) _
.AddJsonFile("appsettings.json", optional:=False, reloadOnChange:=False) _
.AddJsonFile("appsettings.Local.json", optional:=True) _
.AddEnvironmentVariables("SHELF_") _
.AddCommandLine(args) _
.Build()
Dim options = configuration.GetSection("Shelf").Get(Of ShelfOptions)()
Console.WriteLine("DataFile = " & options.DataFile)
Console.WriteLine("PageSize = " & options.PageSize.ToString())
Console.WriteLine("Verbose = " & options.Verbose.ToString())
Console.WriteLine("Libraries = " & String.Join(", ", options.Libraries))
Console.WriteLine("Raw key = " & configuration("Shelf:PageSize"))
Console.WriteLine("Missing = [" & If(configuration("Shelf:Nope"), "(Nothing)") & "]")
Directory.Delete(folder, True)
End Sub
End Module
$ dotnet shelf.dll
DataFile = catalog.json
PageSize = 75
Verbose = False
Libraries = Central, North
Raw key = 75
Missing = [(Nothing)]
[exit code 0]
$ dotnet shelf.dll --Shelf:PageSize=5 --Shelf:Verbose=true
DataFile = catalog.json
PageSize = 5
Verbose = True
Libraries = Central, North
Raw key = 5
Missing = [(Nothing)]
[exit code 0]
Source: Configuration in .NET
The environment variable (75) beat the JSON file (50), and the command line (5) beat both; Verbose was never set by a file so it stayed False until the command line gave it. configuration("Shelf:Nope") is Nothing for a missing key, so use If(configuration("key"), "default") to give a default
(VB has no ??). In a real project the JSON file lives next to the program:
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
SetBasePath(AppContext.BaseDirectory) finds it wherever the program is started from. Visual Basic Quickstart Guide and Mastering VB.NET use App.config and ConfigurationManager.AppSettings, which is a .NET Framework convention; it still works on .NET through the
System.Configuration.ConfigurationManager package, but new code uses Microsoft.Extensions.Configuration (and My.Settings is a Windows Forms feature, see
The My Namespace).
The Generic Host and dependency injection
The Generic Host (Microsoft.Extensions.Hosting) packages configuration, logging, dependency injection and the application lifetime behind one object. It is the foundation of ASP.NET Core, of the C# "Worker Service" template, and it works from Visual Basic
in an ordinary console project; the packages are Microsoft.Extensions.Hosting (which brings in configuration, logging and DI) and, optionally, Microsoft.Extensions.Hosting.WindowsServices.
Host.CreateApplicationBuilder(args) (the builder shape from .NET 7; the older Host.CreateDefaultBuilder(args) still works) reads appsettings.json, appsettings.{Environment}.json, environment variables and the command line (the host’s own settings read the DOTNET_-prefixed variables), adds the console logger,
and wires Ctrl+C and SIGTERM to a graceful shutdown. You register services on builder.Services, call Build(), and Run():
Option Strict On
Imports Microsoft.Extensions.DependencyInjection
Imports Microsoft.Extensions.Hosting
Imports Microsoft.Extensions.Logging
Public Interface ICatalogReader
Function Titles() As IReadOnlyList(Of String)
End Interface
Public Class InMemoryCatalogReader
Implements ICatalogReader
Public Function Titles() As IReadOnlyList(Of String) Implements ICatalogReader.Titles
Return {"Dune", "Neuromancer", "Snow Crash"}
End Function
End Class
Public Class CatalogReport
Inherits BackgroundService
Private ReadOnly _reader As ICatalogReader
Private ReadOnly _logger As ILogger(Of CatalogReport)
Private ReadOnly _lifetime As IHostApplicationLifetime
' Constructor injection: the container supplies every parameter.
Public Sub New(reader As ICatalogReader, logger As ILogger(Of CatalogReport), lifetime As IHostApplicationLifetime)
_reader = reader
_logger = logger
_lifetime = lifetime
End Sub
Protected Overrides Function ExecuteAsync(stoppingToken As Threading.CancellationToken) As Task
For Each title In _reader.Titles()
_logger.LogInformation("Catalogue title: {Title}", title)
Next
_lifetime.StopApplication() ' a one-shot job: ask the host to shut down
Return Task.CompletedTask
End Function
End Class
Module HostDemo
Function Main(args As String()) As Integer
Dim builder = Host.CreateApplicationBuilder(args)
builder.Services.AddSingleton(Of ICatalogReader, InMemoryCatalogReader)()
builder.Services.AddHostedService(Of CatalogReport)()
builder.Logging.SetMinimumLevel(LogLevel.Information)
Using host = builder.Build()
host.Run()
End Using
Return 0
End Function
End Module
$ dotnet shelf.dll
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: ShelfApp.CatalogReport[0]
Catalogue title: Dune
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: ShelfApp.CatalogReport[0]
Catalogue title: Neuromancer
info: ShelfApp.CatalogReport[0]
Catalogue title: Snow Crash
info: Microsoft.Hosting.Lifetime[0]
Content root path: /app
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
[exit code 0]
Source: .NET Generic Host
How the example works, and what is specific to Visual Basic:
-
BackgroundServiceis an abstract base class with one method to override,ExecuteAsync. The container createsCatalogReportand passes its constructor arguments: a registered interface (ICatalogReader), a logger (ILogger(Of CatalogReport), which the host registers for every type) and the host’s ownIHostApplicationLifetime.AddHostedService(Of CatalogReport)()is the VB spelling of C#'sAddHostedService<CatalogReport>(); every generic call takes(Of ...). -
The log lines come from the console logger, which prints the category (
ShelfApp.CatalogReport), the event id ([0]) and the message. Message templates ("Catalogue title: {Title}") are not interpolated strings: pass the values as arguments so that structured loggers can keep them. -
_lifetime.StopApplication()makes this a one-shot job;host.Run()returns when the host has stopped, andMainreturns 0. -
Using host = builder.Build()disposes the host (and the singleton services that implementIDisposable) when the program ends.
DI lifetimes: AddSingleton creates one instance for the whole host, AddScoped one per scope (a request in ASP.NET Core; in a console program only when you create scopes with host.Services.CreateScope()), AddTransient a new instance on every request. A singleton must not depend on a scoped service; in development the host validates this.
For the language-independent details see
Getting Started with C# and the Learn articles in the references.
The Console name clash
A VB-only surprise when you add logging. Microsoft.Extensions.Logging.Console is a namespace, so after Imports Microsoft.Extensions.Logging the simple name Console can bind to Microsoft.Extensions.Logging.Console instead of System.Console (VB resolves names case-insensitively and looks in imported namespaces):
Option Strict On
Imports Microsoft.Extensions.Logging
Module Clash
Sub Main()
Console.WriteLine("hello")
End Sub
End Module
Clash.vb(7,9): error BC30456: 'WriteLine' is not a member of 'Microsoft.Extensions.Logging.Console'.
Write System.Console.WriteLine(...) in files that import Microsoft.Extensions.Logging, or alias it: Imports Out = System.Console.
The older builder
Code written before .NET 7, and most of the C# samples, use Host.CreateDefaultBuilder(args) with ConfigureServices. It is the same host behind a different builder, and it compiles in VB with line continuations or a single long chain:
Option Strict On
Imports Microsoft.Extensions.DependencyInjection
Imports Microsoft.Extensions.Hosting
' The older IHostBuilder style (Host.CreateDefaultBuilder), still common in code written before .NET 8.
Module GenericHostBuilder
Sub Main(args As String())
Host.CreateDefaultBuilder(args) _
.UseWindowsService() _
.ConfigureServices(Sub(context, services) services.AddHostedService(Of ShelfWorker)()) _
.Build() _
.Run()
End Sub
End Module
Both builders were built and run for this page; stopped after a second they print the same start and shutdown messages.
Worker-style background services
A worker is a console program that runs until it is stopped: it polls a queue, checks overdue loans, syncs a catalogue. The C# dotnet new worker template is not available for Visual Basic (see the first section), but the template is nothing more than the Generic Host plus a BackgroundService,
so a VB worker is a console project and these two things. The C# template uses the Microsoft.NET.Sdk.Worker project SDK together with a PackageReference to Microsoft.Extensions.Hosting. A VB project with that SDK and no package reference does not compile (BC30002 for BackgroundService and ILogger), so the package reference is needed either way; the examples use the plain Microsoft.NET.Sdk.
Option Strict On
Imports Microsoft.Extensions.DependencyInjection
Imports Microsoft.Extensions.Hosting
Imports Microsoft.Extensions.Logging
Imports System.Threading
Public Class ShelfWorker
Inherits BackgroundService
Private ReadOnly _logger As ILogger(Of ShelfWorker)
Public Sub New(logger As ILogger(Of ShelfWorker))
_logger = logger
End Sub
Protected Overrides Async Function ExecuteAsync(stoppingToken As CancellationToken) As Task
Using timer As New PeriodicTimer(TimeSpan.FromMilliseconds(300))
Try
While Await timer.WaitForNextTickAsync(stoppingToken)
_logger.LogInformation("Checking for overdue loans at {Time:HH:mm:ss}", DateTime.UtcNow)
End While
Catch ex As OperationCanceledException
' normal shutdown
End Try
End Using
_logger.LogInformation("Worker stopped")
End Function
End Class
Module WorkerDemo
Sub Main(args As String())
Dim builder = Host.CreateApplicationBuilder(args)
' On Windows this makes the process a service; elsewhere it does nothing.
builder.Services.AddWindowsService(Sub(options) options.ServiceName = "ShelfWorker")
builder.Services.AddHostedService(Of ShelfWorker)()
builder.Build().Run()
End Sub
End Module
$ dotnet shelf.dll (stopped with SIGTERM after one second)
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: /app
info: ShelfApp.ShelfWorker[0]
Checking for overdue loans at 21:59:38
info: ShelfApp.ShelfWorker[0]
Checking for overdue loans at 21:59:38
info: ShelfApp.ShelfWorker[0]
Checking for overdue loans at 21:59:39
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
info: ShelfApp.ShelfWorker[0]
Worker stopped
[exit code 0]
Points about workers:
-
ExecuteAsyncis called once when the host starts and must return aTask; it is expected to run untilstoppingTokenis cancelled. Do notAwaitsomething that never completes without passing the token to it, or the host cannot stop. -
PeriodicTimer(since .NET 6) gives a loop with no drift and no overlapping ticks:WaitForNextTickAsyncreturnsFalseafter the timer is disposed and throwsOperationCanceledExceptionwhen the token is cancelled, which theTry/Catchabove treats as the normal way to stop. -
An exception that escapes
ExecuteAsyncstops the host by default (BackgroundServiceExceptionBehavior.StopHost); catch what you can recover from inside the loop. -
The host waits for
StopAsyncof every hosted service up toHostOptions.ShutdownTimeoutbefore it gives up; a worker must observe its token in every long-running call. -
Awaitin anAsync Functionworks the usual way, includingTry/Catch; but not inCatchorFinallyblocks (BC36943): see Async and Await.
Source: Worker Services in .NET
Windows services
A worker becomes a Windows service by one call. AddWindowsService (for the IServiceCollection, as in WorkerDemo) or UseWindowsService (for the older IHostBuilder, as in GenericHostBuilder) comes from Microsoft.Extensions.Hosting.WindowsServices and, when the process
is started by the Windows Service Control Manager, does three things: it registers the service lifetime in place of the console lifetime (so "stop" in the Services console becomes a graceful host shutdown), it adds an Event Log logger with the application name as the source, and it takes the service name from WindowsServiceLifetimeOptions.ServiceName.
When the program is started from a prompt, or on Linux or macOS (as in the runs above), the call does nothing: the same executable is a normal console program, which is how you debug it. Both overloads built and ran here; the service behaviour could not be exercised
because there is no Windows machine, so the following is built, not run.
sc.exe create ShelfWorker binPath= "C:\Services\Shelf\shelf.exe" start= auto
sc.exe description ShelfWorker "Checks for overdue loans"
sc.exe start ShelfWorker
sc.exe stop ShelfWorker
sc.exe delete ShelfWorker
Notes on those commands, which follow the .NET documentation:
-
The space after
binPath=andstart=is required bysc.exe. -
Publish first (
dotnet publish -c Release -r win-x64 --self-contained false -o C:\Services\Shelf) and pointbinPathat the publishedshelf.exe, the apphost; a service cannot rundotnet run. -
Run
sc.exefrom an elevated prompt. The service runs asLocalSystemunless you giveobj=andpassword=: create a dedicated account with the minimum rights instead of leaving it at the default. -
Logging goes to the Event Log, whose default severity is
Warning(the documentation says so), so theLogInformationlines of the example are not recorded unless you configure the level inappsettings.json. -
A service does not start in your application’s folder, and the host’s content root is the current directory by default; read files relative to
AppContext.BaseDirectory, or pass--contentRootinbinPath, as the documentation shows. -
On Linux the equivalent is a systemd unit (the
Microsoft.Extensions.Hosting.Systemdpackage addsAddSystemd()) or a container; the worker code does not change.
A console project targets net10.0, not net10.0-windows; the Windows service package is a plain library with a Windows-only implementation that is simply not active elsewhere.
Before .NET Core, VB developers wrote Windows services with the "Windows Service" project template (a class inheriting ServiceBase, OnStart and OnStop, an installer class and InstallUtil.exe). That template and the designer are .NET Framework only, and the books that
show it (Mastering VB.NET covers Windows Services) describe a model that the Generic Host replaced. The ServiceBase class still exists on .NET (in System.ServiceProcess.ServiceController) if you must inherit it, but there is no designer and no installer tool.
C# differences
| Topic | Visual Basic | C# |
|---|---|---|
Entry point |
|
top-level statements, |
Arguments |
|
|
Template |
|
|
Strictness |
|
always strict |
Generic calls |
|
|
Reserved words |
|
|
Name clashes |
imported namespace |
no namespace import brings child namespaces |
Case |
|
case-sensitive |
|
absent in console projects ( |
not a concept |
Where the books are outdated
| Book habit | Today on .NET 10 |
|---|---|
"Console (.NET Framework)" template, |
|
|
throw |
|
BC30512 under |
|
|
|
not needed with |
Windows Service project with |
a |
|
|
ASP.NET Core, Blazor or Worker templates "in VB.NET" |
there are no VB templates; the Generic Host and |
Error and exit-code reference
| Diagnostic | Meaning (messages are the compiler’s, shown above) |
|---|---|
BC30737 |
|
BC36934 |
|
BC30738 |
|
BC30420 |
|
BC30512 |
|
BC30002 |
a type from a package is not defined: the |
BC30456 |
|
NETSDK1100 |
a |
exit code 134 / non-zero |
an unhandled exception ended the process |
Pitfalls checklist
-
Return an exit code from
Maininstead of callingEnvironment.Exit, which skipsFinallyandUsing. -
Test
Console.ReadLine()forNothingbefore using the value, and useTryParsefor numbers. -
Print usage errors and diagnostics to
Console.Error, results toConsole.Out; do not rely on their relative order when both are redirected. -
Check
Console.IsOutputRedirectedbefore colour, cursor movement or progress output. -
Handle Ctrl+C and
SIGTERM(or use the Generic Host) when the program holds resources. -
There is no
Async Main: block once in a synchronousMainwithGetAwaiter().GetResult(). -
Do not use
MsgBoxorInputBox,My.*,App.configorWebClientin new console code. -
Write
System.Consolein files that importMicrosoft.Extensions.Logging. -
A worker must pass its
CancellationTokento every awaited call, or the host cannot stop it within the shutdown timeout. -
Do not put secrets in
appsettings.jsonin the repository; use environment variables, user secrets or a secret store.
References
-
Console, Environment.ExitCode, Console.CancelKeyPress and PosixSignalRegistration
-
Configuration in .NET, Dependency injection in .NET and Logging in .NET
-
.NET Generic Host, Worker Services in .NET and Create a Windows Service using BackgroundService
-
Aspen Olmsted, Visual Basic Quickstart Guide (Packt, 2023), chapter 2; Kameron Hussain and Frahaan Hussain, Mastering VB.NET (Sonar Publishing, 2023): publisher page not machine-verified, see the section bibliography