Chapter 1: Welcome to C# and .NET

C# sits neatly in the family of modern, strongly typed languages that aim to make software development clear, expressive, and safe. It began life within the wider .NET ecosystem, which provides a broad foundation of libraries, tools, and runtime features that help developers build everything from console utilities to large web services. This chapter prepares your environment, introduces the tools, and guides you through your first working program.

What C# is and where it fits

C# is a general purpose programming language created by Microsoft and shaped over several generations of improvements. It blends object oriented design, expressive functional constructs, and a memory model that reduces many common errors. It runs on the .NET platform, which is an open source and cross platform framework supported on Windows, macOS, and Linux.

The language is used for desktop apps, server side systems, cloud tools, games, mobile apps, command line utilities, and more. You can think of it as a tool with several edges. It handles high level patterns with clean syntax and also supports lower level control when needed.

πŸ’‘ C# and .NET are fully open source at https://github.com/dotnet which means you can explore the runtime source code to understand exactly how platform features work.

How C# relates to C

C# looks as if it grew from the same branch as C since the surface features share familiar shapes. Braces group code, semicolons complete statements, and control structures such as if, while, and for follow a pattern that echoes the older language. These shared elements help newcomers feel at home when they first read C# code because the visual style is recognisable.

The deeper structure is different. C works directly with memory, pointers, and manual resource handling while C# lives inside the managed .NET runtime which handles allocation and cleanup automatically. This means many of the tricky parts of low level programming simply do not appear in C#. Features such as pattern matching, properties, async and await, delegates, generics, and the LINQ query model sit far outside the scope of traditional C. They emerge from a different design philosophy that focuses on safety, clarity, and expressiveness.

You can think of C# as a language that keeps the outer style of the C family while running on a far more structured and protective foundation. The result feels familiar at first glance although the way you write and reason about programs soon diverges from the C approach.

Installing the .NET SDK and the CLI

To write and run C# programs you need the .NET SDK. It contains the compiler, the dotnet command line interface, and the base class libraries. Installation is simple and consistent across supported platforms.

You can download installers or packages directly from the official site at dotnet.microsoft.com/download. After installation, open a terminal and check that everything is ready by running the following command.

dotnet --version

This prints the SDK version number. If you see a valid version string, your setup is complete. The CLI is your primary tool for creating projects, restoring dependencies, building code, and running applications.

⚠️ Some Linux distributions provide their own .NET builds. These can lag behind the official releases and occasionally introduce packaging differences. The official installers are usually the most reliable choice.

Your first console application

Every new language feels calmer once you have created a small working program. The dotnet CLI includes a template system that makes this process simple.

Start by creating a new folder for your project, then run the following commands.

mkdir HelloWorld
cd HelloWorld
dotnet new console

The template generates a small program file named Program.cs. Open it in your editor. You will see a structure similar to this.

Console.WriteLine("Hello world");

You can run your program immediately.

dotnet run

This prints the greeting text to the console. Although the project contains more files than this single line of code, the default layout keeps everything organised so you can grow the project later.

Project structure and the build process

Every .NET project generated by the CLI follows a predictable pattern. The root directory contains a project file with the extension .csproj. This file describes the project type, dependencies, target frameworks, and build settings. Your source files sit alongside it or in subfolders. During compilation the CLI gathers all source files, resolves dependencies, and produces a build output in the bin directory.

A typical layout looks like this.

HelloWorld/
  β”œβ”€ Program.cs
  β”œβ”€ HelloWorld.csproj
  β”œβ”€ bin/
  └─ obj/

The obj directory holds intermediate files used during the build while bin stores the compiled output. When your projects become larger this structure keeps the noise away from your source tree.

Source files, namespaces, and using directives

C# code is organised into namespaces. A namespace acts like a folder system for types which helps avoid naming conflicts and keeps related concepts close together. Within a file you can either declare a namespace explicitly or rely on modern file scoped syntax.

When you need types from other namespaces, you import them using using directives. This keeps code readable and reduces repetition. For example, using System allows you to refer to Console directly rather than typing the fully qualified name each time.

πŸ’‘ You can group related files into folders without affecting their namespaces. Folder structure and namespace structure are separate concepts although many developers follow a matching pattern.

Chapter 2: Language Basics

This chapter explores the small building blocks that make C# code readable, structured, and precise. You will see how the language divides text into tokens, how identifiers are formed, and how keywords shape control flow. These pieces form the grammar of the language and give you the vocabulary that later chapters expand upon.

Tokens, identifiers, and keywords

Every C# program begins life as a stream of characters. The compiler groups these characters into tokens which are the smallest meaningful parts of the language. Tokens include names, operators, literals, punctuation symbols, and keywords. You can imagine them as the tiles of a mosaic that the compiler later arranges into deeper meaning.

Identifiers are names you create for variables, methods, classes, and anything else you define. They can contain letters, digits, and underscores although they cannot begin with a digit. Keywords are reserved words such as if, class, return, and using which carry fixed meanings. They cannot be used as identifiers unless you prefix them with @ which allows special cases such as @class when needed.

πŸ’‘ The compiler ignores whitespace. You are free to add line breaks and spaces wherever clarity improves the shape of the code.

Statements and expressions

A statement tells the program to perform an action. Common examples include variable declarations, assignments, loops, and method calls. Statements usually end with a semicolon except for compound statements which are grouped inside braces.

An expression produces a value. For example, 2 + 3 is an expression that yields the number five. Some expressions also have side effects such as modifying a variable. When a statement contains an expression, the compiler evaluates the expression first and then applies the meaning of the surrounding statement.

You can think of statements as instructions and expressions as calculations. Both work together to describe the behaviour of your program.

Variables, constants, and scope

Variables hold values that may change during the lifetime of a program. You declare them with a type and a name such as int count. Constants are values that cannot change once assigned and are declared using the const keyword. This helps prevent accidental modification and makes the intent clear.

Scope defines where a name is visible. A variable declared inside a block is available only within that block while a variable declared at the top of a class is available to all members of that class. Good scoping keeps code clean and makes accidental shadowing less likely.

Literals and string interpolation

Literals represent fixed values such as numbers, characters, strings, and boolean values. They appear directly in your code. For example, 42, "hello", and true are all literals. Numeric literals can include separators such as 1_000_000 to improve readability.

String interpolation allows you to embed expressions inside strings using the $ prefix. This technique makes it easy to compose meaningful output without manual string concatenation. For example, $"The total is {total}" inserts the value of total into the string at runtime.

⚠️ Interpolated expressions can perform any computation inside the braces although complicated logic can make the string harder to read.

Nullable reference types

Nullable reference types help you avoid unexpected null values. When this feature is enabled, all reference types are non null by default which means the compiler warns you if you forget to initialise something. If a reference can legitimately hold null, you mark it with ?. For example, string? note means the variable may or may not contain a valid string.

This feature encourages cleaner design and clearer intent. It shifts a common source of runtime errors into the compile phase where mistakes are easier to catch.

Coding style and naming conventions

C# has a widely followed style that aims to keep code predictable. Method names, property names, and class names use PascalCase while variables and parameters use camelCase. Braces typically appear on their own line. Indentation uses spaces rather than tabs although the number of spaces depends on project settings.

Consistent naming builds trust in the structure of the code. When readers recognise patterns they no longer need to guess. This frees the mind to focus on behaviour rather than formatting. Many teams rely on built in analyzers to enforce style rules which helps large projects remain tidy as they grow.

Chapter 3: Types and Memory Model

C# uses a type system that blends clarity with safety. Some types live directly on the stack while others are managed on the heap by the runtime. Understanding this split helps you predict performance, memory behaviour, and the cost of certain operations. This chapter walks through the major categories, their rules, and the patterns they encourage.

Value types vs reference types

Value types store their data directly. When you assign one value type to another, the data is copied. Types such as int, double, bool, and most struct types belong to this category. They often live on the stack which keeps access fast and allocation predictable.

Reference types store a reference to data that lives elsewhere. Classes, arrays, delegates, and most objects fall into this group. When you assign a reference type variable to another variable you copy the reference rather than the underlying object. This means both variables point to the same data.

πŸ’‘ You can think of value types as postcards and reference types as postcards containing directions to a house. Copying a postcard gives someone new directions but copying the house would be far more expensive.

Built-in numeric types and ranges

C# provides a family of numeric types arranged by size and purpose. These include signed and unsigned integers, floating point types, and high precision decimals. Each type has a defined range and size which helps the compiler catch accidental overflow or misuse.

A common subset includes byte, short, int, long, float, double, and decimal. Integers suit counting and indexing while floating point types handle scientific and graphics calculations. The decimal type provides extra precision for financial work.

Type Size Typical use
int 32 bits General integer work
long 64 bits Larger integer ranges
double 64 bits Floating point calculations
decimal 128 bits Financial precision

Choosing the correct numeric type is often a balance between range, performance, and precision.

Strings, chars, and immutability

Strings in C# are immutable. Once created, their contents cannot change. Operations that appear to modify a string actually create a new one. This feature helps avoid subtle bugs and keeps code predictable although it means repeated string manipulation can become expensive.

The char type represents a single UTF-16 code unit which is not always identical to a complete character since some Unicode characters require multiple units. When you need flexible text manipulation, consider types such as StringBuilder which can grow and change more efficiently.

Nullable value types

Value types normally cannot hold null. However, nullable value types allow you to represent the absence of a value by wrapping the type in ?. For example, int? represents an integer that may or may not be present. This is useful when values are optional or when working with data from external sources.

You can test nullable values using the HasValue property or by checking against null. When accessing the underlying value you use the Value property or the null coalescing operator to provide a fallback.

⚠️ Accessing Value when the nullable holds no data results in a runtime exception. Using the null coalescing operator is usually safer.

Records and with-expressions

Records provide a concise way to define immutable types that represent data. They automatically generate useful behaviour such as value-based equality and string representations. A record can define its properties in a compact form that makes intent easy to read.

With-expressions allow you to create a new record based on an existing one while changing specific properties. This approach keeps immutability intact and avoids the need for large constructors when only one or two fields change.

Span, ReadOnlySpan, and memory safety

Span<T> and ReadOnlySpan<T> provide views over contiguous memory areas without creating new arrays or copying data. They make high performance operations possible while keeping safety checks in place. These types can point into arrays, stack allocated memory, or even slices of larger structures.

Because they refer directly to memory regions the runtime restricts their usage so they cannot escape into long lived objects. This rule protects you from dangling references. In practice, Span<T> helps you write code that behaves like low level memory manipulation while retaining the guardrails of the managed environment.

Chapter 4: Operators and Conversions

Operators are the building blocks of expressions in C#. They combine values, variables, and method calls to produce new values. Conversions change a value from one type to another; some are automatic, some require an explicit cast. This chapter explains common operators, how expressions are evaluated, how overflow is handled, and how to add your own operators in custom types.

C# Operators

Arithmetic operators work with numeric types; comparison operators evaluate to a bool; logical operators combine or invert bool values. C# also includes bitwise operators for integral types. Each group has predictable rules and edge cases worth knowing.

The Arithmetic operators: +, -, *, /, %

As you would expect + adds, - subtracts, * multiplies, / divides, and % gives the remainder. For integer types, / truncates toward zero; for floating-point types it performs real division. The remainder operator works with integers and floating-point types, although floating-point remainders can be surprising when values include rounding error.

int a = 7, b = 3;
int q = a / b;     // 2
int r = a % b;     // 1

double x = 7, y = 3;
double d = x / y;  // 2.33333333333333…
⚠️ Floating-point math can accumulate rounding error; avoid equality checks on double and float. Prefer comparisons within a tolerance.

The increment, decrement and unary operators: ++, --, +, -

Increment and decrement adjust integral or floating-point values by one. Prefix form updates then yields the updated value; postfix yields the original value then updates. Unary - negates; unary + is a no-op used for readability.

int n = 5;
int a1 = ++n;  // n = 6, a1 = 6
int a2 = n--;  // n = 5, a2 = 6

The comparison operators: ==, !=, <, <=, >, >=

Comparison operators return true or false. For numeric types the meaning is straightforward. For reference types, == and != may be overloaded to compare values; otherwise they compare references. The string type defines value semantics for ==, so two distinct instances with the same contents compare as equal.

string s1 = new string("hello");
string s2 = new string("hello");
bool eq = (s1 == s2);  // true (string value equality)

The logical operators: &&, ||, ! (short-circuit logical)

&& evaluates the right-hand side only if the left-hand side is true; || evaluates the right-hand side only if the left-hand side is false. ! negates a boolean value. Short-circuiting is essential for guarding computations that would otherwise throw exceptions.

if (s != null && s.Length > 0) Console.WriteLine(s[0]);
πŸ’‘ Use short-circuiting to avoid null dereferences; evaluate the cheap and safe condition first.

The bitwise operators: &, |, ^, ~, <<, >>

Bitwise operators act on integral types and bool. &, |, and ^ combine bits; ~ inverts bits; << and >> shift left or right. On bool, & and | are non-short-circuiting logical operations.

int flags = 0b_0010;
flags |= 0b_1000;                     // set bit 3
bool both = true & ExpensiveCheck();  // always calls ExpensiveCheck()
⚠️ & and | on bool do not short-circuit; prefer && and || when you intend short-circuit logic.

Assignment and compound assignment

Assignment uses = to store the right-hand value into the left-hand variable or property. Compound assignment applies an operator and assigns in one step; it can improve readability and sometimes performance with properties or indexers.

The = operator and basic compound forms

Every compound operator op= corresponds to an operator op; examples include +=, -=, *=, /=, %=, &=, |=, ^=, <<=, and >>=.

int total = 0;
total += 5;   // total = 5
total *= 2;   // total = 10

The Null-coalescing and assignment operators: ??, ??=

expr1 ?? expr2 yields expr1 when it is non-null; otherwise it yields expr2. x ??= y assigns y to x only when x is null, then yields the resulting value.

string? name = null;
name ??= "Anonymous"; // "Anonymous"
var display = name ?? "Guest";

Deconstruction and tuple assignment

Tuples support parallel assignment and deconstruction into existing variables or placeholders. Use _ for fields you intend to ignore.

(int x, int y) = (3, 4);
(int _ , int top) = GetBounds(); // ignore the first value

Operator precedence and associativity

Precedence determines which operators bind more tightly in expressions; associativity determines how operators of the same precedence group when there are no parentheses. When in doubt, use parentheses for clarity; the compiler and your readers benefit.

Selected precedence overview

The following table lists common groups in order from higher to lower precedence. Items on the same row associate as shown.

Group Examples Associativity
Primary x.y, x[y], f(…), new, checked Left to right
Unary +, -, !, ~, ++, --, await, sizeof, typeof Right to left
Multiplicative *, /, % Left to right
Additive +, - Left to right
Shifts <<, >> Left to right
Relational <, <=, >, >=, is, as Left to right
Equality ==, != Left to right
Logical AND && Left to right
Logical OR || Left to right
Null-coalescing ?? Right to left
Conditional cond ? a : b Right to left
Assignment =, +=, *=, &=, ??=, … Right to left

A tight operator like * binds before a looser operator like +. Therefore 2 + 3 * 4 is 14, not 20. Parentheses always make intent explicit.

πŸ’‘ Overloading an operator changes behavior for a type; it does not change the precedence level of that operator.

Checked and unchecked arithmetic

Overflow in integral arithmetic wraps around by default in unchecked context. A checked context raises OverflowException on overflow for integral arithmetic and integral conversions. You can apply checked or unchecked to an expression or a block.

int x = int.MaxValue;
int wrap = x + 1;           // wraps to int.MinValue (unchecked by default)
int fail = checked(x + 1);  // throws OverflowException
checked {
  byte b = 255;
  b += 1;                   // OverflowException
}
⚠️ checked affects integral arithmetic and conversions; it does not affect float or double which follow IEEE rules and never throw on overflow.

Implicit and explicit conversions

An implicit conversion is applied automatically when it is guaranteed to succeed without data loss; an explicit conversion requires a cast because it can fail or lose information. C# also supports user-defined conversions using implicit and explicit operators on your types.

Numeric widening and narrowing

Widening conversions move to a type that can represent every value of the source type; narrowing conversions risk overflow or precision loss. Widening is implicit; narrowing requires a cast.

int i = 123;
long L = i;          // implicit (widening)
short s = (short)i;  // explicit (narrowing)

Reference, nullable, and enum conversions

Reference conversions include upcasts within an inheritance chain and interface conversions; these are implicit. Downcasts require a cast and can fail at runtime. Nullable conversions add or remove ? while preserving or unwrapping the underlying value. Enums convert to and from their underlying integral type with explicit casts.

Stream s = new MemoryStream();     // implicit upcast
MemoryStream m = (MemoryStream)s;  // explicit downcast, may throw if not a MemoryStream

int? nx = 42;
int ny = nx ?? 0;                  // coalesce to non-nullable

enum Color : byte { Red = 1, Green = 2 }
byte b = (byte)Color.Red;          // explicit to underlying type

as, pattern matching, and safe casts

as performs a reference or nullable conversion; it yields null on failure rather than throwing. Pattern matching with is can test and capture the converted value in one step.

object o = "hi";
var ms = o as MemoryStream;  // null (no exception)
if (o is string s1 && s1.Length > 0) Console.WriteLine(s1);

Parsing and formatting

Converting between text and numbers is not an operator feature; it is very common in real programs. Use TryParse to avoid exceptions when input may be invalid.

if (int.TryParse("123", out int v)) Console.WriteLine(v);
string formatted = $"{DateTime.UtcNow:O}";

User-defined operators

Structs and classes can overload many operators by declaring public static methods with the operator keyword. You can also define user-defined conversions using implicit or explicit. Choose overloading when it makes code clearer; avoid surprising semantics.

