Learning C#

A practical C# learning path: from your first line to a reliable .NET program

Build a receipt calculator into a small, reliable application while learning types, methods, collections, LINQ, validation, resources, async, and publishing.

Editorial illustration for A practical C# learning path: from your first line to a reliable .NET program

Learn one complete program before collecting syntax

A useful C# learning path begins with a program you can explain from input to output. Imagine a small receipt calculator: it receives quantities and prices, rejects invalid values, computes a subtotal, applies a stated rounding rule, and prints a result. This modest example contains decisions that appear again in services, desktop applications, and data pipelines. It also gives every new language feature a purpose. A loop processes receipt lines; a method names the calculation; a record describes an item.

Choose a question before opening the editor. What result should three items priced at 12.50 produce? What should happen for zero items? Write the answers in ordinary language, then translate one case into code. During the first sessions, prefer visible calculations to elaborate setup. A successful exercise ends when you can predict the output, deliberately change it, and explain why the change worked. Memorizing every keyword can wait. The immediate goal is to connect each statement to an observable effect.

Separate the language, compiler, runtime, and library

C# is the language in which you express the calculation. A compiler checks the source and produces a compiled representation. The .NET runtime executes managed code, while the base libraries supply familiar types and operations such as strings, collections, dates, and file access. An SDK includes development tools used to create, build, and publish projects. Keeping these roles distinct makes error messages easier to classify: a missing semicolon differs from an unavailable API or a deployment missing its runtime.

For learning, draw a simple pipeline on paper: source, compilation, execution, output. If the compiler rejects the program, execution has not started. If the program throws an exception, the source compiled but an operation failed while running. If a sample depends on a native library, a network, or a filesystem path, its environment matters as much as its syntax. This distinction is especially useful in browser and mobile environments. A small console calculation is portable in ways that launching a child process or accessing an arbitrary machine directory may not be.

Start with top-level statements and a prediction

Top-level statements let a small console program begin directly with executable code. There is still an entry point; the compiler supplies the surrounding structure. Put the statements in one file and read them in order. Type definitions, when needed in the same file, go after those statements. This keeps the first exercise focused on the work rather than a ceremony of classes and entry-point declarations.

Before running the example, predict both lines. Then change the initial quantity and predict them again. Finally move the second statement above the assignment and explain the new result. This tiny experiment teaches sequencing, variable state, and the difference between reading a value and replacing it. Keep a notebook of predictions, including incorrect ones. An incorrect prediction followed by a precise explanation is productive learning. When a program grows, the same habit becomes debugging: form a specific hypothesis, observe one relevant value, and revise the hypothesis from evidence rather than changing several unrelated lines at once.

C# example

using System;

var quantity = 2;
Console.WriteLine(quantity);
quantity = quantity + 1;
Console.WriteLine(quantity);

References: Microsoft Learn: Top-level statements

Use types to make the calculation honest

A variable has a type even when its declaration uses var. Type inference asks the compiler to determine that type from the assigned expression; it does not make the variable dynamically typed. Choosing between an integer count, a decimal price, and a text label expresses what operations make sense. A quantity can be multiplied by a price, while an arbitrary label should not silently become an amount.

For the receipt exercise, use decimal literals with the m suffix. Decimal can represent ordinary base-ten fractions such as 0.1 exactly, whereas binary floating-point types cannot represent every such fraction exactly. Decimal still has finite range and precision, and divisions can require rounding. The important design question is the business rule: are you rounding each line or the final invoice, and which midpoint rule applies? State that rule instead of assuming a numeric type supplies an accounting policy. Practice with a value near a rounding boundary and include the expected result beside the example.

C# example

using System;

var quantity = 3;
var unitPrice = 12.50m;
var subtotal = quantity * unitPrice;
Console.WriteLine(subtotal);

References: Microsoft Learn: Floating-point numeric types

Make conditions describe boundaries

Conditions encode choices, and the difficult part is usually the boundary. If delivery is free at a subtotal of 50, the comparison must include exactly 50. Testing only 12 and 90 leaves the most revealing case unexamined. For every new branch, write three examples: a value below the boundary, the boundary itself, and a value above it. The examples reveal whether greater-than or greater-than-or-equal matches the requirement.

