Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 1 · 1. Foundations, Tooling, and Your First C# Programs · Lesson 1 of 24

    Your First C# Program: Project Structure, Build, and Run

    Learning outcomes

    Explain what a .NET project contains, trace the build-and-run cycle, and create a small console program without treating the tooling as magic. Run a calculator with known input, distinguish standard output from errors, and check the process exit code.

    First-pass route

    Start with Invoice18. Read the project mental model, follow the exact setup, copy the complete program, then use the build-and-run section to try 1250.50 and abc. Your first goal is to explain which files you edit, which step builds, which step runs, and how those two inputs take different paths.

    This is a defensive starter with parsing, rounding, culture settings, and exception handling; you are not expected to invent all of it yet. CultureProbe and InvoiceRate are optional on a first pass. Return to them after you can build and run Invoice18, and use the common-mistakes section to review.

    The project mental model

    A C# application is usually organized around a project file and source files. The project file records the target framework and build settings. In this lesson, Program.cs contains the top-level statements that become the entry point; that filename is a convention, not a language requirement.

    The SDK supplies development tools; the runtime executes the compiled application. Building does not start this calculator. Running starts it, after a build unless you choose --no-build. Restore resolves the project's dependencies; it can still create assets under obj even when the project has no package references. Build output goes under bin and intermediate files under obj. Edit the source and project, not those generated folders.

    The usual workflow remains: dotnet new console creates a project; dotnet restore resolves dependencies; dotnet build compiles and reports diagnostics; dotnet run builds and launches; dotnet test runs tests in a configured test project. Later in this lesson, our dependency-free checks are a console program invoked with dotnet run, not a test-runner project.

    Exact setup: SDK, folder, and files

    Use an already installed .NET 10 SDK, including its net10.0 reference pack and runtime. A runtime-only installation cannot compile this project. Check:

    Text
    dotnet --version
    dotnet --list-sdks

    The selected SDK should be 10.0.x for this lab. No specific patch is forced with global.json. These exercises require no third-party packages, package feeds, or installer. If .NET 10 is missing, stop here rather than assuming a successful run.

    Create a folder named FirstProgramLab. Inside it, create Invoice18/Invoice18.csproj and Invoice18/Program.cs. Put this NuGet.Config alongside the Invoice18 folder:

    XML
    <?xml version="1.0" encoding="utf-8"?>
    <configuration>
      <packageSources><clear /></packageSources>
    </configuration>

    Put exactly this project file in Invoice18/Invoice18.csproj:

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <LangVersion>14.0</LangVersion>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <NuGetAudit>false</NuGetAudit>
      </PropertyGroup>
    </Project>

    The project explicitly selects net10.0, C# 14, nullable checking, and implicit standard namespace imports. TreatWarningsAsErrors is this lab's chosen policy. It is not a claim that every C# warning always prevents compilation. The next lesson compares both policies.

    If you prefer a template, run the command below from FirstProgramLab, then replace its generated project and Program.cs with the exact versions shown here. Do not append a second top-level program to the generated Hello World code.

    Text
    dotnet new console --name Invoice18 --output Invoice18 --framework net10.0 --no-restore

    Guided first program: Invoice18

    Put this complete source in Invoice18/Program.cs. It keeps the original calculator's 18% tax and explicitly rounded decimal calculation, while making output culture and arithmetic failure behavior reproducible. The fixed 18% rate is fictional teaching data, not tax or legal guidance.

    C#
    using System.Globalization;
    
    Console.Write("Enter an invoice amount: ");
    
    if (!decimal.TryParse(
            Console.ReadLine(),
            NumberStyles.Number,
            CultureInfo.InvariantCulture,
            out var amount) ||
        amount < 0)
    {
        Console.Error.WriteLine("Enter a non-negative number such as 1250.50.");
        return 1;
    }
    
    try
    {
        var tax = decimal.Round(amount * 0.18m, 2, MidpointRounding.AwayFromZero);
        var total = amount + tax;
        Console.WriteLine(FormattableString.Invariant($"Subtotal: {amount:F2}"));
        Console.WriteLine(FormattableString.Invariant($"Tax:      {tax:F2}"));
        Console.WriteLine(FormattableString.Invariant($"Total:    {total:F2}"));
        return 0;
    }
    catch (OverflowException)
    {
        Console.Error.WriteLine("Amount is too large for this calculation.");
        return 2;
    }

    The amount guard returns before any calculation on invalid input. The calculation creates the tax and total before printing the report, so overflow cannot leave a half-printed invoice. The prompt has already been written. Return 0 means success; this program chooses 1 for invalid input and 2 for arithmetic overflow. Those meanings are this program's contract, not universal meanings for those numbers.

    Top-level statements become an implicit entry point. They run in order. Returning an integer supplies this process's exit code. Larger applications move behavior into methods and types while keeping an entry point.

    We keep decimal for this money-oriented example and TryParse for recoverable input. Decimal does not eliminate overflow or decide business rounding rules. Here tax alone is rounded to two places, with exact midpoints rounded away from zero. F2 chooses the displayed precision; it does not change the stored amount. A real invoice policy must separately decide whether to accept amounts with more than two decimal places.

    Build, then run the known artifact

    From FirstProgramLab, run each command separately and stop if restore or build fails:

    Text
    dotnet restore Invoice18/Invoice18.csproj --configfile NuGet.Config
    dotnet build Invoice18/Invoice18.csproj -c Release --no-restore
    dotnet run --project Invoice18/Invoice18.csproj -c Release --no-build --no-launch-profile

    Successful restore/build should exit 0. SDK banners, paths, elapsed times, and detailed build text can vary; they are not part of the application's expected output. --no-build deliberately runs the Release artifact just built. After changing source, build again first. Without that option, dotnet run normally builds before starting.

    Type 1250.50 and press Enter. A terminal with input echo shows:

    Text
    Enter an invoice amount: 1250.50
    Subtotal: 1250.50
    Tax:      225.09
    Total:    1475.59

    The typed input and its echoed newline belong to the terminal, not the program's stdout. With redirected stdin containing the bytes 1250.50 followed by a newline, captured stdout is exactly:

    Text
    Enter an invoice amount: Subtotal: 1250.50
    Tax:      225.09
    Total:    1475.59

    Captured stderr is empty and exit is 0. The prompt uses Write rather than WriteLine, which explains the shared first line. To inspect a process's exit code, run echo $? immediately afterward in a POSIX shell, or $LASTEXITCODE in PowerShell. Do not run another command first.

    For input abc followed by a newline, captured stdout is only the prompt with its trailing space and no newline. Stderr is:

    Text
    Enter a non-negative number such as 1250.50.

    Exit is 1; there is no subtotal, tax, or total. A negative number or immediate end-of-input follows the same path. The largest decimal value, 79228162514264337593543950335, parses but overflows when tax is added: stdout is only the prompt, stderr is Amount is too large for this calculation. followed by a newline, and exit is 2. A value one greater than that fails parsing instead and exits 1.

    Optional deeper dive: parsing culture and output culture

    TryParse receives InvariantCulture, but plain interpolated output normally uses CurrentCulture. The original calculator specified only the first boundary. FormattableString.Invariant now makes the three report lines invariant too.

    This lab retains NumberStyles.Number. It accepts grouping separators: use 1250.50 or 1,250.50, with a dot for decimals. A comma is not an invariant decimal separator. Do not enter a local comma-decimal amount and assume it means the same value. A stricter input contract can omit AllowThousands, as the console-design lesson does.

    To see the two boundaries without relying on your machine's locale, create CultureProbe/CultureProbe.csproj using the same project file above, and put this complete source in CultureProbe/Program.cs:

    C#
    using System.Globalization;
    
    var commaCulture = (CultureInfo)CultureInfo.InvariantCulture.Clone();
    commaCulture.NumberFormat.NumberDecimalSeparator = ",";
    CultureInfo.CurrentCulture = commaCulture;
    
    var parsed = decimal.TryParse("1250.50", NumberStyles.Number,
        CultureInfo.InvariantCulture, out var amount);
    Console.WriteLine($"Parsed: {parsed}");
    Console.WriteLine($"Current-culture output: {amount:F2}");
    Console.WriteLine(FormattableString.Invariant($"Invariant output: {amount:F2}"));

    Restore and build CultureProbe by substituting its project path in the commands above, then run it with --no-build. It reads no input and expects empty stderr, exit 0, and:

    Text
    Parsed: True
    Current-culture output: 1250,50
    Invariant output: 1250.50

    This tiny process changes its own current culture to a cloned culture with a comma decimal separator. It does not change operating-system settings. The number parsed from the invariant input is unchanged; only the first formatting call uses the altered convention.

    Optional worked extension: a user-supplied tax rate

    The original exercise asks for a second input between 0 and 100, an extracted calculation, and checks for zero, normal, and midpoint amounts. Try it before reading this solution.

    Create InvoiceRate/InvoiceRate.csproj using the same project file, then InvoiceRate/Program.cs as follows. The method protects its own contract because callers other than the console can eventually use it. The small check runner invokes that exact method; it does not copy the calculation into the expected answers.

    C#
    using System.Globalization;
    
    if (args is ["--self-test"])
        return RunChecks();
    if (args.Length != 0)
    {
        Console.Error.WriteLine("Usage: InvoiceRate [--self-test]");
        return 2;
    }
    
    Console.Write("Enter an invoice amount: ");
    if (!TryNumber(Console.ReadLine(), out var amount) || amount < 0)
    {
        Console.Error.WriteLine("Enter a non-negative amount such as 1250.50.");
        return 1;
    }
    Console.Write("Enter a tax rate (0-100): ");
    if (!TryNumber(Console.ReadLine(), out var rate) || rate is < 0 or > 100)
    {
        Console.Error.WriteLine("Enter a rate from 0 to 100, such as 18.");
        return 1;
    }
    
    try
    {
        var tax = CalculateTax(amount, rate);
        var total = amount + tax;
        Console.WriteLine(FormattableString.Invariant($"Subtotal: {amount:F2}"));
        Console.WriteLine(FormattableString.Invariant($"Tax:      {tax:F2}"));
        Console.WriteLine(FormattableString.Invariant($"Total:    {total:F2}"));
        return 0;
    }
    catch (OverflowException)
    {
        Console.Error.WriteLine("Amount is too large for this calculation.");
        return 2;
    }
    
    static bool TryNumber(string? raw, out decimal value) =>
        decimal.TryParse(raw, NumberStyles.Number,
            CultureInfo.InvariantCulture, out value);
    
    static decimal CalculateTax(decimal amount, decimal rate)
    {
        if (amount < 0)
            throw new ArgumentOutOfRangeException(nameof(amount));
        if (rate is < 0 or > 100)
            throw new ArgumentOutOfRangeException(nameof(rate));
        return decimal.Round(amount * (rate / 100m), 2,
            MidpointRounding.AwayFromZero);
    }
    
    static int RunChecks()
    {
        var failures = 0;
        Check("zero amount", 0m, 18m, 0m);
        Check("normal amount", 1250.50m, 18m, 225.09m);
        Check("midpoint away from zero", 0.25m, 18m, 0.05m);
        Check("zero rate", 10m, 0m, 0m);
        Check("full rate", 10m, 100m, 10m);
        ExpectRange("negative amount", -1m, 18m, "amount");
        ExpectRange("negative rate", 10m, -1m, "rate");
        ExpectRange("rate above 100", 10m, 101m, "rate");
        Console.WriteLine($"Checks: {8 - failures}/8 passed");
        return failures == 0 ? 0 : 1;
    
        void Check(string name, decimal amount, decimal rate, decimal expected)
        {
            var actual = CalculateTax(amount, rate);
            var passed = actual == expected;
            if (!passed) failures++;
            Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {name}");
        }
    
        void ExpectRange(string name, decimal amount, decimal rate, string parameter)
        {
            var passed = false;
            try { CalculateTax(amount, rate); }
            catch (ArgumentOutOfRangeException ex) { passed = ex.ParamName == parameter; }
            if (!passed) failures++;
            Console.WriteLine($"{(passed ? "PASS" : "FAIL")} {name}");
        }
    }

    The rate is a percentage, so 18 means 18%. Dividing the rate by 100 before multiplying avoids needlessly multiplying the amount by 18 first. Total can still exceed decimal's range, which the shell reports. The self-test mode is selected before any console input, making those checks noninteractive.

    Restore and build InvoiceRate like Invoice18, then run:

    Text
    dotnet run --project InvoiceRate/InvoiceRate.csproj -c Release --no-build --no-launch-profile -- --self-test

    Expected stdout is shown below; stderr is empty and exit is 0:

    Text
    PASS zero amount
    PASS normal amount
    PASS midpoint away from zero
    PASS zero rate
    PASS full rate
    PASS negative amount
    PASS negative rate
    PASS rate above 100
    Checks: 8/8 passed

    Without --self-test, enter 1250.50 then 18 on separate lines. The results remain subtotal 1250.50, tax 225.09, total 1475.59. With amount 0.25 and rate 18, unrounded tax is 0.045, rounded tax is 0.05, and total is 0.30. With amount 20 and rate 0, tax is 0.00; with rate 100, tax is 20.00. Rate -1, 101, text, or missing second input produces the rate error and exit 1 before calculation. Unexpected command-line arguments print the usage line and exit 2.

    The complete appendix contains every project, input/output fixture, and command. It also distinguishes these expected transcripts from the SDK execution receipt; a prediction is not a test result.

    Common mistakes and interview check

    • Editing bin or obj instead of rebuilding from source
    • Calling decimal.Parse for normal user mistakes, then treating those mistakes as unexpected failures
    • Assuming invariant parsing also selects invariant formatting
    • Ignoring warning policy, stderr, or a non-zero exit code
    • Running --no-build after editing source and accidentally checking an old binary
    • Calling a console assertion runner a dotnet test project

    Explain the SDK/runtime distinction, the role of the project file, how the guard clause changes control flow, and why stdout, stderr, and exit code are three separate observable outputs. Then predict what changes if the tax method is called by an API rather than the console: the calculation contract stays, while parsing and presentation move to the new boundary.

    References

    Official API and tooling references support the small language/tooling notes; the calculator, exercises, and expected cases are original teaching examples.

    Analogy

    Everyday picture

    Imagine a label-printing workshop. A design sheet says what a label should contain, and a job card describes the setup. Preparation tools produce a ready-to-use job. Pressing Start later operates the printer; revising the design sheet does not update a job that was already prepared.

    Mapping. The source and project files are the design sheet and job card. The SDK's build tools prepare the job; the runtime carries it out. Rebuild after editing before using this lesson's --no-build run command.

    Where it stops. Preparing the job does not operate the printer. In this lab, the build step neither asks for an amount nor prints the calculator result. The story does not explain parsing, rounding or overflow; follow those paths in the actual program.

    Cheat sheet (PDF)

    csharp-first-program-build-run-companion.pdf18 pages · 95 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in