Your First C# Program: Top-Level Statements and Modern Project Structure

A modern C# console app is one line of code and a three-line project file. Since C# 9 and the .NET 6 templates, top-level statements let you skip the Program class and the Main method. The compiler generates them for you. This guide walks through creating the project, reading every file the SDK produces, and structuring the code so it scales past “Hello, World!”

Quick Takeaways

  • Run dotnet new console and dotnet run to get a working C# program. The template uses top-level statements by default.
  • The compiler wraps your statements in a generated Program class with a Main method. args, await, and return all work.
  • Only one file per project may contain top-level statements. Put types and namespaces after the statements.
  • The .csproj file controls the target framework, ImplicitUsings, and Nullable. Keep it small and explicit.
Approach Boilerplate Best for
Top-level statements None Scripts, tools, learning, small services
Classic Program.Main Class + method Teams that want an explicit entry point
File-based app (dotnet run file.cs) No project file One-off scripts on .NET 10+

Prerequisites and Setup

Install the .NET SDK. This guide targets .NET 10 (LTS) and C# 14. The commands also work on later SDKs. Verify your install:

dotnet --version

The output is the SDK version, for example 10.0.100. If the command is not found, install the SDK from Microsoft and restart your terminal.

Create the Project

Create a folder and scaffold a console app:

dotnet new console -n HelloCSharp
cd HelloCSharp
dotnet run

The dotnet new console template generates three things: Program.cs, HelloCSharp.csproj, and an obj/ folder created on first build. Running dotnet run restores dependencies, compiles, and executes in one step. The terminal prints Hello, World!.

Anatomy of Program.cs

Open Program.cs. The entire file is:

// Program.cs
// Top-level statement: no class, no Main method.
Console.WriteLine("Hello, World!");

There is no using System; because ImplicitUsings is enabled in the project file. Execution starts at the first statement and ends at the last.

What the Compiler Generates

The compiler turns your file into roughly this:

// Conceptual equivalent of what the compiler emits.
using System;

internal class Program
{
    private static void Main(string[] args)
    {
        Console.WriteLine("Hello, World!");
    }
}

The real generated method is named <Main>$. You cannot call it by that name, but the entry point is real. Your statements run inside a static method, so every variable you declare is a local variable.

Anatomy of the .csproj File

The project file is plain XML:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- Produces an executable instead of a library -->
    <OutputType>Exe</OutputType>

    <!-- Target framework moniker -->
    <TargetFramework>net10.0</TargetFramework>

    <!-- Auto-imports common namespaces globally -->
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- Turns on nullable reference type analysis -->
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Property Purpose Recommended
OutputType Exe builds a runnable app; Library builds a DLL Exe
TargetFramework Selects the runtime and API surface Latest LTS
ImplicitUsings Adds global usings for common namespaces enable
Nullable Flags possible null dereferences at compile time enable

For Microsoft.NET.Sdk, implicit usings cover System, System.IO, System.Linq, System.Collections.Generic, System.Net.Http, System.Threading, and System.Threading.Tasks. The SDK writes them to a generated *.GlobalUsings.g.cs file under obj/.

Write a Real First Program

“Hello, World!” teaches nothing about the language. This version reads input, uses a local function, and returns an exit code.

Using the Built-in args Array

// Program.cs
// 'args' is available automatically in top-level code.
string name = args.Length > 0 ? args[0] : "developer";

Console.WriteLine(Greet(name));

// Local function: declared after use, scoped to the generated Main.
static string Greet(string who) => $"Hello, {who}!";

Run it with an argument:

dotnet run -- Ada

The output is Hello, Ada!. The -- separates arguments for dotnet run from arguments for your program. Without an argument, the output is Hello, developer!.

Returning an Exit Code

// Program.cs
if (args.Length == 0)
{
    Console.Error.WriteLine("Usage: HelloCSharp <name>");
    return 1; // Non-zero signals failure to the shell or CI pipeline.
}

Console.WriteLine($"Hello, {args[0]}!");
return 0;

A top-level return with an integer changes the generated Main to return int. Check the result in Bash with echo $?. It prints 1 when you omit the argument.

Using await at the Top Level

// Program.cs
using var http = new HttpClient();

// 'await' works directly: the generated Main becomes async Task.
string html = await http.GetStringAsync("https://example.com");

Console.WriteLine($"Downloaded {html.Length} characters.");

The compiler produces async Task Main when it sees await. The output is the character count of the page. No .Result or .Wait() is needed.

Top-Level Statements Rules and Edge Cases

These rules trigger the most compiler errors for beginners.

One File Per Project