Prefer names that preserve the meaning of a comparison. A variable named qualifiesForFreeDelivery reads more clearly than flag, particularly when more conditions arrive. Avoid compressing several unrelated decisions into a single expression during early practice. First write the cases clearly, then remove duplication if doing so makes the rule easier to see. When the result looks wrong, print the inputs and the intermediate Boolean value. This separates a mistaken input from a mistaken comparison. Later, the same technique helps diagnose authorization checks, date ranges, discount eligibility, and pagination boundaries without guessing which part of the decision failed.

Learn loops by writing their invariant

A loop repeats an operation while preserving something you can state about its progress. In a total calculation, the useful statement is that total contains the sum of the items already visited. Initially no items have been visited, so zero is the natural starting value. Each iteration adds one more price. At the end, every price has contributed exactly once. This description is more valuable than memorizing a loop template.

Use foreach when you want each element and do not need an index. Use an indexed loop when the position itself matters. Practice an empty collection, a single element, and several elements; those cases expose initialization errors and accidental extra iterations. Do not change the collection's structure while enumerating it unless the particular API explicitly permits that operation. If you need a filtered collection, build a separate result or use a query. Write down what makes a loop finish. A program that waits forever often has a condition that never changes or a counter updated along the wrong path.

C# example

using System;

var prices = new[] { 12.50m, 8m, 4.25m };
var total = 0m;
foreach (var price in prices)
{
    total += price;
}
Console.WriteLine(total);

Extract methods around a meaningful question

A method is a useful boundary when it answers a question that can be named. CalculateSubtotal is better than Process because callers can understand the result before reading the implementation. Its parameters should expose required inputs, and its return value should carry the calculated answer. A calculation that reads hidden global state or prints its only result is harder to reuse and harder to check independently.

Start by extracting a pure calculation: given the same quantity and price, it returns the same subtotal. Keep console printing outside that method. Then test the method with normal and boundary values before adding file access or a user interface. Decide what invalid arguments mean. A negative quantity might be a programming error for this method, even if a user typing a negative number is an ordinary validation problem at the application's boundary. That distinction helps place the check. The method can reject an invalid call, while the input layer gives the person a helpful message and a chance to correct it.

C# example

using System;

Console.WriteLine(CalculateSubtotal(3, 12.50m));

static decimal CalculateSubtotal(int quantity, decimal unitPrice)
{
    if (quantity < 0)
        throw new ArgumentOutOfRangeException(nameof(quantity));
    if (unitPrice < 0m)
        throw new ArgumentOutOfRangeException(nameof(unitPrice));
    return quantity * unitPrice;
}

Choose collections from the question you ask

An array is convenient for a known group of values. A list supports a sequence that grows as entries arrive. A dictionary associates a key with a value, and a set represents membership without duplicates. These structures are not interchangeable decorations. If the application repeatedly asks whether a product code exists, a set communicates a different intention from a list that happens to contain product codes.

For the receipt, keep line items in their display order and use a dictionary only if you actually need lookup by a unique key. Decide what a duplicate product code means: merge quantities, keep separate lines, or reject the duplicate. A collection cannot choose that policy for you. Also distinguish an absent key from a key whose stored value is zero. Practice the shape of the result you need before selecting an API. When teaching yourself a new collection, write the operations in plain language: append a line, find a product, remove a cancelled item, and preserve the order shown to the customer.

Add LINQ after you understand the loop

LINQ expresses transformations and questions over sequences. Where selects matching elements, Select transforms each element, and Sum combines numeric values. Reading a query becomes easier after you can write the equivalent loop, because you already understand the data flow. In the example, the filter keeps prices at least ten, and the final sum produces 37. The lambda parameter represents one element at a time.

Do not assume assigning a query to a variable stores its results. Many sequence operators defer their work until enumeration, while terminal operations such as Sum calculate an answer immediately. Materializing with ToArray or ToList creates a collection of the observed results at that moment. That distinction matters if the source changes or is expensive to enumerate. As a first exercise, use a fixed array and predict the filtered elements before predicting their sum. Then add a second enumeration and observe what executes again. Keep side effects out of filtering and projection functions so the query remains understandable when execution timing changes.

C# example

using System;
using System.Linq;

var prices = new[] { 12m, 8m, 25m };
var total = prices.Where(price => price >= 10m).Sum();
Console.WriteLine(total);

References: Microsoft Learn: Introduction to LINQ queries