Overloading arithmetic and comparison

Overloadable operators include arithmetic, bitwise, equality, and ordering operators. Some operators must be provided in pairs; for example if you overload == you should also overload !=. For ordering, provide < and > together and usually <= and >= as well.

public readonly struct Meters
{
  public double Value { get; }
  public Meters(double v) => Value = v;

  public static Meters operator +(Meters a, Meters b) => new Meters(a.Value + b.Value);
  public static Meters operator -(Meters a, Meters b) => new Meters(a.Value - b.Value);
  public static bool operator ==(Meters a, Meters b) => a.Value == b.Value;
  public static bool operator !=(Meters a, Meters b) => !(a == b);

  public override bool Equals(object? obj) => obj is Meters m && m.Value == Value;
  public override int GetHashCode() => Value.GetHashCode();
}
πŸ’‘ Keep dimensional correctness by restricting cross-type operators; for example avoid adding Meters to Seconds unless you define a clear result type.

User-defined conversions

Conversion operators live on the source or target type. Use implicit only when the conversion cannot lose information or surprise readers. Prefer explicit when the conversion may fail or is narrowing.

public readonly struct Ratio
{
  public double Value { get; }
  public Ratio(double v) => Value = v;

  public static implicit operator double(Ratio r) => r.Value;
  public static explicit operator Ratio(double v) => new Ratio(v);
}

True/false and custom truthiness

Types can define operator true and operator false so instances can participate in if and conditional operators. This is rare; most types should not define custom truthiness because it can reduce clarity.

public readonly struct Validated
{
  public bool IsValid { get; }
  public Validated(bool ok) => IsValid = ok;

  public static bool operator true(Validated v) => v.IsValid;
  public static bool operator false(Validated v) => !v.IsValid;
}
⚠️ Excessive or surprising operator overloading harms readability; prefer clear method names when intent would not be obvious to a new reader of the code.

Minimal class outline with operators

The following sketch shows where operator members live within a type; bodies omitted for brevity with to indicate elided content.

public sealed class Money
{
  public decimal Amount { get; }
  public string Currency { get; }
  public Money(decimal amount, string currency) { Amount = amount; Currency = currency; }

  public static Money operator +(Money a, Money b) => … // validate currency then add
  public static bool operator ==(Money a, Money b) => …
  public static bool operator !=(Money a, Money b) => …

  public static explicit operator decimal(Money m) => m.Amount;
  public override bool Equals(object? o) => …
  public override int GetHashCode() => …
}

Chapter 5: Flow Control

Flow control directs the order of execution in your program. In C# it includes conditional branches, pattern-based decisions, loops that repeat work, early exits from blocks, and structured error handling. The following sections build a practical foundation that you can apply in every project.

Using if, else if, else

The if family chooses between paths based on a Boolean expression. The condition is evaluated; if it is true the associated block runs, otherwise the next branch is considered. Braces improve clarity for single statements and avoid subtle bugs; it is best to keep them even when the body is a single line.

Basic branching with blocks

This example reads an integer and classifies it. The braces show clear structure and prevent ambiguity when new lines are added later.

int n = int.Parse(Console.ReadLine());
if (n > 0) {
  Console.WriteLine("positive");
} else if (n < 0) {
  Console.WriteLine("negative");
} else {
  Console.WriteLine("zero");
}

Combining conditions

Combine conditions with && (and), || (or), and ! (not). Group with parentheses for readability and to ensure the intended precedence.

string role = "admin";
bool active = true;
if ((role == "admin" || role == "owner") && active) {
  Console.WriteLine("has access");
}
πŸ’‘ Prefer explicit comparisons such as if (flag == true) only when helpful for clarity. In many cases if (flag) reads better.

Guard clauses for early return

Use a short initial check to exit a method early when a precondition fails. This keeps the happy path aligned to the left and reduces nested indentation.

void Process(Order order) {
  if (order is null) { throw new ArgumentNullException(nameof(order)); }
  if (!order.IsValid) { return; }
  // … continue with processing
}

Handling switch expressions

The switch statement and the newer switch expression route control across many alternatives. Pattern matching adds expressive power; you can branch on constant values, types, properties, positional tuples, and relational conditions. The switch expression yields a value; it is often concise and easy to read.

Classic switch statement

Use labels and break to avoid fall-through. The default label handles everything that did not match earlier cases.

var ch = Console.ReadKey(intercept: true).KeyChar;
switch (ch) {
  case 'y':
  case 'Y':
    Console.WriteLine("yes");
    break;
  case 'n':
  case 'N':
    Console.WriteLine("no");
    break;
  default:
    Console.WriteLine("unknown");
    break;
}

switch expression with patterns

When you want a value from a decision, the expression form is neat. Combine patterns and guards with when for fine control.

static decimal Shipping(decimal weightKg, string region) => (weightKg, region) switch {
  (<= 0m, _)                 => throw new ArgumentOutOfRangeException(nameof(weightKg)),
  (<= 0.5m, "local")         => 2.50m,
  (<= 2m,   "local")         => 4.00m,
  (<= 2m,   "international") => 9.50m,
  var (w, r) when w > 2m     => 12.00m + (w - 2m) * 3m,
  _                          => 7.00m
};
πŸ’‘ The _ pattern is a catch-all. Keep it last so earlier, more specific patterns are considered first.

Type, property, and relational patterns

Patterns can test types and drill into members. This keeps branching code declarative and avoids temporary variables.

string Describe(object obj)   => obj switch {
  null                        => "null",
  string s when s.Length == 0 => "empty string",
  string s                    => $"string({s.Length})",
  int < 0                     => "negative int",
  { Length: > 3 }             => "has length > 3",  // any type with Length
  _                           => "something else"
};
⚠️ Pattern order matters. The first matching arm is chosen. Place specific patterns before general ones to prevent unintentional matches.

Creating while, do, and for loops

Loops repeat work while a condition holds. Choose the construct that best expresses intent; the right choice improves readability and reduces off-by-one mistakes.

while and do basics

while checks the condition before each iteration. do runs the body once before checking. Use while for zero-or-more scenarios; use do when you need at least one pass.

// while
int i = 0;
while (i < 3) {
  Console.WriteLine(i);
  i++;
}
// do
string input;
do {
  Console.Write("Enter value (q to quit): ");
  input = Console.ReadLine();
} while (input != "q");

for with counters

for is ideal for counted loops where you initialize, test, and increment in one place. Keep loop variables scoped to the loop for clarity.

for (int j = 0; j < 5; j++) {
  Console.WriteLine($"j = {j}");
}

Choosing a loop construct

This compact summary helps you select the right tool.

ConstructChecksBest when
whileBefore bodyUnknown count; may be zero
doAfter bodyAt least one iteration is required
forBefore bodyCounter based or index iteration
πŸ’‘ Keep loop bodies short; extract helper methods when bodies grow. Small loops are easier to reason about and test.

Working with foreach

foreach enumerates items from any type that exposes an appropriate enumerator. It expresses intent to visit each element and keeps index logic out of sight. Iterator methods with yield return produce sequences lazily; this can reduce memory usage and improve composability.

foreach over sequences

Enumerate arrays, lists, dictionaries, and any type implementing the required pattern. The loop variable is read-only for reference types and a copy for value types.

var words = new List<string> { "alpha", "beta", "gamma" };
foreach (var w in words) {
  Console.WriteLine(w);
}

Iterator methods with yield

Use yield return to produce values on demand. The state machine is generated by the compiler; you focus on the logic. Use yield break to stop early.

IEnumerable<int> Range(int start, int count) {
  for (int k = 0; k < count; k++) {
    yield return start + k;
  }
}

foreach (var n in Range(10, 3)) {
  Console.WriteLine(n); // 10, 11, 12
}
⚠️ Avoid mutating a collection while iterating it with foreach. Many collections throw InvalidOperationException when modified during enumeration.

Using break, continue, and goto

These keywords alter control flow in loops and switch constructs. Use them sparingly; clarity comes first. When structure alone conveys intent, prefer that to jumps.

break and continue

break exits the nearest enclosing loop or switch. continue skips to the next iteration of the nearest loop.

for (int i2 = 0; i2 < 10; i2++) {
  if (i2 % 2 == 1) { continue; }   // skip odd
  if (i2 > 6) { break; }            // stop once past 6
  Console.WriteLine(i2);
}

goto and labeled statements

goto can jump to a label or to a switch case. It is rarely needed. Prefer structured alternatives such as factoring code into methods or using return to exit a method.

int tries = 0;
retry:
tries++;
bool ok = TryConnect();
if (!ok && tries < 3) { goto retry; }
Console.WriteLine(ok ? "connected" : "failed");
πŸ’‘ A well-named helper method with an early return is usually clearer than a label and goto.

Exception handling

Exceptions report unexpected conditions. Use try to delimit a risky operation, catch to handle specific failures, and finally to run cleanup regardless of success. Rethrow when you cannot handle the error usefully; do not swallow failures silently.

Catching and filtering

Catch the most specific exceptions first. Filters with when allow conditional handling without altering the exception stack.

try {
  using var stream = File.OpenRead(path);
  // … read and process
}
catch (FileNotFoundException ex) when (ex.FileName?.EndsWith(".cfg") == true) {
  Console.WriteLine("Missing configuration file.");
}
catch (UnauthorizedAccessException) {
  Console.WriteLine("Access denied.");
}
catch (IOException ex) {
  Console.WriteLine($"I/O error: {ex.Message}");
}

Cleanup with finally

The finally block always runs. It is ideal for releasing unmanaged resources or restoring state. The using statement usually handles disposal for you, however finally remains essential for custom cleanup.

Stream? s = null;
try {
  s = File.OpenRead(path);
  // … process
}
catch (Exception ex) {
  Console.Error.WriteLine(ex.Message);
  throw; // preserve the original stack
}
finally {
  s?.Dispose();
}
⚠️ Throwing a new exception inside catch without the original as InnerException loses context. Use throw; to rethrow or wrap with the original as inner detail.

Chapter 6: Methods and Parameters

Methods package behavior behind a name; they take parameters, produce return values, and form the core of most program logic. A clear method signature tells callers what they can expect. This chapter explores parameters, overload rules, local functions, expression-bodied members, and the subtleties of recursion.

Method signatures and return values

A method signature includes its name, parameter list, and any type parameters when generics are involved. The return type describes the value produced. A method with no meaningful return uses void. If a method may fail, prefer exceptions over sentinel values because they make error paths explicit.

Declaring methods

Each method specifies visibility, modifiers, return type, name, and parameters in that order. Curly braces hold the body.

public int Add(int a, int b) {
  return a + b;
}

Returning values

The return statement ends execution and yields the result. Use a single return when clarity demands it or multiple returns when guarding early. Both styles are valid when used with care.

public string Classify(int score) {
  if (score < 0) { return "invalid"; }
  if (score < 50) { return "fail"; }
  return "pass";
}
πŸ’‘ Prefer clear naming and simple logic inside methods. When they grow long, extract helper methods to keep each piece focused.

Expression-bodied methods

When a method only returns the result of one expression, the => form provides a compact style. Properties, indexers, operators, and local functions can also use this pattern.

public int Square(int x) => x * x;

Parameters

By default parameters pass by value which means the method receives a copy for value types or a copy of the reference for reference types. The ref, in, and out modifiers change how arguments flow between caller and callee and they affect mutability and assignment rules. These features should be used deliberately; they make data flow explicit.

ref parameters

ref passes a variable by reference; the method receives a reference to the caller’s storage. The caller must initialize the variable before passing it. Assigning to the parameter changes the original variable.

void Increase(ref int value) {
  value++;
}

int n = 5;
Increase(ref n); // n becomes 6

out parameters

out passes a variable that the method guarantees to assign before returning. This is common when a method needs to return multiple values or signal whether parsing succeeded.

if (int.TryParse("123", out int result)) {
  Console.WriteLine(result);
}

in parameters

in passes a reference that is read-only. It is often used with large structs to avoid copying but still prevent modification. The caller must initialize the variable before passing it.

readonly struct Point {
  public int X { get; }
  public int Y { get; }
  public Point(int x, int y) { X = x; Y = y; }
}

int Dist(in Point p) {
  return p.X * p.X + p.Y * p.Y;
}
⚠️ Avoid overusing ref and out; they can obscure data flow. Consider returning a tuple or defining a struct when you need to return several values together.

Optional and named arguments

Optional parameters specify default values. Callers can omit them when the defaults suit their needs. Named arguments improve clarity at the call site by pairing each argument with its parameter name.

Declaring optional parameters

Optional values must be compile-time constants or default. Place optional parameters after required ones to avoid ambiguity.

void Log(string message, bool timestamp = true) {
  if (timestamp) {
    Console.Write($"[{DateTime.Now}] ");
  }
  Console.WriteLine(message);
}

Using named arguments

Named arguments document intent especially when calls include many Boolean or numeric parameters. Names can appear in any order after positional ones.

Log(message: "Starting", timestamp: false);
πŸ’‘ Use named arguments to improve clarity but avoid changing names frequently; callers depend on stable parameter names.

Overloading and resolution

Methods with the same name may have different parameter lists. This is overloading. The compiler chooses the best candidate based on argument types, conversions, and generic inference. Overloads should form a coherent family with clear and consistent semantics.

Creating overload sets

Each overload must differ in parameter count or types. Return type alone cannot distinguish overloads. Keep overload sets tidy so callers can rely on predictable behavior.

public void Write(string s) { Console.WriteLine(s); }
public void Write(int n)    { Console.WriteLine(n); }

Overload resolution rules

The compiler considers exact matches first then widening conversions then applicable generic methods. Ambiguous calls produce errors so keep overloads distinct enough that typical calls remain unambiguous.

void F(long x) { Console.WriteLine("long"); }
void F(double x) { Console.WriteLine("double"); }

F(3); // chooses F(long) because int widens to long
⚠️ Too many overloads can confuse readers. Consider using optional parameters or carefully designed types when the overload list grows unwieldy.

Local functions

Local functions live inside another method. They help decompose logic without exposing helper methods at the class level. Expression-bodied members provide compact syntax for simple operations and can be applied to local functions too. Both features keep related logic close together and support clear layering.

Defining local functions

Local functions have full access to variables in the enclosing method. They avoid allocations that often arise with lambdas and can improve readability by grouping steps together.

int SumTo(int limit) {
  int Acc(int n) {
    return n == 0 ? 0 : n + Acc(n - 1);
  }
  return Acc(limit);
}

Expression-bodied members

Any method or local function that consists of one simple expression can use the => form. This keeps focus on what the method returns rather than the boilerplate structure.

string Greet(string name) => $"Hello, {name}";
πŸ’‘ Use the compact form for simple functions; switch back to block bodies when logic becomes more complex.

Recursion

Recursion occurs when a method calls itself. It is a powerful technique for problems that break into smaller parts. Each call pushes a new frame on the stack; deep recursion may exhaust stack space. Tail-call optimization can reduce stack use in some cases although its availability depends on runtime conditions and method characteristics.

Typical recursive structure

A recursive method must define a base case that ends the recursion and a recursive step that moves toward that base. Without both it risks infinite recursion.

int Fact(int n) {
  if (n <= 1) { return 1; }
  return n * Fact(n - 1);
}

Tail-call opportunities

A tail call occurs when the recursive call is the method’s final action. Some runtimes can optimize this by reusing the current stack frame. C# does not guarantee tail-call optimization; consider iterative forms when call depth might be large.

int FactTail(int n, int acc) {
  if (n <= 1) { return acc; }
  return FactTail(n - 1, n * acc); // tail position
}
⚠️ Even when a call is in tail position the optimizer may not apply tail-call elimination. When performance or depth safety matters, prefer loops.

Chapter 7: Objects, Classes, and Structs

C# is an object-oriented language with pragmatic features that help you model real systems. In this chapter you define types with class and struct, add state with fields and properties, provide behavior with methods and indexers, and apply modern features such as object initializers, deconstruction, and tuples. By the end you will be comfortable choosing between classes and structs and using encapsulation to keep your code robust.

Defining classes and fields

A class defines a reference type. You use it to model entities that have identity and often longer lifetimes. Fields hold raw data inside the type; in most designs fields are private and surfaced through properties. Start with the smallest viable shape and evolve as behavior becomes clear.

Declaring a basic class

At minimum a class has a name and an accessibility modifier. You can add members such as fields, methods, and properties as needed.

public class Person {
  private string _firstName;
  private string _lastName;

  public string FullName() { return _firstName + " " + _lastName; }
}

Field design and accessibility

Prefer private fields with clear names. Expose data through properties to maintain invariants. Use const for compile-time constants and static readonly for runtime constants.

public class Meter {
  public const double InchesPerMeter = 39.3700787;
  private readonly double _value;

  public Meter(double value) { _value = value; }
}
πŸ’‘ Use the underscore prefix convention for private fields; it is widely adopted in C# codebases.

Choosing between fields and properties

Fields are implementation details; properties are part of the public contract. Switching a field to a property is a breaking change for consumers, so default to properties for public or protected data.

public class Customer {
  // Bad public field; callers can assign invalid values.
  // public int Age;

  // Good property; you can validate inside set.
  private int _age;
  public int Age {
    get { return _age; }
    set {
      if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
      _age = value;
    }
  }
}

Constructors and object initializers

Constructors establish valid state. C# also supports object initializers which set properties directly after construction. Combine them to keep required settings enforced and optional settings flexible.

Declaring constructors

Constructors share the type name and cannot return a value. Use the shortest constructor that guarantees validity; chain with this(...) to avoid duplication.

public class ConnectionOptions {
  public string Host { get; }
  public int Port { get; }