Only one file may contain top-level statements. A second file produces error CS8802. Use that file as your entry point and put everything else in regular classes.

Statements Come Before Type Declarations

// Program.cs
Console.WriteLine(new Counter().Next());

// Types must come AFTER all top-level statements.
class Counter
{
    private int _value;
    public int Next() => ++_value;
}

Placing a statement after a type declaration produces error CS8803. Using directives must come first of all.

Namespaces and the Generated Program Class

Types declared in the top-level file live in the global namespace unless you wrap them in a namespace. The generated Program class is internal. Integration tests with WebApplicationFactory need access to it, so add this to a separate file:

// ProgramMarker.cs
// Exposes the generated Program class to test projects.
public partial class Program { }

Variables Are Locals, Not Fields

Top-level variables cannot be used inside other classes. They are locals of the generated method, though local functions and lambdas can capture them. Move shared state into a class.

Top-Level Statements vs. Classic Main

Feature Top-level statements Classic Main
Lines for Hello World 1 7+
Access to args Implicit Explicit parameter
Async entry point await anywhere async Task Main
Runtime performance Identical Identical
Explicit class control Limited Full
Multiple entry points Not allowed Not allowed (use StartupObject)

Both compile to the same IL shape. Choose top-level statements for readability. Choose a classic Main if your team style guide requires an explicit Program class. Scaffold the classic form with dotnet new console --use-program-main.

Modern Project Structure

A single project is fine for learning. Real work needs a layout that separates source from tests.

MyApp/
├── MyApp.slnx
├── global.json
├── Directory.Build.props
├── .gitignore
├── src/
│   └── MyApp.Console/
│       ├── MyApp.Console.csproj
│       └── Program.cs
└── tests/
    └── MyApp.Tests/
        ├── MyApp.Tests.csproj
        └── CounterTests.cs

Build it with the CLI:

dotnet new sln -n MyApp
dotnet new console -n MyApp.Console -o src/MyApp.Console
dotnet new xunit -n MyApp.Tests -o tests/MyApp.Tests
dotnet sln add src/MyApp.Console tests/MyApp.Tests
dotnet new gitignore
dotnet build

On .NET 10, dotnet new sln creates a .slnx file, the XML-based solution format. Older SDKs create the classic .sln. Both work with dotnet sln add.

File Role
.slnx / .sln Groups projects so one command builds all of them
global.json Pins the SDK version for every developer and CI run
Directory.Build.props Shares properties (Nullable, LangVersion) across all projects
bin/ and obj/ Build output and intermediate files; never commit them

A minimal Directory.Build.props removes duplicated settings:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Every project under that folder inherits these values. Remove the duplicates from each .csproj.

Essential dotnet CLI Commands

Command What it does
dotnet new console Scaffolds a console project
dotnet build Compiles and reports errors
dotnet run Builds and runs the project
dotnet run --project src/MyApp.Console Runs a specific project from the solution root
dotnet test Runs all test projects
dotnet publish -c Release Produces deployable output
dotnet watch Rebuilds and reruns on file changes

Skip the Project: File-Based Apps

.NET 10 added file-based apps. Save this as hello.cs:

// hello.cs
#:package Humanizer@2.14.1

using Humanizer;

Console.WriteLine(TimeSpan.FromMinutes(90).Humanize());

Run it with dotnet run hello.cs. The #:package directive pulls a NuGet package without a .csproj. The output is 1 hour. Convert the file to a full project later with dotnet project convert hello.cs.

Best Practices and Refactoring

Top-level statements make it easy to dump everything into one file. Resist that.

Anti-Pattern: Everything in Program.cs

// BAD: logic, parsing, and formatting tangled together.
var items = new List<string>();
foreach (var line in File.ReadAllLines(args[0]))
{
    var parts = line.Split(',');
    if (parts.Length > 1 && int.TryParse(parts[1], out var qty) && qty > 0)
        items.Add(parts[0] + " x" + qty);
}
foreach (var i in items) Console.WriteLine(i);

This code crashes when args is empty. It cannot be unit tested, and the parsing rules are buried in a loop.

Refactored: Thin Entry Point, Testable Types

// Program.cs (GOOD): orchestration only.
if (args.Length != 1)
{
    Console.Error.WriteLine("Usage: inventory <file.csv>");
    return 1;
}

var parser = new OrderParser();
foreach (string line in parser.Parse(await File.ReadAllLinesAsync(args[0])))
{
    Console.WriteLine(line);
}
return 0;
// OrderParser.cs: pure logic, easy to unit test.
public sealed class OrderParser
{
    public IEnumerable<string> Parse(IEnumerable<string> lines)
    {
        foreach (string line in lines)
        {
            string[] parts = line.Split(',');
            if (parts.Length > 1 && int.TryParse(parts[1], out int qty) && qty > 0)
            {
                yield return $"{parts[0]} x{qty}";
            }
        }
    }
}