Represent data with a small, explicit model

When the receipt needs a description, quantity, and price for every line, several parallel arrays become fragile. A line item type keeps those values together. A record is convenient when you want a concise data model with compiler-generated value equality. A class is also appropriate, especially when the object represents identity or manages changing state. Choose based on the behavior you need rather than declaring every new type in the same style.

A positional record provides a compact starting point, but compactness does not automatically establish valid data. If a negative quantity is forbidden, decide where that invariant is checked. Likewise, an init-only property prevents later reassignment of that property; it does not recursively freeze a mutable object stored inside it. For the initial exercise, store simple values and make the subtotal a calculated property. Then explain which values are inputs and which are derived. This prevents the common error of storing both a subtotal and its inputs and allowing them to disagree after an edit.

C# example

using System;

var line = new ReceiptLine("Notebook", 3, 12.50m);
Console.WriteLine($"{line.Description}: {line.Subtotal}");

record ReceiptLine(string Description, int Quantity, decimal UnitPrice)
{
    public decimal Subtotal => Quantity * UnitPrice;
}

References: Microsoft Learn: C# record types

Treat missing data as part of the model

Missing data is ordinary in real applications. A customer may omit a note, a lookup may find no product, and a file may contain an empty field. Nullable reference annotations help express whether a reference is expected to be present. With nullable analysis enabled, string? communicates that null is an allowed possibility, while string communicates an expectation that the reference is non-null. Compiler warnings point to places where the program has not established that expectation.

Respond to the warning by deciding the policy. A missing optional note might display an empty label. A missing product might require a clear validation error. Applying the null-forgiving operator simply suppresses analysis at that location; it does not create a value. In practice exercises, intentionally pass null through a small method and observe the warning before adding the appropriate branch. This teaches the difference between proving that a value exists and merely telling the compiler to stop asking. Keep absence separate from a valid empty string when the application needs to distinguish them.

References: Microsoft Learn: Nullable reference types

Parse input at the boundary

User input begins as text and should become trusted application values only after validation. TryParse-style APIs are useful when invalid text is an expected possibility. They let the program ask whether conversion succeeded without using exceptions as the normal path for a mistyped number. Parsing alone is insufficient: a successfully parsed negative quantity can still violate the receipt's rule, and a very large quantity can exceed a sensible operational limit.

Choose a culture deliberately for data exchanged between programs. A decimal separator that feels obvious to one user can mean something different elsewhere. For a machine-readable example, an explicit invariant culture makes the format predictable. For a user-facing application, the selected locale may be appropriate, but the interface should explain the accepted input. Keep parsing, range validation, and calculation as separate steps. That structure produces specific feedback: an unrecognized number differs from a recognized number outside the permitted range. It also lets tests exercise the calculation without depending on a keyboard, a console prompt, or a device-specific input mechanism.

C# example

using System;
using System.Globalization;

var input = "12.50";
if (decimal.TryParse(input, NumberStyles.Number,
    CultureInfo.InvariantCulture, out var price) && price >= 0m)
{
    Console.WriteLine(price.ToString(CultureInfo.InvariantCulture));
}
else
{
    Console.WriteLine("Enter a non-negative price.");
}

Handle failures where a useful decision is possible

An exception communicates that an operation could not complete normally. Catching every exception immediately and returning zero can turn a visible failure into an apparently valid receipt. A zero total has business meaning; it should not also mean that reading the price file failed. Catch an exception where the application can make a useful decision, such as offering another file, showing a retry action, or terminating with a clear explanation.

Keep the protected region small enough to understand which operation failed. Catch a specific exception when that specificity determines the recovery. If you catch only to add context and then propagate the original failure, use a bare throw inside the catch to preserve the original stack trace. Avoid putting secrets or entire private documents into an error message. For learning, force one failure deliberately and explain the resulting control flow. Which statements ran? Which were skipped? Did the caller receive a result or an exception? Understanding that path is more useful than a blanket rule to wrap every method in try and catch.

References: Microsoft Learn: Exception-handling statements

Give resources an owner and a lifetime

A program can finish a calculation and still misuse a resource. Files, streams, and other disposable objects often need explicit cleanup. A using declaration ties disposal to the end of the surrounding scope, including ordinary exception paths. That is different from waiting for garbage collection. When you read a file, ask which method owns the reader and how long the reader must remain usable.