  public ConnectionOptions(string host) : this(host, 443) {}
  public ConnectionOptions(string host, int port) {
    Host = host ?? throw new ArgumentNullException(nameof(host));
    Port = port;
  }
}

Object initializers for clarity

Object initializers assign properties and fields immediately after construction. They improve readability when there are many optional members.

var req = new HttpRequest("GET", "/") {
  Timeout = TimeSpan.FromSeconds(10),
  Headers = { ["Accept"] = "application/json" }
};

Primary constructors for concise models

In modern C#, you can declare primary constructors on classes to capture constructor parameters and use them directly in member initializers and the body. This keeps data-carrying types concise.

public class Rectangle(double width, double height) {
  public double Width { get; } = width;
  public double Height { get; } = height;
  public double Area() { return Width * Height; }
}
⚠️ Keep required invariants in the constructor or in property setters; do not rely on object initializers to rescue invalid state.

Properties, indexers, and encapsulation

Properties wrap fields with controlled access. Indexers generalize property access with a parameter list, which lets your type feel like a collection. Use get, set, and init accessors to formalize mutability.

Auto-implemented properties and validation

Auto-implemented properties are concise. When you need validation, expand them to backed properties with a private field.

public class Product {
  public string Sku { get; init; } = "";
  private decimal _price;
  public decimal Price {
    get { return _price; }
    set {
      if (value < 0m) throw new ArgumentOutOfRangeException(nameof(value));
      _price = value;
    }
  }
}

Read-only and init-only patterns

Use read-only properties for values that must not change. Use init to allow assignment during initialization only.

var p = new Product { Sku = "ABC-123", Price = 9.99m };
// p.Sku = "XYZ"; // Not allowed after initialization when using init

Indexers for collection-like access

An indexer is declared with the this keyword and parameters. It allows clients to use bracket syntax. Validate indices and keys to protect invariants.

public class Settings {
  private readonly Dictionary<string,string> _values = new();
  public string this[string key] {
    get { return _values.TryGetValue(key, out var v) ? v : ""; }
    set { _values[key] = value; }
  }
}
πŸ’‘ Favor properties for cheap operations; reserve methods for work that clearly performs an action or could be expensive.

Structs, readonly members, and performance

A struct defines a value type that is typically small and immutable. Value types are copied by value, which can be faster and can reduce allocations. Keep structs small; large structs increase copying costs.

Declaring a struct

Use structs for small mathematical types, coordinates, and identifiers. Prefer immutability to avoid surprising copy semantics.

public readonly struct Point2D {
  public double X { get; }
  public double Y { get; }
  public Point2D(double x, double y) { X = x; Y = y; }
  public Point2D Offset(double dx, double dy) { return new Point2D(X + dx, Y + dy); }
}

readonly and defensive design

Mark the struct or members as readonly to prevent accidental mutation and to enable optimizations. A readonly struct guarantees that instance members do not modify state.

public readonly struct Angle {
  public double Radians { get; }
  public Angle(double radians) { Radians = radians; }
  public double Degrees() { return Radians * (180.0 / Math.PI); }
}

When to choose a class instead

Choose a class when the type is large, mutable, or widely shared. Classes avoid repeated copies and allow polymorphism through inheritance and interfaces.

⚠️ Avoid putting parameterless public constructors on structs that represent arbitrary numeric values; uninitialized default values can be misleading. Prefer explicit construction that makes the state clear.

Partial types and file-scoped namespaces

Partial types let you split a type across files. This is helpful when code is generated or when separating concerns. File-scoped namespace declarations shorten indentation and improve readability in single-namespace files.

Declaring a partial type

Use the partial modifier on each part. The compiler merges parts at build time. Keep member visibility consistent and avoid conflicting definitions.

public partial class ApiClient {
  public Task ConnectAsync() { /* … */ return Task.CompletedTask; }
}

// In another file
public partial class ApiClient {
  public Task DisconnectAsync() { /* … */ return Task.CompletedTask; }
}

Using file-scoped namespace

With file-scoped namespaces you declare the namespace once at the top of the file followed by a semicolon; the rest of the file belongs to that namespace without braces.

namespace Contoso.Networking;

public class Packet { /* … */ }
public class Frame { /* … */ }
πŸ’‘ Combine file-scoped namespaces with one-type-per-file to keep diffs small and navigation simple.

Generated code and partial methods

Generated code often uses partial types and partial methods so that you can extend behavior without touching the generated file. Partial methods can be declared without an implementation; if unused they are removed at compile time.

public partial class Model {
  partial void OnValidated();

  public void Validate() {
    // validate …
    OnValidated();
  }
}

// Your extension file
public partial class Model {
  partial void OnValidated() { Console.WriteLine("Model validated"); }
}

Deconstruction and tuples

Tuples provide lightweight groupings of values. Deconstruction unpacks a type into separate variables. Together they let you return multiple values without defining a dedicated type, while keeping code readable.

Working with (...) tuples

Create tuples with parentheses and optional element names. Access elements by name or by position. Tuples are value types with structural equality.

(int code, string message) result = (200, "OK");
Console.WriteLine(result.code);     // 200
Console.WriteLine(result.message);  // OK

Returning multiple values

Functions can return a tuple to provide several results in one call. Choose clear element names to improve readability.

public static (bool found, string value) TryGet(IDictionary<string,string> map, string key) {
  if (map.TryGetValue(key, out var v)) return (true, v);
  return (false, "");
}

Deconstruction patterns

Deconstruction assigns tuple elements to individual variables. You can also add a Deconstruct method to your own types to enable the same syntax.

var (ok, value) = TryGet(new Dictionary<string,string> { ["a"] = "alpha" }, "a");
if (ok) Console.WriteLine(value);

public class Range {
  public int Start { get; }
  public int End { get; }
  public Range(int start, int end) { Start = start; End = end; }
  public void Deconstruct(out int start, out int end) { start = Start; end = End; }
}

var r = new Range(10, 20);
var (s, e) = r; // uses Deconstruct

Chapter 8: Inheritance and Polymorphism

Inheritance lets a type reuse and extend another type; polymorphism lets code operate on a base type while dispatching behavior to the most specific implementation at runtime. This chapter shows how to declare hierarchies, control extensibility, and choose inheritance or composition wisely.

Base classes and derived classes

A class can inherit from one base class using the colon syntax. The derived class gains the base class members that are not private; it can add new members and specialize behavior. Constructors are not inherited; a derived constructor must chain to a base constructor explicitly or implicitly.

Declaring a base and deriving a subtype

The following example defines a base Shape and a derived Circle. The base provides common state; the derived adds specific state and behavior.

class Shape
{
  public string Name { get; }
  public Shape(string name) { Name = name; }
}

class Circle : Shape
{
  public double Radius { get; }
  public Circle(double radius) : base("Circle") { Radius = radius; }
}

When a derived constructor calls base(...) it ensures the base portion is initialized correctly; omitting an explicit call uses a parameterless base constructor if it exists.

Access modifiers in hierarchies

Members marked private are not visible to derived types; protected members are visible to derived types; internal members are visible within the assembly. You can combine protected internal or use private protected for finer control.

πŸ’‘ Prefer protected only for members intended for derived classes; otherwise keep members private and expose behavior through methods or properties.

Virtual dispatch

Polymorphism in C# uses virtual dispatch. A base member marked virtual can be overridden in a derived type using override. You can prevent further overriding with sealed on an overriding member. This allows a library to be extensible where needed and fixed where stability matters.

Declaring and overriding virtual members

Mark base members as virtual only when you intend them to be replaced. Use override in derived classes to specialize behavior and optionally call the base implementation with base.Member().

class Shape
{
  public virtual double Area() { return 0.0; }
}

class Rectangle : Shape
{
  public double Width { get; }
  public double Height { get; }
  public Rectangle(double w, double h) { Width = w; Height = h; }
  public override double Area() { return Width * Height; }
}

If a derived override should be the last implementation in the chain, mark it sealed. This keeps the class derivable while locking that specific behavior.

class Square : Rectangle
{
  public Square(double side) : base(side, side) { }
  public sealed override double Area() { return base.Area(); }
}

Overriding properties and indexers

Properties and indexers participate in polymorphism. You can mark a property virtual and then override its accessors; the get and set bodies live together under the same declaration.

class Document
{
  public virtual string Title { get; set; } = "Untitled";
}

class Report : Document
{
  public override string Title { get; set; } = "Report";
}

Abstract classes and members

An abstract class cannot be instantiated; it can provide shared implementation and define abstract members that derived classes must implement. Abstract members have no body. Use an abstract base when you want to share code and enforce a contract together.

Defining abstract types

In this example the base Shape requires an Area implementation. Some derived shapes implement it directly; others can remain abstract until they provide enough information.

abstract class Shape
{
  public abstract double Area();
  public virtual string Describe() { return "Shape"; }
}

class Circle : Shape
{
  public double Radius { get; }
  public Circle(double r) { Radius = r; }
  public override double Area() { return Math.PI * Radius * Radius; }
}
⚠️ Prefer an abstract base only when you need shared code plus a required contract; if you only need a contract choose an interface.

Interfaces

An interface defines a contract that multiple types can implement. C# allows default interface members; you can provide a method body in the interface for shared behavior while still allowing implementations to override. This is useful for evolving contracts gradually.

Implementing interfaces

A type can implement many interfaces. Members are implicitly public; explicit interface implementation can hide members from the class surface when you only want them through the interface reference.

interface IPrintable
{
  void Print();
  string Header => "Header"; // default property body
  void PrintHeader() { Console.WriteLine(Header); } // default method body
}

class Invoice : IPrintable
{
  public void Print() { Console.WriteLine("Invoice"); }
  // inherits default PrintHeader unless overridden
}

Default members in an interface are inherited by implementers; the implementer can provide its own body with the same signature to replace the default.

Explicit interface implementations

Use explicit implementation when a member should not be visible on the class itself. Access it through an interface reference; this avoids name collisions and reduces the public surface.

interface IHasId { string Id { get; } }

class User : IHasId
{
  string IHasId.Id => "user:…"; // explicit implementation
}

Hiding vs override

Sometimes a derived class declares a member with the same signature as a base member. Using override participates in virtual dispatch; using the new modifier hides the base member. Hiding selects the member based on the static type of the reference; overriding selects based on the runtime type.

Comparison of behavior

The following table summarizes the differences between overriding and hiding; understand the dispatch rules to avoid surprises.

TechniqueKeywordDispatchWhen to use
OverridingoverrideRuntime (virtual)Specialize a virtual contract
HidingnewCompile-time (static)Replace a base member for new scenarios without changing virtual behavior

Code demonstration

Notice how calls route differently when the variable is typed as the base or derived class. The hidden method is chosen only when the static type is the derived class.

class Base
{
  public virtual void M() { Console.WriteLine("Base.M"); }
  public void N() { Console.WriteLine("Base.N"); }
}

class Derived : Base
{
  public override void M() { Console.WriteLine("Derived.M"); }
  public new void N() { Console.WriteLine("Derived.N"); }
}

Base b = new Derived();
Derived d = new Derived();

b.M();  // Derived.M (override)
b.N();  // Base.N (hidden, static binding)

d.M();  // Derived.M
d.N();  // Derived.N

When to prefer composition

Inheritance models an is-a relationship; composition models a has-a relationship. Prefer composition when you want to assemble behavior from independent parts, when you do not control a base class, or when a type hierarchy would become deep and rigid. Composition keeps coupling low and testing simple.

Delegation with composed services

In this example a ReportRenderer delegates to a IFormatter. You can swap implementations at runtime; you can combine behaviors without creating many subclasses.

interface IFormatter { string Format(string text); }

class PlainFormatter : IFormatter
{
  public string Format(string text) { return text; }
}

class MarkdownFormatter : IFormatter
{
  public string Format(string text) { return "**" + text + "**"; }
}

class ReportRenderer
{
  private readonly IFormatter _formatter;
  public ReportRenderer(IFormatter formatter) { _formatter = formatter; }
  public void Render(string text) { Console.WriteLine(_formatter.Format(text)); }
}

Composition can live alongside inheritance. You might have a small abstract base for minimal shared code and then compose services for variable behavior; this keeps the hierarchy shallow and extensible.

Chapter 9: Generics

Generics let you write flexible, type-safe code without giving up performance. By lifting algorithms and structures over type parameters you reduce duplication and improve clarity. This chapter explores generic types, type constraints, variance, and the design of reusable generic APIs.

Generic types and methods

A generic type or method introduces type parameters inside angle brackets. These type parameters act like placeholders for concrete types. At compile time the compiler substitutes real types and verifies that your usage is type safe.

Declaring generic types

Types often become generic when they need to store or operate on elements of any type. The following example defines a simple box that wraps an arbitrary value. This supports strong typing without needing casts.

public class Box<T>
{
  private T _value;
  public Box(T value) { _value = value; }
  public T Value { get { return _value; } set { _value = value; } }
}

Type parameters can appear in properties, methods, and nested types. Use descriptive names like TItem or TKey when it improves clarity.

Generic methods

Methods can introduce type parameters independently of the containing type. This makes algorithms reusable even in non-generic classes. Type inference often selects the type parameter based on arguments.

public static class Util
{
  public static T Echo<T>(T value)
  {
    return value;
  }
}

var s = Util.Echo("hello");
var i = Util.Echo(42);
πŸ’‘ Keep type parameter lists short and meaningful; a few clear placeholders make generic code easier to read.

Type parameters and constraints

Constraints narrow the set of types allowed for a type parameter. They let you use members safely on that parameter and also communicate expectations. Add constraints with the where clause and place them after the parameter list.

Common constraints

C# supports several constraint forms. These guide both design and usage.

ConstraintMeaning
where T : classReference types only
where T : structNon-nullable value types only
where T : unmanagedBlittable types suitable for low-level work
where T : new()Requires a public parameterless constructor
where T : SomeBaseRequires T to derive from SomeBase
where T : ISomeInterfaceRequires T to implement ISomeInterface

Combining constraints helps formalize contracts and allows compilers to optimize. For example, where T : struct, IComparable<T> lets an algorithm sort lightweight value types safely.

Using constrained members

Inside a constrained generic type or method you can use the members guaranteed by the constraints. This reduces the need for reflection or dynamic dispatch.

public static T Min<T>(T a, T b) where T : IComparable<T>
{
  return a.CompareTo(b) <= 0 ? a : b;
}
⚠️ Avoid overly tight constraints; they make a generic type less reusable. Choose constraints that express the smallest correct contract.

Covariance and contravariance

Variance describes how type parameters relate across inheritance. C# supports variance for interfaces and delegates through the out and in modifiers. Covariance (out) preserves direction in assignment; contravariance (in) reverses it. This is powerful when working with generic interfaces and delegates that handle related types.

Covariance with out

Covariance allows substituting a more derived type for a less derived one. For example, IEnumerable<string> can be used where IEnumerable<object> is expected because iteration only produces values.

IEnumerable<string> names = new[] { "Ada", "Linus" };
IEnumerable<object> objs = names;  // allowed due to out T

Contravariance with in

Contravariance allows substituting a less derived type for a more derived one. This works when the consumer accepts values of the type parameter rather than producing them. Delegates that take parameters often use this.

Action<object> handleObj = o => Console.WriteLine(o);
Action<string> handleString = handleObj;  // allowed due to in T
πŸ’‘ Variance applies only to generic interface and delegate type parameters; generic classes do not support variance directly.

Generic collections

The System.Collections.Generic namespace provides strongly typed collections. They avoid boxing and offer compile-time safety. Choose a collection that matches your access pattern; this keeps code tight and reliable.

Lists and dictionaries

A List<T> provides indexed access and dynamic sizing. A Dictionary<TKey,TValue> maps keys to values. These types are widely used because they balance performance and ease of use.

var list = new List<int> { 1, 2, 3 };
var map = new Dictionary<string,int> { ["one"] = 1, ["two"] = 2 };

Lists offer methods like Add, Remove, and Sort. Dictionaries offer methods like TryGetValue and an indexer for setting values.

Queues, stacks, and sets

Queues follow FIFO behavior; stacks follow LIFO behavior. HashSet<T> tracks unique items quickly. These structures make algorithms more expressive and efficient.

var q = new Queue<string>();
q.Enqueue("first");
q.Enqueue("second");
Console.WriteLine(q.Dequeue());

var seen = new HashSet<int>();
seen.Add(10);
seen.Add(10);  // ignored, already present

Custom generic collections

You can create your own collection by implementing interfaces like IEnumerable<T> or IReadOnlyList<T>. This provides integration with foreach and LINQ. Focus on consistent semantics; callers expect familiar behaviors from common interfaces.

public class PairSequence<T> : IEnumerable<T>
{
  private readonly T _a;
  private readonly T _b;
  public PairSequence(T a, T b) { _a = a; _b = b; }

  public IEnumerator<T> GetEnumerator()
  {
    yield return _a;
    yield return _b;
  }

  System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
  {
    return GetEnumerator();
  }
}

Performance and allocations

Generics avoid boxing when working with value types; this reduces allocations and improves speed. When designing generics be mindful of how many objects you allocate, how often you reallocate buffers, and how many interface calls you perform. The compiler can devirtualize some constrained calls which keeps loops fast.

Boxing and generic code

Boxing occurs when a value type is treated as an object. Generic code usually avoids boxing because it uses concrete value types at JIT time. Watch for interface conversions or mixing non-generic collections.

List<int> values = new();
values.Add(42);        // no boxing
object o = values[0];  // boxing occurs here

Allocation patterns in collections

Collections resize their internal storage when capacity is exceeded. Setting an initial capacity reduces resizing costs. Reusing buffers or pooling objects can help in tight loops.

