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 On in the generated file or the project. The SDK defaults are OptionExplicit On, OptionInfer On, OptionStrict Off and OptionCompare Binary; this reference sets Option Strict On at the top of every file (see Option Statements) or <OptionStrict>On</OptionStrict> in the project.

  • The program is a Module with a Sub Main. VB has no top-level statements: C# 9 and later let Console.WriteLine("Hello"); stand alone in Program.cs; in Visual Basic the entry point is always a declared procedure.

  • The project does not set MyType, so it is the SDK default Empty and My.Computer, My.Application and the rest do not exist; see The My Namespace.

  • Imports System is redundant: the SDK imports Microsoft.VisualBasic, System, System.Collections, System.Collections.Generic, System.Diagnostics, System.Linq, System.Xml.Linq and System.Threading.Tasks into every VB project (read with dotnet msbuild -getItem:Import). The listings here omit it, and add an Imports line only for other namespaces such as System.IO or System.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

Sub Main()

yes

Sub Main(args As String())

yes; args has the command-line arguments without the program name

Function Main() As Integer

yes; the return value is the process exit code

Function Main(args As String()) As Integer

yes

Function Main(args As String()) As String

no: BC30737

Async Function Main(args As String()) As Task(Of Integer)

no: BC30737, VB has no asynchronous Main

Async Sub Main(args As String())

no: BC36934, The 'Main' method cannot be marked 'Async'

Sub Main in a Module

yes

Public Shared Sub Main() in a Class

yes (the class does not have to be a module); name it in StartupObject when there is more than one

two procedures named Main in different modules

BC30738 unless StartupObject names one

no Main at all in an executable

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'.

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]

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

0

success

1

a general failure (for example, nothing found)

2

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 Integer returns it. This is the cleanest and the one to use in new code.

  • Environment.ExitCode = n sets the code that a normal return from Main will use, so it works from a Sub Main and from anywhere in the program.

  • Environment.Exit(n) ends the process immediately: Finally blocks and Using disposals 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.

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

Console.WriteLine(text), Console.Write(text)

standard output; WriteLine appends the platform’s line ending (CR LF on Windows, LF on Unix)

`+Console.WriteLine("{0,-12}

{1,6:N1}

", a, b)+`

composite formatting: ,-12 left-aligns in twelve characters, ,6 right-aligns in six, :N1 is a numeric format string

`+$"{name,-12}

{year,6}

"+`

an interpolated string; the same alignment and format specifiers (see Strings and Text)

Console.Error.WriteLine(text)

standard error: use it for messages that must not be mixed with the program’s output when it is redirected or piped

Console.ReadLine()

one line from standard input, or Nothing at the end of the input (a closed pipe or Ctrl+Z / Ctrl+D)

Console.In.ReadToEnd()

all of standard input

Console.ReadKey(True)

one key without echo; throws InvalidOperationException when input is redirected

Console.IsInputRedirected, IsOutputRedirected, IsErrorRedirected

tell whether the stream is a terminal; use them to turn off colours, prompts and progress bars when a program is piped

Console.OutputEncoding

the encoding of standard output; set System.Text.Encoding.UTF8 when you print non-ASCII text (Windows consoles used a legacy code page by default)

Console.Title, WindowWidth, CursorLeft, Clear()

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]

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]

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

AddJsonFile("appsettings.json", optional:=False)

Microsoft.Extensions.Configuration.Json

nested objects become Shelf:PageSize

AddEnvironmentVariables("SHELF_")

Microsoft.Extensions.Configuration.EnvironmentVariables

the prefix is removed and __ (two underscores) means :, so SHELF_Shelf__PageSize is Shelf:PageSize

AddCommandLine(args)

Microsoft.Extensions.Configuration.CommandLine

--Shelf:PageSize=5, /Shelf:PageSize=5 or --Shelf:PageSize 5

AddInMemoryCollection(dictionary)

Microsoft.Extensions.Configuration

tests and defaults

User secrets

Microsoft.Extensions.Configuration.UserSecrets

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]

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]