The entry point validates input and delegates. OrderParser takes lines and returns strings, so a test needs no file system.

Quick Rules

Anti-pattern Fix
Business logic in Program.cs Move it into classes in separate files
.Result or .Wait() on tasks Use top-level await
Hard-coded paths and values Read from args or configuration
Ignoring exit codes Return 0 for success, non-zero for failure
Disabled nullable analysis Keep <Nullable>enable</Nullable>

Common Build Errors

Error Cause Fix
CS8802 Top-level statements in more than one file Keep them in a single file
CS8803 Statement placed after a type declaration Move statements above all types
CS0103 Name does not exist, often a missing using Add the namespace or enable implicit usings
NETSDK1045 Project targets a newer framework than the installed SDK Install the matching SDK or lower TargetFramework

FAQ

What are top-level statements in C#?

Top-level statements are executable code written directly in a file, without a surrounding class or Main method. The compiler generates a Program class and entry point from them. They arrived in C# 9 and are the default console template since .NET 6.

Can I still use the Main method in C# 14?

Yes. Classic Main is fully supported and compiles to the same IL shape. Run dotnet new console --use-program-main to generate it. Use it if you need explicit control over the entry-point class.

Where do I put classes when using top-level statements?

Put them in separate files, one type per file. If you must keep a type in the entry file, place it after all top-level statements. Types in that file belong to the global namespace unless you declare a namespace.

How do I run a C# program from the command line?

Run dotnet run inside the project folder. From a solution root, use dotnet run --project path/to/Project.csproj. On .NET 10 or later, run a single file with dotnet run file.cs.

Hot this week

Humanoids in Factories and Warehouses: What They Actually Do Today

Which humanoid robots do real work in factories and warehouses today? See deployment data, ROI math and ROS 2 code. Read the 2026 guide.

Debugging Robot Code: Logs, Serial Output, rosbag, and Foxglove

Debug robot code faster with ROS 2 logging, serial capture, rosbag2 recording, and Foxglove. Copy the configs and scripts into your stack today.

Build a Mapping Robot With SLAM From Scratch

Build a SLAM mapping robot from scratch: wiring, odometry, LiDAR, ROS 2 slam_toolbox config and tuning. Follow the full guide and map your first room.

Depth Cameras: Stereo, Time-of-Flight, and Structured Light Compared

Compare stereo, time-of-flight, and structured light depth cameras: accuracy, range, sunlight, ROS2 setup. Pick the right sensor for your robot.

Python vs C++ for Robotics: Which Should You Use?

Python vs C++ for robotics compared: latency, ROS2 performance, control loops, and code examples. Learn which language fits your robot. Read the guide.

Topics

Humanoids in Factories and Warehouses: What They Actually Do Today

Which humanoid robots do real work in factories and warehouses today? See deployment data, ROI math and ROS 2 code. Read the 2026 guide.

Debugging Robot Code: Logs, Serial Output, rosbag, and Foxglove

Debug robot code faster with ROS 2 logging, serial capture, rosbag2 recording, and Foxglove. Copy the configs and scripts into your stack today.

Build a Mapping Robot With SLAM From Scratch

Build a SLAM mapping robot from scratch: wiring, odometry, LiDAR, ROS 2 slam_toolbox config and tuning. Follow the full guide and map your first room.

Depth Cameras: Stereo, Time-of-Flight, and Structured Light Compared

Compare stereo, time-of-flight, and structured light depth cameras: accuracy, range, sunlight, ROS2 setup. Pick the right sensor for your robot.

Python vs C++ for Robotics: Which Should You Use?

Python vs C++ for robotics compared: latency, ROS2 performance, control loops, and code examples. Learn which language fits your robot. Read the guide.

Build a ROS 2 Rover: Raspberry Pi 5, Motors, and LiDAR

Build a ROS 2 rover with Raspberry Pi 5, RPLIDAR C1, and a Pico motor controller. Copy the code, wire it, and map your room. Start building now.

Imitation Learning for Robots: Teach by Demonstration

Learn imitation learning for robots: behavior cloning, DAgger, ACT, and diffusion policies with ROS2 code. Build your first policy today.

LiDAR for Robots: 2D, 3D, and Solid-State Compared

Compare 2D, 3D, and solid-state LiDAR for robots. See specs, ROS2 configs, code, and a selection workflow. Pick the right sensor now.

Related Articles

Popular Categories