var list = new List<int>(capacity: 1024);  // reduces resizing
⚠️ Avoid excessive generic instantiations that carry large closures or large captured state. Keep lambdas and delegate captures small when they appear in hot paths.

Designing reusable generic APIs

Good generic APIs are small, expressive, and predictable. They avoid unnecessary constraints and expose type parameters where they add clarity. Favor generic interfaces for shared protocols and generic methods for standalone algorithms. Keep signatures stable so callers can rely on them.

Balancing flexibility and clarity

Keep the essential type parameters visible and tuck away others behind private helpers. Too many parameters make an API noisy; too few make it rigid. Strike a balance based on the patterns you expect clients to use.

public interface IRepository<TItem, TId>
{
  TItem Find(TId id);
  void Save(TItem item);
}

Putting it all together

When you design a generic API combine clear type parameters, minimal constraints, and predictable behaviors. This keeps your API stable even as new callers join. Generic design is a craft of small rules that add up to reusable, reliable components.

Chapter 10: Collections and LINQ

The .NET libraries provide a wide range of collection types for storing and managing data. LINQ layers a fluent querying model on top, giving you expressive and composable ways to filter, shape, and transform sequences. Together they act like a toolkit for taming data, whether it comes from arrays, lists, dictionaries, or external sources that stream values.

Arrays, lists, dictionaries, and sets

Collections in .NET come in several shapes. Arrays offer compact storage and fast indexed access; lists support dynamic resizing; dictionaries map keys to values; sets track unique items. Choose the structure that matches how you plan to use the data.

Working with arrays

Arrays are fixed in length and store elements contiguously. They are ideal for tight loops and predictable sizes. Access is constant time and cache friendly.

int[] scores = { 10, 20, 30 };
Console.WriteLine(scores[1]);

Use array initializers for clarity. If you need to grow the data over time consider a list instead because resizing an array requires allocating a new one.

Using lists for dynamic data

A List<T> resizes as you add items. Internally .NET grows the array in chunks to keep amortized performance steady. Lists integrate smoothly with LINQ because they implement IEnumerable<T>.

var items = new List<string> { "alpha", "beta" };
items.Add("gamma");
πŸ’‘ If you expect many additions set an initial capacity to reduce allocation overhead; this helps keep the underlying buffer stable during growth.

Dictionaries for key based access

Dictionaries map keys to values using hashing. They give near constant time lookups. Keys must be unique and must provide stable equality and hashing behavior.

var ages = new Dictionary<string,int>();
ages["Ada"] = 36;
ages.TryGetValue("Ada", out var a);

Dictionaries are widely used across .NET for configuration, caching, and general lookup patterns because they combine speed with convenience.

Sets for uniqueness

A HashSet<T> stores only distinct items. Use it when you need fast membership tests or when you want to eliminate duplicates before running a query.

var seen = new HashSet<int> { 1, 2, 3 };
seen.Add(2); // ignored

Enumerables and iterators

The IEnumerable<T> interface represents a sequence you can iterate with foreach. Iterators in C# let you implement this contract with minimal boilerplate. They are the backbone of LINQ and many .NET libraries.

Understanding IEnumerable<T>

An enumerable produces values one at a time. The consumer does not need to know how the sequence is stored or computed. This separation of shape and behavior is what makes LINQ powerful.

IEnumerable<int> Range()
{
  yield return 1;
  yield return 2;
  yield return 3;
}

foreach (var v in Range()) Console.WriteLine(v);

Iterators like this generate values lazily. The body runs only when the consumer moves through the sequence.

Building custom iterators

You can create sequences backed by any data source. A sequence might wrap a file, a network stream, or even generated values. This fits well with .NET’s streaming APIs because it avoids buffering entire data sets in memory.

public static IEnumerable<string> Lines(StreamReader reader)
{
  string? line;
  while ((line = reader.ReadLine()) != null)
    yield return line;
}
⚠️ Dispose resources such as streams outside the iterator to avoid leaving them open while the sequence is partially consumed.

LINQ query syntax

LINQ query syntax uses a SQL inspired style to express filtering, projection, grouping, and joining. Internally it translates to method calls on IEnumerable<T> or IQueryable<T>. The syntax is optional but can improve readability for complex queries.

Writing basic queries

A query starts with from and usually ends with select. In between you can place where, orderby, group, and other clauses.

var names = new[] { "Ada", "Brendan", "Linus" };

var result =
  from n in names
  where n.Length > 3
  orderby n
  select n.ToUpper();

The query runs when you enumerate result because LINQ uses deferred execution.

Query expressions and continuation

Query clauses can chain smoothly. You can add let clauses to introduce intermediate values; these help simplify complex expressions.

var query =
  from n in names
  let upper = n.ToUpper()
  where upper.StartsWith("A")
  select upper;

LINQ method syntax

Method syntax uses extension methods such as Where, Select, OrderBy, and GroupBy. It is precise and compact. Many developers favor method syntax for its tooling support and predictable chaining.

Filtering and projection

Filtering and shaping data are the heart of LINQ. Each call returns a new sequence that wraps the previous one. Nothing executes until enumeration begins.

var r =
  names
    .Where(n => n.Length > 3)
    .Select(n => n.ToUpper());

The lambda expressions describe small behaviors; .NET’s implementation of LINQ composes them into streaming pipelines.

Ordering and combining calls

Ordering uses OrderBy and ThenBy. These preserve streaming behavior except during the sort itself which needs to buffer.

var sorted =
  names
    .OrderBy(n => n.Length)
    .ThenBy(n => n);
πŸ’‘ Continuation methods like ThenBy run only after the preceding order is established; this lets you build stable hierarchies of sort keys.

Deferred execution and streaming

LINQ sequences typically stream their values. They do not compute results until the consumer asks for them. This keeps memory usage low and helps build pipelines that operate on large inputs.

Deferred behavior in action

The following example prints only when values are pulled from the sequence. You can weave extra logging into the chain to observe this behavior.

var seq =
  names
    .Where(n => {
      Console.WriteLine("Checking " + n);
      return n.Length > 3;
    });

foreach (var v in seq) Console.WriteLine("Result " + v);

Deferred execution interacts well with .NET’s streaming APIs; this lets you process large files, network responses, or generated data without buffering everything at once.

When LINQ must buffer

Operators such as OrderBy, GroupBy, and Join need to collect values before producing results. This is expected because they rely on grouping, sorting, or key matching. Be mindful of memory when using them on large sequences.

⚠️ Mixing deferred and immediate operators in complex chains can lead to multiple enumerations; use ToList or ToArray to materialize when needed.

Grouping, joins, and projections

LINQ supports relational style operations. Grouping gathers items under keys; joins correlate items across sequences; projections shape the final result. These patterns echo database queries and feel natural when working with structured data in .NET.

Grouping by key

Grouping partitions data into buckets keyed by an expression. Each group exposes the key and the elements belonging to that group.

var byLength =
  names.GroupBy(n => n.Length);

foreach (var g in byLength)
{
  Console.WriteLine("Length " + g.Key);
  foreach (var v in g) Console.WriteLine("  " + v);
}

Joining sequences

Joins combine two sequences based on matching keys. LINQ implements hash based joins which are efficient for in memory data. They work similarly with LINQ to Objects and LINQ providers such as LINQ to SQL.

var people = new[]
{
  new { Id = 1, Name = "Ada" },
  new { Id = 2, Name = "Linus" }
};

var scores2 = new[]
{
  new { PersonId = 1, Score = 90 },
  new { PersonId = 2, Score = 88 }
};

var q =
  people.Join(
    scores2,
    p => p.Id,
    s => s.PersonId,
    (p, s) => new { p.Name, s.Score });

Shaping results with projections

Projections create a new form by mapping each element through a selector. This might extract a few fields or compose a new anonymous object. Projections keep query logic tidy by isolating the final shape of the result.

var projection =
  names.Select(n => new { Original = n, Upper = n.ToUpper() });

LINQ’s projection model matches the shape first approach that permeates the .NET libraries. It encourages constructing exactly the structure you need, no more and no less.

Chapter 11: Delegates, Events, and Lambdas

Delegates, events, and lambda expressions form a core trio in modern C#. Delegates provide type-safe references to methods; events build on delegates to implement the publish and subscribe model; lambda expressions make delegate instances concise to write and easy to compose. You will meet these features across .NET APIs such as EventHandler, Task continuations, Timer callbacks, and LINQ.

Delegates and type safety

A delegate is a type-safe object that represents a method signature. You can store a reference to any method whose parameters and return type match the delegate definition; at call time C# enforces that the target method fits the signature. Delegates are reference types, support invocation, and can target static or instance methods.

Declaring and instantiating a delegate

Declare a delegate with the delegate keyword. Construct an instance by providing a compatible method group or lambda. Invocation uses normal call syntax.

using System;

public delegate int BinaryOp(int x, int y);

class Calculator
{
  public static int Add(int a, int b) { return a + b; }
  public int Multiply(int a, int b) { return a * b; }
}

class Program
{
  static void Main()
  {
    BinaryOp op1 = Calculator.Add;             // static method group
    BinaryOp op2 = new Calculator().Multiply;  // instance method group

    int s = op1(3, 4);  // 7
    int p = op2(3, 4);  // 12
    Console.WriteLine($"{s}, {p}");
  }
}
πŸ’‘ Prefer method groups or lambdas when creating delegates; the older new DelegateType(target, method) syntax is rarely needed.

Variance with delegate type parameters

Generic delegates support covariance in return types and contravariance in parameter types. This enables flexible assignment as long as the direction matches what is safe for substitution.

delegate Animal AnimalFactory();           // return type is covariant
delegate void AnimalHandler(Mammal m);     // parameter is contravariant

class Animal { }
class Mammal : Animal { }
class Cat : Mammal { }

Animal MakeAnimal() { return new Cat(); }  // OK: Cat β†’ Animal
void HandleAnimal(Animal a) { /* ... */ }

void Demo()
{
  AnimalFactory f = MakeAnimal;            // covariance
  AnimalHandler h = HandleAnimal;          // contravariance
  h(new Cat());
}
⚠️ Variance applies at the delegate type boundary; inside the target method your parameter and return types are the ones you declared in that method’s signature.

Anonymous methods and lambda expressions

Anonymous methods and lambda expressions create delegates without a named method. Lambdas are the idiomatic approach in modern C#. A lambda can be an expression-bodied form that returns its expression, or a statement-bodied form enclosed in braces which can contain multiple statements.

Lambda syntax and expression forms

The compiler infers parameter and return types from the target delegate. Use parentheses for zero or multiple parameters; omit them for a single parameter when no type annotation is present. Statement-bodied lambdas use braces and must return explicitly if the delegate has a non-void return type.

Func square = x => x * x;              // expression-bodied
Action log = (msg) => { Console.WriteLine(msg); };  // statement-bodied
Func add = (a, b) => a + b;

int nine = square(3);
log($"3Β² = {nine}");
int seven = add(3, 4);
πŸ’‘ Use _ as a discard parameter when you do not need a value in multi-parameter lambdas. Example: (_, value) => Console.WriteLine(value).

Anonymous methods with delegate

The older anonymous method form uses the delegate keyword. It can still be useful in rare cases, such as when you want to omit parameter names or use unsafe code blocks; lambdas are otherwise preferred.

Action hello = delegate { Console.WriteLine("Hello"); };
Func max = delegate(int a, int b) { return a > b ? a : b; };

hello();
Console.WriteLine(max(5, 9));
⚠️ A static lambda does not capture outer variables. Write static x => { … } to enforce no captures and enable small runtime optimizations.

Events and event patterns

Events implement the publish and subscribe model on top of delegates. An event exposes += and -= for subscriber management. Only the declaring type can raise the event. .NET libraries standardize events with the EventHandler and EventArgs pattern.

Declaring and raising an event

Declare an event with a delegate type. Use a protected virtual method named OnEventName to raise it. The null-conditional operator helps avoid a race condition where the invocation list changes between a null check and the call.

using System;

class TickEventArgs : EventArgs
{
  public DateTime When { get; }
  public TickEventArgs(DateTime when) { When = when; }
}

class Ticker
{
  public event EventHandler<TickEventArgs>? Ticked;

  protected virtual void OnTicked(DateTime when)
  {
    Ticked?.Invoke(this, new TickEventArgs(when));
  }

  public void Tick() { OnTicked(DateTime.UtcNow); }
}

class Program
{
  static void Main()
  {
    var t = new Ticker();
    t.Ticked += (sender, e) => Console.WriteLine($"Tick at {e.When:O}");
    t.Tick();
  }
}
πŸ’‘ Expose events using EventHandler or EventHandler<TEventArgs>. This matches .NET conventions and works smoothly with UI frameworks, Timer, and many libraries.

Custom accessors and thread safety

Events can define custom add and remove accessors to control subscription storage or implement weak references. For thread safety, treat the invocation list as immutable during a raise by copying the delegate to a local variable, or use ?.Invoke which reads it once.

private EventHandler? _changed;

public event EventHandler Changed
{
  add { _changed += value; }
  remove { _changed -= value; }
}

void RaiseChanged()
{
  var handler = _changed;  // local copy
  handler?.Invoke(this, EventArgs.Empty);
}
⚠️ In long-lived publishers such as UI controls, strong event references can keep subscribers alive. Consider patterns like weak events in UI frameworks or explicit Dispose unsubscription to prevent memory leaks.

Action, Func, and Predicate

.NET ships general-purpose delegate types so you do not need to declare your own for common shapes. Action represents a method that returns void; Func represents a method that returns a value; Predicate<T> represents a method that returns bool for a single input.

Built-in delegates overview

The following table summarizes the most frequently used built-in delegates. The arity column indicates the number of input parameters.

Delegate Arity Signature Example
Action 0 () => void Action a = () => Console.WriteLine("Hi");
Action<T1, …> 1..16 (T1, …) => void Action<int,string> a = (i,s) => { … };
Func<TResult> 0 () => TResult Func<DateTime> now = () => DateTime.UtcNow;
Func<T1, …, TResult> 1..16 (T1, …) => TResult Func<int,int,int> add = (a,b) => a + b;
Predicate<T> 1 (T) => bool Predicate<int> even = n => n % 2 == 0;

Predicate<T> is equivalent to Func<T, bool>. Many .NET APIs prefer Func; older ones may still accept Predicate for readability.

Using built-in delegates with tasks and timers

Many .NET APIs accept Action or Func. For example, you can attach a continuation to a task or provide a callback to a timer using a lambda.

using System;
using System.Threading;
using System.Threading.Tasks;

Task.Run(() => 21)
  .ContinueWith(t => Console.WriteLine(t.Result * 2));  // Action<Task<int>>

using var timer = new Timer(_ => Console.WriteLine("Tick"), null, 0, 500); // Action<object?>
// keep process alive briefly
Thread.Sleep(1200);
πŸ’‘ When you see generic parameters ending with TResult in Func, the last generic type is the return type. All previous types are inputs.

Closures and captured variables

A lambda or anonymous method can capture variables from its enclosing scope. The captured variables are stored in a hidden closure object so the lambda can read or modify them even after the original scope has returned. This feature enables factories and deferred execution; it can also surprise you if you capture a loop variable incorrectly.

Capturing and mutating outer variables

Captured variables retain identity and changes are visible to all lambdas that close over the same variable. The lifetime extends to the last live delegate that references it.

Func Counter()
{
  int n = 0;                        // captured
  return () => { n++; return n; };  // closes over n
}

var next = Counter();
Console.WriteLine(next()); // 1
Console.WriteLine(next()); // 2
⚠️ Captures can extend object lifetimes. If the captured variable refers to a disposable or large object, be deliberate about when the delegate becomes unreachable.

Loop variable capture

Each foreach iteration variable is distinct. In a for loop the control variable is shared; create a local copy inside the loop when you need per-iteration capture.

var actions = new Action[3];

for (int i = 0; i < 3; i++)
{
  int copy = i;                  // capture the copy
  actions[i] = () => Console.WriteLine(copy);
}

foreach (var a in actions) a();  // prints 0 then 1 then 2
πŸ’‘ If you add static to a lambda it cannot capture outer variables; this guards against accidental captures and clarifies intent.

Multicast delegates

Delegates can reference multiple targets. When you combine delegates with + or subscribe multiple handlers to an event, you create a multicast delegate whose invocation list runs in order. If a target throws an exception and you invoke with normal call syntax, execution stops and the exception propagates.

Combining and removing delegates

Use + or += to combine, and - or -= to remove. The return value of a multicast delegate call is the return of the last invocation; do not rely on return values in multicast scenarios unless you control the list and design for it.

void A() { Console.Write("A"); }
void B() { Console.Write("B"); }

Action m = A;
m += B;
m();     // prints "AB"
m -= A;
m();     // prints "B"

Safely iterating the invocation list

To invoke all targets even if some throw exceptions, iterate GetInvocationList() and invoke each handler inside a try block. This is common in logging and notification pipelines.

Action handlers = () => Console.WriteLine("one");
handlers += () => throw new InvalidOperationException("boom");
handlers += () => Console.WriteLine("three");

foreach (var d in handlers.GetInvocationList())
{
  try
  {
    ((Action)d)();
  }
  catch (Exception ex)
  {
    Console.Error.WriteLine(ex.Message);
  }
}
πŸ’‘ Events naturally use multicast delegates. The pattern above is useful when you need best-effort delivery across many handlers in a server or plugin system.

Chapter 12: Pattern Matching

Pattern matching in C# lets you describe the shape of data rather than chase it through nested conditionals. The compiler checks the structure and types for you so your code reads like a clear explanation of intent. These features blend smoothly with .NET types such as records, tuples, collections, and primitives.

Type patterns and constant patterns