A subtle mistake is returning deferred work that still depends on a reader which has already been disposed. The caller receives something that looks like a sequence, but enumeration happens after the resource's lifetime ended. Either finish reading into an appropriate collection before leaving the scope, or design an iterator whose lifetime includes the reading operation. Start with the simpler owned collection while learning. Also separate cleanup from business success: closing a stream is necessary even when parsing fails. Once you can explain those ownership boundaries, asynchronous disposal and streaming APIs become much easier to reason about because the underlying question remains who is responsible for releasing what.

References: Microsoft Learn: using statement

Add asynchronous work only where waiting exists

Async and await are useful when an operation can complete later, such as an asynchronous file read or a network request. They allow the calling flow to resume when the result becomes available without blocking a thread for the entire wait. They do not automatically make a calculation faster or run every statement on another thread. A short subtotal method should remain a straightforward synchronous calculation.

When adding asynchronous work, keep its lifecycle visible. Return Task or Task<T> so the caller can await completion and observe failures. Pass a cancellation token when the relevant API supports one, and decide what the interface shows if the person cancels. In a small learning environment, a token-aware delay can demonstrate the flow without relying on an external service. First trace the successful case, then the cancelled case, and finally a deliberate exception. This sequence teaches that asynchronous programming includes ownership and error handling, rather than only adding await before method calls. It also prepares you to keep a user interface responsive without abandoning unfinished work.

References: Microsoft Learn: Asynchronous programming scenarios

Write checks that protect the requirement

The strongest early tests describe behavior in the language of the exercise. For the receipt, test an empty receipt, a single line, several lines, the free-delivery boundary, and the chosen rounding policy. A test that merely repeats the same arithmetic expression as the implementation may reproduce the same mistake. Instead, calculate the expected answer independently and explain why that answer follows from the rule.

Keep pure calculations easy to call. Tests should not need to launch a window or contact a server just to check a subtotal. Use integration checks separately for file formats and external boundaries. When fixing a bug, create a small reproducing example before changing the code; after the fix, the example should pass for a clear reason. Avoid measuring progress by the number of assertions alone. One carefully selected boundary can protect more behavior than many ordinary examples. Also read failure messages as part of the test's design: a useful failure says which rule broke and shows the input that exposed it.

Move from a snippet to a reproducible project

A snippet is excellent for isolating a language idea, but an application also has project settings, dependencies, content files, and a target environment. Move the receipt into a regular .NET project once the core calculation is understood. Keep the initial project small, record how to build and run it, and add dependencies only when the application needs a capability that the existing code and libraries do not provide.

Publishing prepares application output for distribution, and the selected mode affects the destination's requirements. A framework-dependent deployment expects a compatible .NET runtime on the destination. A self-contained deployment includes the runtime for its selected target. Native dependencies and platform APIs still require attention. Do not treat a successful run on your computer as proof that every target environment is supported. Build the artifact you intend to distribute and run a representative check in that environment. For browser and mobile sandboxes, consult their supported capabilities before assuming that filesystem access, processes, native code, or arbitrary packages behave like a desktop console application.

References: Microsoft Learn: .NET application publishing

Follow a weekly cycle of building, explaining, and revising

A practical study cycle has three outcomes: a working program, a written explanation, and a new question. During the first week, build the receipt with variables, conditions, loops, and methods. During the next, introduce collections, records, and validation. Then add a saved data format and a failure path. Finally explore asynchronous loading and cancellation in an environment that supports them. These are suggested milestones, not deadlines; repeat a stage when your explanation is still uncertain.

At the end of each session, change one requirement. Add a discount with a precise boundary, permit a missing note, or report invalid input without losing valid lines. Explain the change to an imaginary teammate who has not seen the code. If the explanation requires pointing at an unexplained flag or hidden assumption, improve the name or structure. The durable skill is the ability to translate a requirement into observable behavior and then check that behavior. New syntax, frameworks, and deployment tools will keep arriving; that cycle gives you a stable way to learn them.

Sources & further reading

Technical references checked against primary documentation. Browser playground examples target the SharpRune .NET 9 profile; production server guidance describes a separate hosting environment.

Illustration created with ImageGen for SharpRune. Article and examples are educational; validate behavior in your target runtime.
Put an idea into practice