How the example works, and what is specific to Visual Basic:

  • BackgroundService is an abstract base class with one method to override, ExecuteAsync. The container creates CatalogReport and passes its constructor arguments: a registered interface (ICatalogReader), a logger (ILogger(Of CatalogReport), which the host registers for every type) and the host’s own IHostApplicationLifetime. AddHostedService(Of CatalogReport)() is the VB spelling of C#'s AddHostedService<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, and Main returns 0.

  • Using host = builder.Build() disposes the host (and the singleton services that implement IDisposable) 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:

  • ExecuteAsync is called once when the host starts and must return a Task; it is expected to run until stoppingToken is cancelled. Do not Await something 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: WaitForNextTickAsync returns False after the timer is disposed and throws OperationCanceledException when the token is cancelled, which the Try/Catch above treats as the normal way to stop.

  • An exception that escapes ExecuteAsync stops the host by default (BackgroundServiceExceptionBehavior.StopHost); catch what you can recover from inside the loop.

  • The host waits for StopAsync of every hosted service up to HostOptions.ShutdownTimeout before it gives up; a worker must observe its token in every long-running call.

  • Await in an Async Function works the usual way, including Try/Catch; but not in Catch or Finally blocks (BC36943): see Async and Await.

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= and start= is required by sc.exe.

  • Publish first (dotnet publish -c Release -r win-x64 --self-contained false -o C:\Services\Shelf) and point binPath at the published shelf.exe, the apphost; a service cannot run dotnet run.

  • Run sc.exe from an elevated prompt. The service runs as LocalSystem unless you give obj= and password=: 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 the LogInformation lines of the example are not recorded unless you configure the level in appsettings.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 --contentRoot in binPath, as the documentation shows.

  • On Linux the equivalent is a systemd unit (the Microsoft.Extensions.Hosting.Systemd package adds AddSystemd()) 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

Sub Main or Function Main in a module or class; no top-level statements; no Async Main

top-level statements, static async Task<int> Main

Arguments

Sub Main(args As String()); Command() as a VB extra

args implicitly available in top-level code

Template

console only; no worker, webapi

console, worker, webapi, grpc and more

Strictness

Option Strict is off unless you set it; Dim n As Integer = Console.ReadLine() compiles with it off

always strict

Generic calls

AddHostedService(Of T)()

AddHostedService<T>()

Reserved words

[Option](Of T) for System.CommandLine

Option<T>

Name clashes

imported namespace Microsoft.Extensions.Logging can shadow Console

no namespace import brings child namespaces

Case

console.writeline compiles and is auto-corrected by the editor

case-sensitive

My

absent in console projects (MyType is Empty)

not a concept

Where the books are outdated

Book habit Today on .NET 10

"Console (.NET Framework)" template, Module Module1, "VB is part of the .NET Framework"

dotnet new console -lang VB, Module Program; VB targets .NET 10 through the SDK, with the .NET Framework only in maintenance

MsgBox and InputBox as the first input and output

throw PlatformNotSupportedException in a console project; use Console

Console.ReadLine() assigned straight to an Integer

BC30512 under Option Strict On; use Integer.TryParse

App.config and ConfigurationManager.AppSettings

Microsoft.Extensions.Configuration with JSON, environment variables and the command line

Console.ReadKey() at the end "to keep the window open"

not needed with dotnet run or a terminal; it also throws when input is redirected

Windows Service project with ServiceBase, InstallUtil.exe

a BackgroundService in the Generic Host, AddWindowsService, sc.exe

WebClient, WebRequest in console samples

HttpClient; see Web, HTTP and Services

ASP.NET Core, Blazor or Worker templates "in VB.NET"

there are no VB templates; the Generic Host and BackgroundService work in VB, ASP.NET Core minimal hosting works from code, Razor and Blazor do not

Error and exit-code reference

Diagnostic Meaning (messages are the compiler’s, shown above)

BC30737

No accessible 'Main' method with an appropriate signature was found: Async Function Main, a Main that returns String, or one with the wrong parameters

BC36934

The 'Main' method cannot be marked 'Async'

BC30738

'Sub Main' is declared more than once: two modules with a Main and no StartupObject

BC30420

'Sub Main' was not found: an executable project with no Main

BC30512

Option Strict On disallows implicit conversions: Console.ReadLine() assigned to a number

BC30002

a type from a package is not defined: the PackageReference is missing or restore failed

BC30456

'Computer' is not a member of 'ShelfApp.My': a My member in a console project (see the My page)

NETSDK1100

a net10.0-windows project built on a non-Windows machine without EnableWindowsTargeting

exit code 134 / non-zero

an unhandled exception ended the process

Pitfalls checklist

  • Return an exit code from Main instead of calling Environment.Exit, which skips Finally and Using.

  • Test Console.ReadLine() for Nothing before using the value, and use TryParse for numbers.

  • Print usage errors and diagnostics to Console.Error, results to Console.Out; do not rely on their relative order when both are redirected.

  • Check Console.IsOutputRedirected before 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 synchronous Main with GetAwaiter().GetResult().

  • Do not use MsgBox or InputBox, My.*, App.config or WebClient in new console code.

  • Write System.Console in files that import Microsoft.Extensions.Logging.

  • A worker must pass its CancellationToken to every awaited call, or the host cannot stop it within the shutdown timeout.

  • Do not put secrets in appsettings.json in the repository; use environment variables, user secrets or a secret store.