A type pattern checks whether a value is compatible with a specific type. If it is, the pattern can introduce a variable of that type. A constant pattern compares a value to a literal such as a number, a string, or null. You can combine these patterns with switch expressions or is expressions to write focused logic.

Using is with type patterns

The is keyword lets you test and assign at the same time. This helps avoid repeated casts or checks and keeps the flow readable.

object item = "hello";

if (item is string s)
{
  Console.WriteLine(s.ToUpper());
}
πŸ’‘ When a type pattern introduces a variable you can use it immediately within that scope which keeps branching code compact and descriptive.

Constant patterns in a switch expression

A constant pattern compares the input against known values. You often use this in small classification code or to handle discrete states.

string Classify(int code) => code switch
{
  1 => "One",
  2 => "Two",
  3 => "Three",
  _ => "Unknown"
};

The underscore pattern matches anything which makes it a good fallback in these cases.

Relational and logical patterns

Relational patterns let you form conditions with operators such as > and <=. Logical patterns combine subpatterns using and, or, and not. These patterns let you describe ranges and boundaries more naturally than a series of traditional if expressions.

Checking numeric ranges with relational patterns

Relational operators in patterns express the shape of numeric space directly. This improves the readability of validation code and threshold checks.

string Score(int value) => value switch
{
  < 0 => "Invalid",
  < 50 => "Low",
  < 80 => "Medium",
  _ => "High"
};
⚠️ Relational patterns depend on the underlying type supporting comparison. For custom types consider implementing comparison interfaces or using property patterns that examine sortable members.

Combining patterns with logical connectors

The logical connectors help narrow or broaden matches. Combine them to craft rich descriptions of state.

bool IsAdult(int age) => age is (>= 18 and <= 120);

bool IsWeekend(DayOfWeek d) =>
  d is (DayOfWeek.Saturday or DayOfWeek.Sunday);

bool IsNotEmpty(string? s) =>
  s is not null and not "";

These patterns remain readable even as the underlying logic grows.

Property and positional patterns

Property patterns match on the values of named members. Positional patterns match values by unpacking them through a deconstruct method. Records in .NET work especially well here because they automatically generate a deconstructor that fits these patterns.

Matching named members with property patterns

Property patterns use braces and named members to match interior structure. You can match partial shapes which means you do not need to list every member.

record User(string Name, int Level);

string Describe(User u) => u switch
{
  { Level: >= 5 } => "Advanced user",
  { Level: < 5 } => "New user",
  _ => "Unknown"
};
πŸ’‘ Property patterns shine when matching nested types. You can walk the structure layer by layer without deeply nested if statements.

Using positional patterns with records and tuples

Positional patterns rely on the order of elements returned by a deconstructor. Records generate this automatically although you can also define your own Deconstruct method for custom types.

record Point(int X, int Y);

string Quadrant(Point p) => p switch
{
  ( > 0, > 0 ) => "First",
  ( < 0, > 0 ) => "Second",
  ( < 0, < 0 ) => "Third",
  ( > 0, < 0 ) => "Fourth",
  _ => "Axis"
};

Tuples match just as naturally since their elements already have a fixed position.

List patterns and slices

List patterns match sequences by length, ordering, and the shape of individual elements. Slices use the .. operator to match variable-length sections. These features help express rules for arrays, lists, and any type that implements the right collection interfaces.

Fixed and open-ended list patterns

A list pattern can match exact shapes or open-ended ones. You can specify expected elements or use a slice to gather the middle portion.

string Classify(int[] values) => values switch
{
  [] => "Empty",
  [0] => "Single zero",
  [1, 2, 3] => "One two three",
  [1, .. var tail] => $"Starts with one then {tail.Length} more",
  _ => "Unknown"
};
⚠️ List patterns depend on the input type supporting length or count semantics. Arrays and many .NET collections work out of the box but custom types may require specific interfaces.

Matching nested list structures

You can nest list patterns inside property patterns to inspect structured or tree-like shapes.

record Node(string Name, Node[] Children);

bool IsLeaf(Node n) => n is { Children: [] };

bool StartsWithChild(Node n) => n is
{
  Children: [ { Name: "rootchild" }, .. ]
};

This kind of pattern creates a clear picture of hierarchical data.

Exhaustiveness and guard clauses

Switch expressions require all possible input shapes to be accounted for which gives you compile-time checking of completeness. A guard clause adds an extra condition to a pattern using when. This combination helps enforce correctness and clarity.

Ensuring full coverage in switch expressions

When the compiler knows all possible values of a type it checks for exhaustive handling. This works well for enum types and closed shapes such as records with limited patterns.

enum Light { Red, Yellow, Green }

string Action(Light l) => l switch
{
  Light.Red => "Stop",
  Light.Yellow => "Prepare",
  Light.Green => "Go"
};
πŸ’‘ If you see a compiler warning about non-exhaustive patterns the fix often involves adding a fallback case or expanding your pattern set to cover all known shapes.

Adding guards with when

A guard clause filters a matching pattern by an additional boolean expression. It provides a focused way to handle exceptions to common shapes.

string Describe(int value) => value switch
{
  0 => "Zero",
  > 0 and <= 10 when value % 2 == 0 => "Small even",
  > 0 and <= 10 => "Small odd",
  _ => "Large"
};

Guards help keep pattern bodies free from extra condition code because the check sits directly with the pattern itself.

Designing with patterns in mind

Pattern matching works best when the surrounding types lean into structure. Records, deconstructors, and explicit shapes make patterns more powerful and the code easier to follow. When you design classes and data flows with this in mind you create software that reads like a layered description of states and transitions.

Shaping your types for expressive patterns

Adding a Deconstruct method or using records can turn your types into well-shaped pattern targets. This lets callers inspect only the pieces they need while still preserving encapsulation.

class Range
{
  public int Start { get; }
  public int End { get; }

  public Range(int start, int end)
  {
    Start = start;
    End = end;
  }

  public void Deconstruct(out int start, out int end)
  {
    start = Start;
    end = End;
  }
}

string Classify(Range r) => r switch
{
  (var s, var e) when s < e => "Forward",
  (var s, var e) when s > e => "Backward",
  _ => "Point"
};

Designing your data this way builds a steady rhythm across your code where each pattern tells a small story about shape and intent.

Chapter 13: Error Handling and Diagnostics

Error handling in C# leans on exceptions, structured diagnostics, and clear separation between normal program flow and fault handling. These tools help you respond to unexpected states without cluttering everyday logic. .NET supports rich debugging, structured logging, and strong exception hierarchies which together form a reliable diagnostic toolkit.

Exceptions and best practices

Exceptions represent exceptional conditions rather than routine branching. They carry stack information, message text, and optional inner exceptions that trace chains of failure. Good practice involves throwing only meaningful exceptions, catching them at boundaries where recovery is possible, and letting them bubble upward when the caller is better suited to decide what to do.

When to throw and when to avoid exceptions

Throw an exception when something breaks a contract such as invalid input, missing resources, or impossible states. Avoid exceptions for predictable control flow because frequent throwing is slower and obscures the intent of the code.

int ParsePort(string text)
{
  if (!int.TryParse(text, out int port))
  {
    throw new FormatException("Port must be a valid integer");
  }
  return port;
}
πŸ’‘ Use TryParse and similar methods when invalid input is common because this prevents unnecessary exceptions during routine checks.

Preserving stack information with rethrowing

If you catch an exception only to log or wrap it you can rethrow with throw to preserve the original stack trace. Using throw ex resets the trace which loses important context.

try
{
  DangerousWork();
}
catch (Exception ex)
{
  Log(ex);
  throw;  // preserves stack trace
}

Preserving the trace makes debugging far clearer because the original fault location remains intact.

Creating custom exceptions

Custom exceptions help describe domain-specific errors. They should derive from Exception or an appropriate subclass, include the standard constructors, and stay focused on their purpose. Adding custom properties is fine when the extra information aids error handling.

Defining a well-formed custom exception

The following pattern includes the common constructors and preserves serialization compatibility in full .NET environments.

using System;

public class CalculationException : Exception
{
  public int Operand { get; }

  public CalculationException(string message, int operand)
    : base(message)
  {
    Operand = operand;
  }

  public CalculationException(string message, Exception inner, int operand)
    : base(message, inner)
  {
    Operand = operand;
  }
}
⚠️ Custom exceptions should be used sparingly. A small, meaningful hierarchy is easier to understand than many narrowly focused types.

Wrapping lower-level exceptions

When you translate errors between layers wrap the original exception as an inner exception. This preserves context and helps the caller see the chain of failure.

try
{
  LoadUserProfile(path);
}
catch (IOException ex)
{
  throw new ProfileException("Profile load failed", ex);
}

Wrapped exceptions form a breadcrumb trail that leads back to the original source.

Exception handling

C# structures exception handling with try, catch, optional filters, and finally. Filters let you narrow which exceptions a block should handle. A finally section always runs which provides a safe place for cleanup.

Using filters to narrow catch scope

A filter uses when to decide whether a catch block should run. This is more expressive than catching then rethrowing.

try
{
  Process(value);
}
catch (Exception ex) when (ex is InvalidOperationException)
{
  Console.Error.WriteLine("Operation error");
}

Filters improve clarity because you can explain the condition succinctly and avoid unnecessary handling.

Ensuring cleanup with finally

The finally clause is ideal for releasing handles, closing streams, or resetting state. It executes whether the operation succeeds, fails, or returns early.

FileStream? fs = null;
try
{
  fs = File.OpenRead(path);
  ReadConfig(fs);
}
finally
{
  fs?.Dispose();
}
πŸ’‘ using statements handle disposal automatically and often remove the need for an explicit finally block.

Debugging with the CLI and IDEs

Debugging in .NET works well from both the command line and graphical environments. The CLI provides inspection and control tools while IDEs such as Visual Studio and VS Code offer breakpoints, watches, step-through execution, and rich visualizers.

Using the dotnet CLI for diagnostics

The dotnet toolchain includes commands for structured diagnostics. You can produce dumps, inspect dependencies, and run analyzers directly from the terminal.

dotnet dump collect --process-id 1234
dotnet trace collect --process-id 1234
dotnet tool run analyze …

These tools help when debugging remote systems or processes without graphical interfaces.

Working with breakpoints and watches in IDEs

Breakpoints pause execution exactly at the point of interest. Watches let you track variables and expressions as you step through code which gives you a close-up view of program state.

// Example stub for debugging session
int x = ComputeTotal();
int y = ComputeTotal();
int z = x + y;  // breakpoint here
Console.WriteLine(z);
⚠️ Step into asynchronous code can jump across threads and contexts. IDE visualizers help follow the path by showing awaited tasks and continuations.

Assertions and defensive programming

Assertions verify assumptions during development. Defensive checks validate inputs and states at runtime. C# and .NET provide mechanisms for both which help prevent subtle logic errors.

Assertions for development-time assumptions

Assertions confirm internal expectations. They are not intended to handle user-facing errors but instead catch programming mistakes early.

using System.Diagnostics;

Debug.Assert(items.Count > 0, "Items must not be empty");

// production code follows

Assertions only fire in debug builds so they introduce no overhead in release configurations.

Writing clear defensive checks

Defensive checks ensure the program does not continue with invalid data. They should use precise exceptions and clear messages.

public void SetRate(double rate)
{
  if (rate < 0) throw new ArgumentOutOfRangeException(nameof(rate));
  _rate = rate;
}
πŸ’‘ Defensive checks guide callers gently toward correct usage which leads to more predictable code across the system.

Logging basics

Logging provides a running record of application events. .NET’s ILogger system offers structured logs, scoped categories, and pluggable providers such as console output, files, or centralized collectors. Good logs help you reconstruct the story of a failure with minimal guesswork.

Structured messages with ILogger

Structured logging separates message templates from data values which enhances filtering and analysis. This pattern is used by built-in .NET logging frameworks.

using Microsoft.Extensions.Logging;

void Run(ILogger logger)
{
  int count = 3;
  logger.LogInformation("Processing {Count} items", count);
}

Log entries stay consistent because names like Count remain stable even as values vary.

⚠️ Logging too much at high severity levels can drown real issues. Reserve warnings and errors for genuinely abnormal conditions.

Configuring providers and levels

You configure loggers by adding providers and choosing levels. Console logging is common during development while production systems often route logs to structured stores or observability platforms.

using Microsoft.Extensions.Logging;

using var loggerFactory = LoggerFactory.Create(builder =>
{
  builder
    .AddConsole()
    .SetMinimumLevel(LogLevel.Information);
});

var logger = loggerFactory.CreateLogger("Demo");
logger.LogWarning("Low disk space");

A thoughtful logging strategy becomes a long-term ally when diagnosing rare or intermittent faults.

Chapter 14: Files, Streams, and Serialization

In this chapter you learn how to work with the file system using the System.IO namespace, how to create and consume Stream types, how to read and write binary and text data, how to serialize objects to JSON, how to use asynchronous I/O with buffering, and how to ensure resources are cleaned up with using. The focus is on C# syntax and patterns; .NET types and runtime behavior are woven in where they directly affect your code.

Working with paths and directories

The Path, Directory, DirectoryInfo, File, and FileInfo types provide the building blocks for working with files and folders. You can combine segments, normalize separators, inspect the current directory, and enumerate content without touching file data. These APIs are cross-platform; separators are handled for you on Windows, macOS, and Linux.

Common Path operations

Use Path.Combine to join segments; use Path.GetExtension, GetFileName, and GetDirectoryName to dissect a path. Path.Join avoids adding redundant separators; Path.GetTempPath returns a suitable temporary location.

using System;
using System.IO;

string baseDir = Environment.CurrentDirectory;
string sub = "data";
string file = "report.txt";
string full = Path.Combine(baseDir, sub, file);
Console.WriteLine(full);
Console.WriteLine(Path.GetExtension(full));  // .txt
Console.WriteLine(Path.GetFileName(full));   // report.txt
Console.WriteLine(Path.GetDirectoryName(full));
πŸ’‘ Prefer Path.Combine and Path.Join over string concatenation; they handle separators safely across platforms.

Creating and enumerating directories

Directory.CreateDirectory makes a folder if it does not exist. Enumerate with Directory.EnumerateFiles and Directory.EnumerateDirectories; the Enumerate methods stream results which is better for large trees than the Get methods that materialize arrays.

string root = Path.Combine(baseDir, "logs");
Directory.CreateDirectory(root);

foreach (string path in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories))
{
  Console.WriteLine(path);
}

File and directory metadata

Query and modify attributes with File.GetAttributes, File.SetAttributes, and the info types. Timestamps are available as creation, last access, and last write times; use UTC where possible.

var info = new FileInfo(full);
if (info.Exists)
{
  Console.WriteLine($"{info.Length} bytes; modified {info.LastWriteTimeUtc:u}");
}
⚠️ Relative paths are resolved against Environment.CurrentDirectory. If your process changes the working directory, resolve early to absolute paths with Path.GetFullPath.

Streams and readers

A Stream represents a sequence of bytes that can be read and or written. Concrete types include FileStream for files, MemoryStream for in-memory buffers, NetworkStream for sockets, and GZipStream for compression. Text helpers such as StreamReader and StreamWriter layer character encoding on top of byte streams.

Using FileStream effectively

FileStream exposes buffered I/O with synchronous and asynchronous members. Opening with FileMode, FileAccess, and FileShare lets you control intent precisely. For modern code prefer the FileStream constructor that accepts FileStreamOptions when you need fine control.

using var fs = new FileStream(
  path: full,
  mode: FileMode.OpenOrCreate,
  access: FileAccess.ReadWrite,
  share: FileShare.Read);

// Read or write bytes with fs.Read, fs.Write, or their async counterparts

StreamReader and StreamWriter for text

Wrap a stream with StreamReader for decoding and with StreamWriter for encoding. Specify Encoding.UTF8 to be explicit and portable.

using var writer = new StreamWriter(full, append: true, encoding: System.Text.Encoding.UTF8);
writer.WriteLine("Hello");
writer.Write("World");

using var reader = new StreamReader(full, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
string all = reader.ReadToEnd();
Console.WriteLine(all);
πŸ’‘ StreamWriter buffers data; call Flush or dispose to force the write. Disposing is sufficient in most cases.

Binary and text I/O

Choose text I/O when you need human readable content that respects encodings and line endings. Choose binary I/O for compact data or when you must preserve exact bytes. BinaryReader and BinaryWriter provide typed methods for primitives; endianness matters for cross-system exchange.

Writing and reading text

The File convenience methods are terse and allocate complete buffers; use streams if you need streaming or partial reads.

string[] lines = { "alpha", "beta", "gamma" };
File.WriteAllLines(full, lines, System.Text.Encoding.UTF8);

foreach (string line in File.ReadLines(full, System.Text.Encoding.UTF8))
{
  Console.WriteLine(line);
}

Writing and reading binary

With BinaryWriter and BinaryReader you control the exact layout. Document the layout or include a simple header with a magic value and version number.

using var bfs = new FileStream(full + ".bin", FileMode.Create, FileAccess.Write, FileShare.None);
using var bw = new BinaryWriter(bfs);
bw.Write(0xCAFEBABE);  // magic
bw.Write(1);           // version
bw.Write(3.14159);     // double
bw.Write(true);        // bool
bw.Write("hello");     // length-prefixed UTF-8 by default

bfs.Flush();

using var rfs = new FileStream(full + ".bin", FileMode.Open, FileAccess.Read, FileShare.Read);
using var br = new BinaryReader(rfs);
int magic = br.ReadInt32();
int version = br.ReadInt32();
double pi = br.ReadDouble();
bool ok = br.ReadBoolean();
string text = br.ReadString();
Console.WriteLine($"{magic:x}; v{version}; {pi}; {ok}; {text}");
⚠️ BinaryReader and BinaryWriter use little-endian order for numeric values on .NET. If you must exchange with big-endian systems, write bytes manually or use BinaryPrimitives.

JSON serialization

System.Text.Json provides fast JSON serialization and deserialization. Use JsonSerializer.Serialize and JsonSerializer.Deserialize for simple cases; configure JsonSerializerOptions for naming policies, comments, numbers, and other behavior. Attributes on your types control property names, null handling, and conversions.

Basic JsonSerializer usage

Define plain objects with public getters and setters. Anonymous types can be serialized to JSON but not deserialized back. Records provide concise immutable models.

using System.Text.Json;
using System.Text.Json.Serialization;

public record Book(
  string Title,
  string Author,
  int Year);

var model = new Book("This is C# & .NET", "Robin", 2025);

var options = new JsonSerializerOptions
{
  WriteIndented = true,
  PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

string json = JsonSerializer.Serialize(model, options);
Console.WriteLine(json);

// {"title":"This is C# & .NET","author":"Robin","year":2025}

Deserializing and dealing with missing data

When reading unknown JSON shape, target JsonDocument or JsonElement for DOM-style access. For streaming large payloads use Utf8JsonReader and Utf8JsonWriter.

string incoming = "{\"title\":\"This is C#\",\"meta\":{\"tags\":[\"csharp\",\"dotnet\"],\"isbn\":\"...\"}}";
using JsonDocument doc = JsonDocument.Parse(incoming);
JsonElement root = doc.RootElement;
Console.WriteLine(root.GetProperty("title").GetString());
if (root.TryGetProperty("meta", out var meta) && meta.TryGetProperty("isbn", out var isbn))
{
  Console.WriteLine(isbn.GetString());
}
πŸ’‘ Use [JsonPropertyName("...")], [JsonIgnore], and custom converters to keep your public model clean while matching external JSON.

Async I/O and buffering

Asynchronous I/O prevents blocking threads while the operating system performs disk or network work. Use await with ReadAsync, WriteAsync, and higher-level helpers such as File.ReadAllTextAsync. Buffer sizes affect throughput and memory; choose values that suit your device and workload.

Asynchronous file operations

These helpers read or write whole files. For streaming, call async members on streams. Configure FileStreamOptions to enable or disable buffering as needed.

using System.Buffers;
using System.IO;
using System.Text;
using System.Threading.Tasks;

string path = Path.Combine(Environment.CurrentDirectory, "async.txt");
await File.WriteAllTextAsync(path, "Hello async I/O", Encoding.UTF8);

await using var fs = new FileStream(path, new FileStreamOptions
{
  Mode = FileMode.Open,
  Access = FileAccess.Read,
  Share = FileShare.Read,
  BufferSize = 64 * 1024,
  Options = FileOptions.Asynchronous | FileOptions.SequentialScan
});

byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
  int read;
  while ((read = await fs.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0)
  {
    // process bytes...
  }
}
finally
{
  ArrayPool<byte>.Shared.Return(buffer);
}

Working with MemoryStream and pipelines

MemoryStream is convenient for small to medium buffers. For high-throughput scenarios consider System.IO.Pipelines which offers producer and consumer friendly primitives. The examples in this book keep to Stream for simplicity.

⚠️ Async methods still use buffers. Large buffers reduce syscalls but use more memory. Measure with realistic data before choosing sizes.

Resource cleanup with using

Types that hold unmanaged resources implement IDisposable or IAsyncDisposable. Use the using statement or the using declaration to ensure timely cleanup even if exceptions occur. Async disposal releases resources that need asynchronous finalization such as flushing async pipelines.

using statement and declaration

The classic using statement scopes the resource to a block. The declaration form disposes when the enclosing scope ends which can reduce nesting. Choose the form that is clearer for the reader of your code.

// Statement form
using (var stream = File.OpenRead(full))
{
  // use stream
}

// Declaration form
using var stream2 = File.OpenRead(full);
// use stream2
// disposed at end of scope

Async disposal with await using

When a type implements IAsyncDisposable, use await using. File streams support async disposal; network streams and writers often do as well.

await using var asyncStream = new FileStream(full, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
// use asyncStream with await
// disposed asynchronously at end of scope
πŸ’‘ If a type offers both sync and async methods, prefer sync calls inside a sync code path and async calls inside an async path; mixing can hurt performance and clarity.

Chapter 15: Asynchrony and Concurrency

C# provides first class support for asynchronous and parallel programming through the Task Parallel Library and language features that integrate with .NET scheduling and synchronization services. This chapter explains how to use Task, the thread pool, async and await, cancellation, synchronization primitives, parallel loops, and how to avoid common pitfalls when building responsive and scalable applications.

Tasks and the ThreadPool

A Task represents an operation that may complete in the future; it can be awaited, composed, and scheduled efficiently on the .NET ThreadPool. The thread pool manages a set of worker threads and uses work stealing queues to balance load which helps you avoid creating raw threads in most cases.

Creating and running Tasks

You can create tasks from delegates, use Task.Run to offload CPU bound work to the pool, or return tasks directly from asynchronous APIs. For naturally asynchronous I/O prefer methods that already return Task or ValueTask and avoid wrapping with Task.Run unnecessarily.

// CPU bound work moved to the pool
Task<int> primeCount = Task.Run(() => CountPrimes(2, 1_000_000));

// Composing tasks
Task both = Task.WhenAll(primeCount, Task.Delay(100));
int result = await primeCount;

Task states and continuations

A task is created, scheduled, then completes in a RanToCompletion, Faulted, or Canceled state. Continuations attach follow up work; prefer await since it composes continuations safely and clearly. For performance sensitive hot paths consider ValueTask when results are often synchronous.

// Continuations are created for you by await
try
{
  int n = await primeCount;
  Console.WriteLine(n);
}
catch (Exception ex)
{
  Console.WriteLine(ex.Message);
}
πŸ’‘ Use Environment.ProcessorCount to estimate parallelism for CPU bound work; always test under production like load because scheduling and contention vary by environment.

async and await

The async modifier enables methods to use await which asynchronously waits for a task to complete and resumes without blocking a thread. The compiler transforms the method into a state machine that coordinates continuations with the current SynchronizationContext or with the thread pool when there is none.

Signatures and return types

Use Task for asynchronous methods that do not produce a value, Task<T> for those that return a value, and ValueTask/ValueTask<T> to reduce allocations when completion is often synchronous. Event handlers can use async void because they integrate with UI event pipelines; avoid async void elsewhere.

public async Task<string> FetchAsync(HttpClient http, string url)
{
  using HttpResponseMessage res = await http.GetAsync(url);
  return await res.Content.ReadAsStringAsync();
}

Context capture and ConfigureAwait

By default await captures the current context then continues there. In library code or server code where a specific context is not required you can append .ConfigureAwait(false) to skip capture which reduces overhead and avoids reentry issues. Keep context capture for UI refreshes or when a synchronization context is required.

string body = await http.GetStringAsync(url).ConfigureAwait(false);
⚠️ Do not mix blocking waits such as Task.Wait() or Result with await on the same call chain; this pattern can deadlock in contexts that require marshaling back to a single thread.

CancellationToken and timeouts

Cancellation is cooperative; you pass a CancellationToken to operations that may be canceled and they observe the token periodically. Timeouts are a special case of cancellation which can be implemented with timers or with methods that accept a timeout value in .NET libraries.

Creating and linking tokens

Use CancellationTokenSource to create tokens, call Cancel to signal, and dispose sources when finished. You can link tokens so that canceling any of them propagates to the linked token.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, externalToken);
await DoWorkAsync(linked.Token);

Observing cancellation

Asynchronous methods should accept a CancellationToken, pass it to I/O operations, and check token.ThrowIfCancellationRequested() at sensible points. When canceled, throw OperationCanceledException with the token so callers can distinguish cancellation from faults.

public async Task ProcessAsync(Stream s, CancellationToken token)
{
  byte[] buffer = new byte[8192];
  int read;
  while ((read = await s.ReadAsync(buffer, 0, buffer.Length, token)) != 0)
  {
    token.ThrowIfCancellationRequested();
    await HandleAsync(buffer.AsMemory(0, read), token);
  }
}

Synchronization primitives

Correct concurrent code protects shared state using primitives that coordinate access. C# provides lock which uses Monitor; .NET adds SemaphoreSlim, Mutex, ReaderWriterLockSlim, atomic helpers in Interlocked, and concurrent collections in System.Collections.Concurrent.

lock and Monitor

lock serializes access to a critical section. Always lock on a private object to avoid accidental external locking. Keep critical sections short to reduce contention and avoid calling into user code while holding a lock.

private readonly object _gate = new object();
private int _count;

public void Increment()
{
  lock (_gate)
  {
    _count++;
  }
}

SemaphoreSlim and asynchronous gates

SemaphoreSlim limits concurrent access and supports WaitAsync. This is useful when controlling parallelism for I/O bound work such as HTTP calls or database queries.

private readonly SemaphoreSlim _sem = new SemaphoreSlim(4);
public async Task FetchManyAsync(IEnumerable<string> urls)
{
  foreach (var url in urls)
  {
    await _sem.WaitAsync();
    _ = Task.Run(async () =>
    {
      try { await FetchAsync(url); }
      finally { _sem.Release(); }
    });
  }
}

Interlocked and concurrent collections

Interlocked performs atomic operations such as increment and exchange without locks. For shared dictionaries and queues prefer ConcurrentDictionary<,>, ConcurrentQueue<T>, and BlockingCollection<T> which handle synchronization internally.

int next = Interlocked.Increment(ref _count);
var cache = new ConcurrentDictionary<string, string>();
string value = cache.GetOrAdd(key, k => Compute(k));
Primitive Use for Async friendly
lock/Monitor Short critical sections No
SemaphoreSlim Bounded concurrency Yes
ReaderWriterLockSlim Many readers few writers No
Interlocked Atomic counters and flags Yes
ConcurrentDictionary Shared map with contention Yes

Parallel loops and data parallelism

Data parallelism partitions a collection so that multiple items are processed concurrently. .NET provides Parallel.For, Parallel.ForEach, and PLINQ which distribute work over the thread pool. Prefer these for CPU bound operations where each iteration is independent and side effects are controlled.

Parallel.ForEach with options

ParallelOptions lets you cap parallelism and pass a cancellation token. This helps tune throughput and protect downstream services.

var options = new ParallelOptions
{
  MaxDegreeOfParallelism = Environment.ProcessorCount,
  CancellationToken = token
};

Parallel.ForEach(images, options, img =>
{
  ProcessImage(img);
});

PLINQ and ordering

PLINQ parallelizes LINQ queries with AsParallel(). Use AsOrdered() when you must preserve input order which can reduce performance; omit ordering when possible for better throughput.

var hashes = files
  .AsParallel()
  .WithDegreeOfParallelism(Environment.ProcessorCount)
  .Select(f => HashFile(f))
  .ToList();
πŸ’‘ For I/O bound fan out patterns prefer async concurrency with Task.WhenAll over Parallel; this avoids occupying worker threads while awaiting I/O.

Concurrency pitfalls and fixes

Concurrency introduces subtle bugs that pass unit tests and fail under load. Recognize common problems and apply targeted fixes that use language and .NET features carefully.

Deadlocks from blocking on async

Blocking on an async method with .Result or .Wait() can deadlock when continuations need the original context. Make the entire call chain async and use await with ConfigureAwait(false) in library code that does not need context.

// Bad
// var text = http.GetStringAsync(url).Result;

// Good
string text = await http.GetStringAsync(url).ConfigureAwait(false);

Race conditions and torn reads

Unsynchronized reads and writes can interleave and produce inconsistent results. Use lock for compound operations, or atomic APIs in Interlocked. For shared flags use Volatile to prevent reordering when simple reads and writes are sufficient.

private int _state;
public void Advance()
{
  int s1 = Interlocked.CompareExchange(ref _state, 1, 0);
  if (s1 == 0)
  {
    Initialize();
  }
}

Starvation and unbounded fan out

Creating too many tasks at once can overwhelm the scheduler and starve important work. Use SemaphoreSlim to bound concurrency, batch work, or use dataflow blocks in System.Threading.Tasks.Dataflow which include built in capacity limits and backpressure.

var gate = new SemaphoreSlim(8);
var tasks = new List<Task>();

foreach (var u in urls)
{
  await gate.WaitAsync();
  tasks.Add(Task.Run(async () =>
  {
    try { await FetchAsync(u); }
    finally { gate.Release(); }
  }));
}

await Task.WhenAll(tasks);
⚠️ Exceptions thrown in fire and forget tasks are observed later by the runtime; attach handlers, store the task for awaiting, or use TaskScheduler.UnobservedTaskException carefully to log failures.

This chapter introduced the core asynchronous and concurrent programming tools in C# and .NET; you can now design responsive services and applications that scale well while remaining correct under load.

Chapter 16: Reflection, Attributes, and Source Generators

Reflection gives your code a way to examine types, members, and metadata at runtime. Attributes let you declare extra information that tools and libraries can discover. Source generators operate at compile time and create code before your assembly is built. This chapter shows how these features relate and how to use them in a practical and efficient way.

Type inspection at runtime

The System.Reflection API lets you read type information such as methods, constructors, properties, fields, interfaces, generic parameters, and custom attributes. Type is the central entry point; you obtain it from typeof(SomeType), instance.GetType(), or by loading an assembly and searching for exported types.

Discovering type structure

Use the members on Type to examine a structure. You can check whether a type is a class, struct, enum, or interface; you can test whether it is generic; you can read its base type and implemented interfaces. This powers object mappers, serializers, plug in systems, and more.

Type t = typeof(Dictionary<string, int>);

Console.WriteLine(t.FullName);
Console.WriteLine(t.IsClass);
Console.WriteLine(t.GetGenericArguments().Length);

foreach (var p in t.GetProperties())
{
  Console.WriteLine($"{p.Name}: {p.PropertyType}");
}
πŸ’‘ When you only need lightweight inspection, prefer Type members that avoid materializing arrays; for example EnumerateCustomAttributes from newer APIs helps reduce allocations.

Loading assemblies

You can load assemblies with Assembly.Load, Assembly.LoadFrom, or by reading the dependency context in an application. Once loaded, call GetTypes or ExportedTypes to explore available types. This is the backbone of plugin discovery.

var asm = Assembly.Load("MyPluginAssembly");
foreach (var type in asm.GetTypes())
{
  if (typeof(IMyPlugin).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract)
  {
    Console.WriteLine("Found plugin: " + type.FullName);
  }
}

Invoking members via reflection

Reflection can invoke methods, create instances, and set or get fields and properties. This gives flexible behavior but avoids compile time checks so you handle errors carefully. Performance is slower than direct calls because reflection goes through metadata and performs safety checks.

Constructing instances

Locate constructors with GetConstructors or GetConstructor then call Invoke. For parameterless constructors you can use Activator.CreateInstance which is shorter and supports generics.

Type target = typeof(Person);
object p = Activator.CreateInstance(target)!;
Console.WriteLine(p);

Invoking methods and accessing members

Methods come from GetMethod or from a LINQ search over GetMethods(). Properties expose GetValue and SetValue. Fields expose GetValue and SetValue directly.

var sayMethod = target.GetMethod("Say", new[] { typeof(string) });
sayMethod?.Invoke(p, new object[] { "Hello" });

var nameProp = target.GetProperty("Name");
nameProp?.SetValue(p, "Riley");
string name = (string)nameProp?.GetValue(p)!;
⚠️ Binding flags control visibility; for example BindingFlags.Instance, BindingFlags.Public, BindingFlags.NonPublic, and BindingFlags.Static. Get the combination right or you may not find the member you expect.

Attributes and metadata

Attributes are small metadata objects attached to declarations. They guide frameworks, control serialization, describe validation rules, document intent, and influence tooling. They are passive until someone queries them through reflection or through a generator.

Declaring custom attributes

Attributes are classes that inherit from System.Attribute. You decorate targets like classes, methods, properties, enums, and parameters. The AttributeUsage attribute controls where your attribute can appear and whether multiple instances are allowed.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class TagAttribute : Attribute
{
  public string Name { get; }
  public TagAttribute(string name) => Name = name;
}

Reading attributes at runtime

Use GetCustomAttributes to retrieve instances. Many frameworks scan attributes to influence behavior; for example JSON serializers and dependency injection containers use them widely.

foreach (var attr in target.GetCustomAttributes(typeof(TagAttribute), inherit: true))
{
  var tag = (TagAttribute)attr;
  Console.WriteLine(tag.Name);
}
πŸ’‘ Attributes should be lightweight because reflection instantiates them when queried. For heavy data use readonly fields or constants rather than costly logic in constructors.

Caller info attributes

C# provides attributes that let you capture information about the calling location without writing extra code. These attributes attach to optional parameters and the compiler fills in values at the call site. This is helpful in logging, diagnostics, and validation.

Using caller info parameters

You attach attributes such as CallerMemberName, CallerLineNumber, and CallerFilePath to optional parameters. The compiler inserts the member name, line number, or file path of the calling location. This reduces boilerplate in debug code.

public void Log(
  string message,
  [CallerMemberName] string member = "...",
  [CallerLineNumber] int line = 0,
  [CallerFilePath] string file = "...")
{
  Console.WriteLine($"{file}({line}) {member}: {message}");
}

Log("Testing");

Introduction to source generators

Source generators are Roslyn components that run during compilation and produce additional C# source code. They do not rewrite user code; instead they supply new files to the compiler. Generators help eliminate reflection at runtime, build serializers, create bindings, and generate repetitive or pattern based code.

How source generators work

A generator implements IIncrementalGenerator or ISourceGenerator. The compiler invokes it with syntax trees and semantic models. The generator analyzes this information then calls context.AddSource with generated code. Incremental generators cache inputs which reduces work for unchanged files.

[Generator]
public class HelloGenerator : ISourceGenerator
{
  public void Initialize(GeneratorInitializationContext context) { }

  public void Execute(GeneratorExecutionContext context)
  {
    string source = "public static class Hello { public static string Text => \"Hello\"; }";
    context.AddSource("Hello.g.cs", source);
  }
}

Scenarios for generators

Generators suit tasks where analyzing code at compile time lets you avoid runtime reflection. They are useful for serializers, dependency injection setups, binding layers, configuration mapping, and compile time validation. Generated code becomes normal C# in your assembly which improves startup time and reduces allocations.

⚠️ Generators run inside the compiler. Avoid heavy I/O and keep algorithms efficient to maintain good edit and build responsiveness.

Performance considerations

Reflection and attributes add flexibility but can be slower than direct code paths. Source generators shift work to compile time which often improves runtime performance but at the cost of more complex build steps. Understanding the trade offs helps you choose the right tool for the right scenario.

Reducing reflection overhead

Cache reflected members such as MethodInfo and PropertyInfo to avoid repeated metadata walks. Use delegates created with MethodInfo.CreateDelegate to invoke methods quickly. Prefer generic constraints and direct calls whenever possible because they let the JIT optimize aggressively.

MethodInfo mi = typeof(Person).GetMethod("Say")!;
var fast = (Action<Person, string>)mi.CreateDelegate(typeof(Action<Person, string>));

fast(new Person(), "Hi");

Balancing generators and reflection

Some systems combine generators and reflection. Generators scan types at compile time then produce code that avoids reflection during execution. This hybrid pattern reduces startup work and gives predictable performance for services that handle many requests.

Reflection, attributes, and source generators give you powerful tools across runtime and compile time. When used thoughtfully they help you build flexible, maintainable, and high performance applications.

Chapter 17: Interop and Unsafe Code

C# runs on a managed runtime, which is a powerful environment for safety and productivity. Sometimes you need to cross the boundary into native code for platform APIs, performance, or existing libraries. This chapter explains how to call native functions, control memory with stackalloc and fixed, work with pointers and pinning, describe data layouts for marshalling, decide when unsafe constructs are appropriate, and verify correctness with tests.

Calling native code

The primary interop mechanism is Platform Invocation Services, often called P/Invoke. You declare a managed signature with [DllImport] or the modern source-generated [LibraryImport] and the runtime marshals parameters across the boundary. You choose the target library, calling convention, character set, and error handling. On .NET you can call system libraries and third-party native libraries on Windows, Linux, and macOS; the exact library names differ by platform.

P/Invoke basics with [DllImport]

[DllImport] binds a managed method to a native export. Specify the library, entry point when it differs, and marshalling hints. The example shows different libraries per platform using conditional compilation.

using System;
using System.Runtime.InteropServices;

internal static partial class Native
{
#if WINDOWS
  private const string Lib = "kernel32";
  [DllImport(Lib, SetLastError = true, ExactSpelling = true)]
  public static extern uint GetTickCount();
#else
  private const string Lib = "libc";
  [DllImport(Lib, ExactSpelling = true)]
  public static extern int printf(string format, __arglist);
#endif
}

public static class Demo
{
  public static void Main()
  {
#if WINDOWS
    uint ms = Native.GetTickCount();
    Console.WriteLine($"Tick count: {ms}");
#else
    Native.printf("Hello from libc: %s\n", __arglist("C#"));
#endif
  }
}
πŸ’‘ Use ExactSpelling and CharSet to control name decoration and character marshalling. For Win32 APIs that set thread-local errors, pass SetLastError = true and read it with Marshal.GetLastWin32Error().

Source-generated marshalling

[LibraryImport] (the DllImportGenerator) moves marshalling work to compile time and can improve startup and throughput. It uses a partial method pattern that the generator fills in. This reduces runtime reflection and supports advanced custom marshalling.

using System.Runtime.InteropServices;
using System.Runtime.CompilerServices;

internal static partial class LibC
{
  [LibraryImport("libc", EntryPoint = "strlen")]
  internal static partial nuint strlen(byte* s);
}

unsafe class SGDemo
{
  static void Main()
  {
    byte* bytes = stackalloc byte[] { (byte)'h', (byte)'i', 0 };
    nuint n = LibC.strlen(bytes);
    System.Console.WriteLine(n);
  }
}
⚠️ The availability of [LibraryImport] depends on your target framework and SDK. Check your project’s target for support and keep the package and SDK in sync with your .NET version.

Reverse interop

When native code needs to call into managed code, mark a static method with [UnmanagedCallersOnly] and obtain a function pointer. Export this pointer to native code through an embedding API or by passing it to a registration function.

using System;
using System.Runtime.InteropServices;

public static class Callbacks
{
  [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
  public static int Add(int a, int b) => a + b;
}

unsafe class ReverseDemo
{
  static void Main()
  {
    delegate* unmanaged[Cdecl]<int,int,int> fp = (delegate* unmanaged[Cdecl]<int,int,int>)(nint)Marshal.GetFunctionPointerForDelegate(
      (Func<int,int,int>)((x, y) => x + y)  /* … choose strategy to obtain a stable pointer … */);
    int sum = fp(2, 3);
    Console.WriteLine(sum);
  }
}

In production code prefer stable exports using AOT or a hosting API instead of transient delegates, and ensure lifetime management for any delegate whose pointer you share.

stackalloc and fixed

stackalloc allocates a block on the current stack frame for short-lived buffers. fixed creates a pinned pointer to a managed object or a fixed-size buffer inside an unsafe struct. Both are useful at interop boundaries where a contiguous, pinned region is required.

stackalloc with Span<T> for safe slices

stackalloc returns a pointer in unsafe code or a Span<T> in safe code. Span<T> enables bounds-checked slicing without garbage collection pressure, which is ideal when preparing data for native calls.

using System;

static class StackallocDemo
{
  static void Main()
  {
    Span<byte> buffer = stackalloc byte[256];
    buffer.Clear();
    var slice = buffer.Slice(0, 8);
    for (int i = 0; i < slice.Length; i++) slice[i] = (byte)i;
    Console.WriteLine(slice.Length);
  }
}
πŸ’‘ Use stackalloc for small buffers sized from known upper bounds. For variable or large sizes, heap allocation is safer; otherwise you risk stack overflow.

The fixed statement

fixed obtains a stable pointer so the garbage collector cannot move the target while native code runs. You can pin arrays, strings, or fields in an unsafe struct that declares fixed-size buffers.

unsafe struct Packet
{
  public fixed byte Data[128]; // inline buffer, not a managed array
}

unsafe class FixedDemo
{
  static void Main()
  {
    byte[] managed = new byte[4] { 1, 2, 3, 4 };
    fixed (byte* p = managed)
    {
      for (int i = 0; i < 4; i++) *(p + i) = (byte)(*(p + i) * 2);
    }
  }
}

Pointers and pinning

Pointers in C# use T* syntax and require an unsafe context. You can dereference with *, index with [], and do arithmetic only in unsafe code. Pinning ensures the garbage collector does not relocate an object while a native function uses its address.

Pointer types, conversions, and arithmetic

Pointer conversions must be explicit. Cast between pointer types with care and only when the underlying representation matches. Arithmetic respects the size of the pointed-to type in bytes.

unsafe class PtrDemo
{
  static void Main()
  {
    int value = 42;
    int* p = &value;
    byte* b = (byte*)p;           // explicit cast
    *(p) = 100;
    Console.WriteLine(*p);
    Console.WriteLine(*(b + 0));  // low byte view
  }
}

Pinning with fixed and GCHandle

fixed pins for the duration of the statement. For longer lifetimes, use GCHandle.Alloc(obj, GCHandleType.Pinned) and free it when finished. Do not leave objects pinned unnecessarily because it can fragment the heap.

using System;
using System.Runtime.InteropServices;

unsafe class PinDemo
{
  static void Main()
  {
    var arr = new byte[1024];
    var handle = GCHandle.Alloc(arr, GCHandleType.Pinned);
    try
    {
      byte* ptr = (byte*)handle.AddrOfPinnedObject();
      for (int i = 0; i < 8; i++) ptr[i] = (byte)i;
      Console.WriteLine(arr[7]);
    }
    finally
    {
      handle.Free();
    }
  }
}

Marshal types and layouts

Interop requires that managed types match native layouts. Use StructLayout, FieldOffset, and MarshalAs to control representation. Prefer SafeHandle for native resources; it wraps a native handle and participates in reliable finalization. Strings require careful treatment because native code may expect ANSI, UTF-16, or UTF-8.

Struct layout and packing

By default, structs use sequential layout that follows field order and natural packing. You can request explicit layout to match bit-level or byte-level contracts. Choose a CharSet when the struct contains characters.

using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct Person
{
  public int Id;
  public double Balance;
  [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
  public string Name;
}

[StructLayout(LayoutKind.Explicit)]
struct Color32
{
  [FieldOffset(0)] public uint Value;
  [FieldOffset(0)] public byte R;
  [FieldOffset(1)] public byte G;
  [FieldOffset(2)] public byte B;
  [FieldOffset(3)] public byte A;
}

Strings, buffers, and UnmanagedType hints

For input strings prefer ReadOnlySpan<byte> or ReadOnlySpan<char> with [LibraryImport] customizations when possible. Otherwise use MarshalAs with the appropriate UnmanagedType. For output buffers use StringBuilder or explicit buffers with length arguments, which aligns with many C APIs.

Native expectation C# parameter Marshal guidance
UTF-16 wchar_t* string / char* CharSet.Unicode or UnmanagedType.LPWStr
ANSI char* string / byte* CharSet.Ansi or UnmanagedType.LPStr
UTF-8 char* byte* / ReadOnlySpan<byte> Pass bytes explicitly or use UTF-8 custom marshalling
Fixed inline string char name[32] string field [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
Out buffer StringBuilder / span Provide capacity and length parameter when required

Handles, ownership, and SafeHandle

Wrapping native handles with SafeHandle prevents leaks and adds reliability. Define a subclass that knows how to release the handle and use it in P/Invoke signatures rather than IntPtr when you own the handle.

using System;
using System.Runtime.InteropServices;

sealed class FileHandle : SafeHandle
{
  public FileHandle() : base(IntPtr.Zero, ownsHandle: true) { }
  public override bool IsInvalid => handle == IntPtr.Zero;

  protected override bool ReleaseHandle()
  {
    return CloseHandle(handle);
  }

  [DllImport("kernel32", SetLastError = true)]
  private static extern bool CloseHandle(IntPtr hObject);
}
πŸ’‘ Prefer SafeHandle in APIs you expose from managed code. It communicates ownership and integrates with the garbage collector and constrained execution regions.

When to consider unsafe constructs

Unsafe code can remove bounds checks and enable pointer arithmetic. Use it when you have a proven hotspot where Span<T>, MemoryMarshal, and vectorized intrinsics are insufficient, or when a native API requires a raw pointer. Keep unsafe regions small, isolate them in well-named helpers, and validate preconditions at boundaries.

⚠️ Unsafe is not inherently faster. The just-in-time compiler can already remove many checks. Measure with a profiler and benchmarks before and after any change, then keep the simpler approach when performance is equivalent.

When security or maintainability is a priority, prefer safe abstractions. Many interop needs are satisfied by Span<T>, ReadOnlySpan<T>, and blittable structs with careful layout. Only choose pointers and pinning when a contract demands a stable address or raw memory view.

Testing and verifying interop

Interop tests should run on each supported platform and architecture. Validate data layout, calling conventions, lifetime rules, and error handling. Assert on Marshal.GetLastWin32Error() when you opt into SetLastError. Use smoke tests that load the library and call simple functions, then add property-based tests for buffer sizes and edge values.

Platform-aware unit tests

Conditionally include tests per platform, and ensure you load the correct library name. Use NativeLibrary.TryLoad for probing when appropriate, then call a simple exported function to validate wiring before running larger suites.

using System;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

public class InteropTests
{
  [SupportedOSPlatform("windows")]
  public void Can_Call_GetTickCount()
  {
#if WINDOWS
    uint ms = Native.GetTickCount();
    if (ms == 0) throw new Exception("Unexpected zero tick count.");
#endif
  }

  public void Can_Probe_Library()
  {
    if (NativeLibrary.TryLoad("libc", out var handle))
    {
      NativeLibrary.Free(handle);
    }
    else
    {
      throw new DllNotFoundException("libc not found … check platform image and rids");
    }
  }
}

Validating layout and marshalling

At runtime you can check sizes and offsets to ensure a managed struct matches the native contract. Combine this with small native fixtures in your repository that report sizeof results for the same structs to catch divergence early.

using System;
using System.Runtime.InteropServices;

static class LayoutChecks
{
  static void Main()
  {
    int managedSize = Marshal.SizeOf<Person>();
    Console.WriteLine($"Person size: {managedSize}");
    // Compare against native sizeof(Person) from a fixture binary …
  }
}
πŸ’‘ Automate interop tests in CI on x64 Windows, Linux, and macOS. Add at least one arm64 run when you ship to Apple Silicon or ARM servers.

Chapter 18: Data Access Essentials

Most real applications persist and retrieve data. In C# you work with data through .NET’s data access APIs; at the lowest level this is ADO.NET, which provides consistent abstractions across providers. This chapter focuses on the essential building blocks you can use in any .NET application, with attention to safety, performance, and clarity.

Connections, commands, and readers

ADO.NET centers on three workhorses. A DbConnection manages a connection to a data source, a DbCommand represents a statement to execute, and a DbDataReader streams rows forward only. Concrete providers such as SqlConnection, NpgsqlConnection, or SqliteConnection implement these abstractions with provider-specific behavior. Connection pooling is handled by .NET, so creating and disposing connections per operation is the normal pattern.

Choosing a provider

Select a provider that matches your database engine. For SQL Server use Microsoft.Data.SqlClient. For PostgreSQL use Npgsql. For SQLite use Microsoft.Data.Sqlite. Each follows the same core patterns, so switching is mainly about connection strings and SQL dialect details.

Connecting and executing a scalar

The following example opens a connection, runs a scalar query, and disposes resources deterministically. The pattern is the same for all providers; only types and connection strings differ.

using Microsoft.Data.SqlClient;

const string cs = "Server=localhost;Database=AppDb;User Id=app;Password=…;TrustServerCertificate=true";
using var conn = new SqlConnection(cs);
await conn.OpenAsync();

using var cmd = new SqlCommand("SELECT COUNT(*) FROM dbo.Users", conn);
var count = (int)await cmd.ExecuteScalarAsync();

Console.WriteLine($"Users: {count}");

Streaming rows with a DbDataReader

A reader streams rows as they arrive and keeps memory use low. Read columns by ordinal for best performance, or by name for clarity.

using var cmd = new SqlCommand("SELECT Id, Email FROM dbo.Users ORDER BY Id", conn);
using var rdr = await cmd.ExecuteReaderAsync();

while (await rdr.ReadAsync())
{
  var id = rdr.GetInt32(0);
  var email = rdr.GetString(1);
  Console.WriteLine($"{id}: {email}");
}
πŸ’‘ Prefer short-lived connections. Open a connection just before executing a command; close or dispose it as soon as you finish. Pooling keeps this efficient.

Parameterization and safety

Never concatenate user input into SQL. Use parameters to avoid injection and to let the provider cache efficient execution plans. Parameters also handle type conversion and nulls correctly.

Using parameters

This example inserts a row using parameters. The provider infers types from values, or you can set types explicitly for full control.

const string sql = "INSERT INTO dbo.Users (Email, DisplayName) VALUES (@email, @name)";
using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@email", emailInput);
cmd.Parameters.AddWithValue("@name", displayNameInput);
var rows = await cmd.ExecuteNonQueryAsync();

Explicit types and null handling

When precision matters, declare parameter types and sizes. Use DBNull.Value for database nulls.

var p = cmd.Parameters.Add("@bio", System.Data.SqlDbType.NVarChar, 4000);
p.Value = string.IsNullOrWhiteSpace(bio) ? DBNull.Value : bio;
⚠️ Avoid string interpolation inside SQL statements that include external input; parameters exist to keep data separate from code.

Transactions and isolation

Transactions group operations so they succeed or fail together. Start a transaction on an open connection, enlist commands in it, and commit or roll back. Isolation levels control visibility of concurrent changes and the types of anomalies that can happen.

Transactional pattern

Use a DbTransaction and ensure it is committed or rolled back even if an exception occurs.

await using var tx = await conn.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);

try
{
  using var debit = new SqlCommand("UPDATE Accounts SET Balance = Balance - @amt WHERE Id = @id", conn, (SqlTransaction)tx);
  debit.Parameters.AddWithValue("@amt", amount);
  debit.Parameters.AddWithValue("@id", fromId);
  await debit.ExecuteNonQueryAsync();

  using var credit = new SqlCommand("UPDATE Accounts SET Balance = Balance + @amt WHERE Id = @id", conn, (SqlTransaction)tx);
  credit.Parameters.AddWithValue("@amt", amount);
  credit.Parameters.AddWithValue("@id", toId);
  await credit.ExecuteNonQueryAsync();

  await tx.CommitAsync();
}
catch
{
  await tx.RollbackAsync();
  throw;
}

Common isolation levels

The table shows typical semantics. Higher isolation reduces anomalies; it can also reduce concurrency.

Level Prevents May Allow
ReadUncommitted Nothing Dirty reads; nonrepeatable reads; phantoms
ReadCommitted Dirty reads Nonrepeatable reads; phantoms
RepeatableRead Dirty reads; nonrepeatable reads Phantoms
Serializable Dirty reads; nonrepeatable reads; phantoms Blocking and deadlocks are more likely
Snapshot Dirty reads; nonrepeatable reads Write conflicts on commit

Basic object mapping

Many applications map rows to objects manually for clarity and control. This keeps dependencies small and performance predictable. You can write small helper methods that translate a reader row into a record or class.

Manual mapping to a record

Read fields by ordinal and construct a record. This keeps mapping code explicit and easy to test.

public readonly record struct User(int Id, string Email, string? DisplayName);

static User MapUser(System.Data.Common.DbDataReader r)
{
  var id = r.GetInt32(0);
  var email = r.GetString(1);
  var name = r.IsDBNull(2) ? null : r.GetString(2);
  return new User(id, email, name);
}

Query method returning a list

Encapsulate data access behind methods that take a connection and optional transaction. This keeps call sites simple and testable.

public static async Task<List<User>> GetUsersAsync(SqlConnection conn, SqlTransaction? tx = null)
{
  const string sql = "SELECT Id, Email, DisplayName FROM dbo.Users ORDER BY Id";
  using var cmd = new SqlCommand(sql, conn, tx);
  using var rdr = await cmd.ExecuteReaderAsync();
  var list = new List<User>();
  while (await rdr.ReadAsync())
    list.Add(MapUser(rdr));
  return list;
}
πŸ’‘ Keep mapping code near the SQL it supports; when the schema changes you will find and update both together.

Async database patterns

Databases are I/O bound; async methods free threads while the server works. Use OpenAsync, ExecuteReaderAsync, ExecuteScalarAsync, and ExecuteNonQueryAsync consistently. Flow a CancellationToken so operations can be abandoned when callers no longer need results.

Async with cancellation

Pass a token through to every async call that accepts one. Ensure you handle OperationCanceledException without treating it as an error in logs.

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await conn.OpenAsync(cts.Token);

using var cmd = new SqlCommand("WAITFOR DELAY '00:00:05'; SELECT 42;", conn);
try
{
  var value = (int)await cmd.ExecuteScalarAsync(cts.Token);
  Console.WriteLine(value);
}
catch (OperationCanceledException)
{
  Console.WriteLine("Query cancelled");
}

Avoiding sync-over-async

Do not call .Result or .Wait() on tasks from data access; doing so can cause thread pool starvation. Keep the entire call chain async.

Resilience and retries

Transient faults happen. Build retry policies for well-known transient errors and add jitter to backoffs to reduce thundering herds. Also consider idempotency; only retry operations that are safe to repeat or that can detect duplicates.

Lightweight retry with a loop

You can implement simple retries without extra libraries. Limit attempts and back off between tries.

static async Task<T> WithRetryAsync<T>(Func<Task<T>> action, int attempts = 3)
{
  var rnd = new Random();
  for (var i = 1; i <= attempts; i++)
  {
    try { return await action(); }
    catch (SqlException ex) when (IsTransient(ex) && i < attempts)
    {
      await Task.Delay(TimeSpan.FromMilliseconds(100 * i + rnd.Next(0, 200)));
    }
  }
  return await action();
}

static bool IsTransient(SqlException ex)
{
  // Check error numbers known to be transient; e.g., 4060, 40197, …
  return ex.Errors.Cast<SqlError>().Any(e => e.Number is 4060 or 40197);
}

Connection resiliency and timeouts

Use command and connection timeouts that reflect normal latencies. For SQL Server, connection resiliency options can reconnect automatically in some scenarios; still design for idempotency where practical.

⚠️ Retries and transactions interact. If a retry repeats a statement that previously committed, outcomes can change. Design the sequence to be idempotent or use business keys that let the database enforce uniqueness.

Chapter 19: Testing and Tooling

Testing guards the edges of your code and keeps regressions away. Tooling sharpens your workflow so errors surface early and fixes arrive quickly. .NET ships with a rich test runner, analyzers, packaging tools, and cross-platform CI options that fit comfortably into any project. This chapter introduces essential practices for writing and maintaining reliable software.

Unit testing fundamentals

Unit tests check small pieces of logic in isolation. The standard way to organise tests in .NET is to create a separate test project using a framework such as xUnit, NUnit, or MSTest. Each framework uses attributes to mark test methods and offers assertions to verify outcomes. Keep tests focused on one behaviour at a time to make failures clear and actionable.

A simple test with xUnit

The following example tests a small calculator helper. The runner discovers test methods marked with [Fact] and reports results.

using Xunit;

public static class MathEx
{
  public static int Square(int x) => x * x;
}

public class MathExTests
{
  [Fact]
  public void Square_of_value_is_correct()
  {
    var result = MathEx.Square(4);
    Assert.Equal(16, result);
  }
}

Asserting exceptions and edge cases

Tests should include inputs that explore edge behaviour. Assert on exceptions when invalid inputs should fail, and check results at boundary conditions.

[Fact]
public void Square_negative_value()
{
  var result = MathEx.Square(-3);
  Assert.Equal(9, result);
}

[Fact]
public void ArgumentNull_throws()
{
  Assert.Throws<ArgumentNullException>(() => SomeApi.DoWork(null!));
}
πŸ’‘ Keep test names descriptive. A good name acts like a short story title that hints at the behaviour you expect.

Mocking and fakes

When logic depends on external services or slow operations, replace collaborators with fakes or mocks. A fake implements the same interface but behaves predictably for tests. A mock is usually created by a library such as Moq or NSubstitute and allows verification of interactions.

Using interfaces for testable design

Inject dependencies through interfaces so you can substitute them. This pattern keeps logic loosely coupled and easy to test.

public interface IClock
{
  DateTime UtcNow { get; }
}

public sealed class SystemClock : IClock
{
  public DateTime UtcNow => DateTime.UtcNow;
}

Mocking interactions with Moq

This example uses Moq to simulate a collaborator. The test verifies both behaviour and method calls.

using Moq;
using Xunit;

public class Greeter
{
  private readonly IClock _clock;
  public Greeter(IClock clock) { _clock = clock; }

  public string Greet() => _clock.UtcNow.Hour < 12 ? "Morning" : "Evening";
}

public class GreeterTests
{
  [Fact]
  public void Greets_based_on_clock_time()
  {
    var mock = new Mock<IClock>();
    mock.Setup(c => c.UtcNow).Returns(new DateTime(2025, 1, 1, 9, 0, 0));

    var g = new Greeter(mock.Object);
    Assert.Equal("Morning", g.Greet());

    mock.Verify(c => c.UtcNow, Times.Once);
  }
}
⚠️ Avoid over-mocking. When you recreate too much behaviour you risk making tests reflect your mocks instead of real logic.

Data-driven tests

Data-driven tests run the same logic against multiple inputs. Frameworks offer attributes that feed parameter sets into a single test method. This keeps duplicate tests small and tidy.

Theory tests with xUnit

Use [Theory] along with [InlineData] for simple sets. Larger sets can come from custom providers.

public class ParserTests
{
  [Theory]
  [InlineData("42", 42)]
  [InlineData("7", 7)]
  [InlineData("0", 0)]
  public void Parses_integers_correctly(string input, int expected)
  {
    var result = int.Parse(input);
    Assert.Equal(expected, result);
  }
}

External data sources

For extensive test cases you can load data from files or generate sequences using MemberData or ClassData. This supports broad coverage without cluttering individual methods.

public class RangeData : IEnumerable<object[]>
{
  public IEnumerator<object[]> GetEnumerator()
  {
    for (int i = -5; i <= 5; i++)
      yield return new object[] { i, i * i };
  }
  IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public class RangeTests
{
  [Theory]
  [ClassData(typeof(RangeData))]
  public void Squares_values(int input, int expected)
  {
    Assert.Equal(expected, input * input);
  }
}
πŸ’‘ Data-driven tests shine when functions are pure and predictable. Use them for parsing, arithmetic, validation rules, and any behaviour that maps inputs to outputs.

Analyzers and code fixes

Code analyzers inspect your source at compile time and highlight issues or improvement opportunities. Roslyn-based analyzers run inside the compiler pipeline and offer diagnostics and automatic fixes. Many .NET SDKs include built-in analyzers for style, performance, and safety.

Using built-in analyzers

Enable recommended rules by adding a configuration file named .editorconfig to your project and selecting rule sets. You can set severity levels such as suggestion, warning, or error.

[*.cs]
dotnet_analyzer_diagnostic.severity = warning
dotnet_style_qualification_for_field = true:suggestion
dotnet_code_quality_unused_parameters = true:warning

Custom analyzers and fixes

You can write your own analyzer that flags patterns in code. Pair it with a code fix provider that rewrites syntax automatically. This is useful when you enforce project-wide conventions or detect subtle mistakes early.

// Analyzer skeleton … full implementation would inspect syntax nodes
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class NoMagicNumbersAnalyzer : DiagnosticAnalyzer
{
  public override void Initialize(AnalysisContext context)
  {
    context.EnableConcurrentExecution();
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.NumericLiteralExpression);
  }

  private static void Analyze(SyntaxNodeAnalysisContext context)
  {
    // Check literal; report diagnostic when needed …
  }

  public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray<DiagnosticDescriptor>.Empty;
}
⚠️ Analyzer performance matters. Poorly written analyzers can slow the editor or reduce build throughput.

Packaging with NuGet

NuGet packages share code across projects or with other developers. You can pack a library into a .nupkg file and publish it to nuget.org or a private feed. Your project file controls metadata, assembly inclusion, and dependency versions. Semantic versioning helps consumers understand compatibility.

Packing a project

The SDK can generate packages directly. Include metadata such as PackageId, Version, Authors, and Description.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <PackageId>MyLib</PackageId>
    <Version>1.0.0</Version>
    <Authors>Robin</Authors>
    <Description>Utility helpers for …</Description>
  </PropertyGroup>
</Project>

Publishing and dependency management

Use dotnet nuget push to publish packages. When consuming packages specify floating versions carefully; stable ranges offer predictability, while wildcards allow faster updates. Private feeds can live inside Azure DevOps, GitHub Packages, or file shares.

πŸ’‘ Include a README file and source link configuration in your package. This gives consumers guidance and allows stepping into your source during debugging.

Continuous integration basics

Continuous integration keeps builds, tests, and analysis running automatically. A CI pipeline compiles your code, runs tests on each commit, checks analyzers, and can publish artifacts. Hosted runners such as GitHub Actions and Azure Pipelines run on Windows, Linux, and macOS.

Simple GitHub Actions workflow

The example workflow restores packages, builds the project, and runs tests across platforms. It keeps the build green and alerts you when new changes break behaviour.

name: build-and-test

on: [push, pull_request]

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with:
        dotnet-version: 8.0.x
    - run: dotnet restore
    - run: dotnet build --configuration Release --no-restore
    - run: dotnet test --configuration Release --no-build

Caching and artifacts

You can cache NuGet packages and store artifacts from each run. This speeds up CI and keeps evidence of test results. Keep caches focused on stable assets so they remain effective and do not accumulate stale content.

⚠️ Even in CI, keep an eye on secrets. Use secure storage for connection strings and keys. Never commit sensitive values into repositories.

Chapter 20: Putting It All Together

This final chapter gathers the ideas from earlier chapters into a small working application. You will design a console tool, parse and validate input, compose types and modules into a tidy structure, apply async APIs and LINQ queries, integrate logging and configuration, and package your work so it can grow gracefully over time. The goal is to show how all these pieces settle into a smooth flow when building something practical.

Designing a small console app

A console app gives you a simple canvas for experimentation. You can read arguments, run commands, and print results. Modern .NET templates already include an async entry point and a minimal program style, so you can focus on logic rather than ceremony. Keep the heart of your application small and delegate work to dedicated classes that handle parsing, data access, formatting, or long-running operations.

A simple project layout

The following layout is enough for a small tool. Program.cs hosts the entry point. A Services folder contains helpers. A Domain folder stores core types. This encourages a calm separation between the public shell and the work underneath.

MyTool
β”œβ”€β”€ Program.cs
β”œβ”€β”€ Domain
β”‚   └── User.cs
└── Services
    β”œβ”€β”€ UserStore.cs
    └── Formatter.cs
πŸ’‘ Keep Program.cs thin. A thin shell is easier to test and easier to keep tidy as your project matures.

Parsing input and validation

Parsing begins with command line arguments. You might need to accept flags, numeric values, or file paths. Validation ensures your application never works with broken assumptions. Check for missing values, malformed numbers, or invalid ranges at the borders of your program so the rest of the code stays clean and predictable.

Basic argument handling

The example reads an action keyword and an optional user id. After validation it chooses a path forward. It is a small pattern, but it scales well for larger tools.

static async Task Main(string[] args)
{
  if (args.Length == 0)
  {
    Console.WriteLine("Commands: list, find <id>");
    return;
  }

  var cmd = args[0].ToLowerInvariant();
  switch (cmd)
  {
    case "list":
      await RunListAsync();
      break;

    case "find":
      if (args.Length < 2 || !int.TryParse(args[1], out var id))
      {
        Console.WriteLine("Missing or invalid id");
        return;
      }
      await RunFindAsync(id);
      break;

    default:
      Console.WriteLine($"Unknown command {cmd}");
      break;
  }
}

Validation helpers

Small helpers reduce repetition and give you named points to refine behaviour over time. They can enforce formats, ranges, or even domain rules.

static bool ValidateId(int id)
{
  return id > 0;
}

Composing types and modules

After parsing you need modules to carry the load. This is where domain types, services, and utilities come together. Compose your application from small, well-defined pieces such as repositories, parsers, or formatters. You can wire them manually or use a dependency injection container. Manual wiring is fine for tiny tools and keeps the structure visible.

A small domain type

Domain types store facts. A simple User record captures just what the example needs.

public readonly record struct User(int Id, string Email);

A service with async operations

This service simulates loading users. In a real app the service might use a database, a web API, or a file. The interface gives you a seam for testing.

public interface IUserStore
{
  Task<IReadOnlyList<User>> ListAsync();
  Task<User?> FindAsync(int id);
}

public sealed class UserStore : IUserStore
{
  private static readonly User[] _seed =
  {
    new User(1, "alba@example.com"),
    new User(2, "terra@example.com")
  };

  public Task<IReadOnlyList<User>> ListAsync()
    => Task.FromResult<IReadOnlyList<User>>(_seed);

  public Task<User?> FindAsync(int id)
  {
    var user = _seed.FirstOrDefault(u => u.Id == id);
    return Task.FromResult<User?>(user);
  }
}
πŸ’‘ Compose by abstraction rather than inheritance. Interfaces and records make boundaries crisp and test-friendly.

Applying async and LINQ

Async operations keep your tool responsive, even when tasks involve network I/O or database operations. LINQ helps you filter, shape, and project data into whatever form your application needs. These two ideas work well together and keep code readable.

Listing items with transforms

The following command lists users, orders them, and selects a shaped object for display. Notice how the LINQ query expresses the shape of the output without extra scaffolding.

async Task RunListAsync()
{
  var store = new UserStore();
  var users = await store.ListAsync();

  var results =
    from u in users
    orderby u.Id
    select $"{u.Id}: {u.Email}";

  foreach (var line in results)
    Console.WriteLine(line);
}

Async lookup paths

Async code works smoothly with conditional logic. You can await operations inside branches without deadlocks as long as you keep the chain async from entry to exit.

async Task RunFindAsync(int id)
{
  var store = new UserStore();
  var user = await store.FindAsync(id);
  Console.WriteLine(user is null ? "Not found" : user.ToString());
}

Logging, configuration, and errors

Even tiny applications benefit from simple logs and external settings. Configuration files allow you to change runtime behaviour without recompiling. Structured logs add clarity when diagnosing unexpected behaviour. Handle errors with care so failures become understandable and repeatable.

Using built-in logging

.NET provides a lightweight logging abstraction in Microsoft.Extensions.Logging. You can add console logging with a few lines and pass the logger to your services.

using Microsoft.Extensions.Logging;

var loggerFactory = LoggerFactory.Create(builder =>
{
  builder.AddSimpleConsole();
});

var log = loggerFactory.CreateLogger("MyTool");
log.LogInformation("Tool starting at {time}", DateTimeOffset.Now);

Configuration with JSON

You can load JSON settings through ConfigurationBuilder. This lets you store connection strings, feature flags, or user preferences outside the codebase.

using Microsoft.Extensions.Configuration;

var config = new ConfigurationBuilder()
  .AddJsonFile("appsettings.json", optional: true)
  .Build();

var mode = config["Mode"] ?? "default";
Console.WriteLine($"Running in {mode} mode");
⚠️ Handle errors at the edges. Catch exceptions where they make sense and log them clearly. When a failure blocks normal operation return a sensible message rather than an unfiltered stack trace.

Packaging, versioning, and next steps

Once your tool works you can publish it as a self-contained executable or pack it as a NuGet tool. Versioning helps consumers understand changes, while documentation keeps your project approachable. By following semantic versioning you communicate compatibility through the version number itself.

Creating a self-contained build

You can publish a self-contained application for Windows, Linux, or macOS. This distributes the .NET runtime together with your app.

dotnet publish -c Release -r win-x64 --self-contained true

Versioning your releases

Keep version numbers predictable. Increment patch versions for small fixes, minor versions for new capabilities, and major versions for changes that break existing behaviour. Automate this whenever you integrate CI pipelines and tag releases in version control.

πŸ’‘ Consider writing a README and a small changelog. Clear documentation is one of the most generous tools you can offer future maintainers or your future self.

You now have the ingredients for building expressive, modern C# and .NET applications that scale from tiny utilities to production systems. The rest is practice, curiosity, and steady improvement.



© 2025 Robin Nixon. All rights reserved

No content may be re-used, sold, given away, or used for training AI without express permission

Questions? Feedback? Get in touch