C# Cheat Sheet

Twenty-four sections, from Hello World to LINQ, records, pattern matching and async. Search it, filter it by level, copy any line with one click. Covers C# 8 through C# 13 with version badges on every modern feature.

24sections
353snippets
C# 13up to date
0signup needed
/
level

0 results

Nothing matches that query. Try a shorter keyword such as list, await or ef core. Several words are combined, so every one of them has to match.

01

Getting Started

program shape, console, comments

Hello World

C# 9+core

Top level statements. No class, no Main, one file.

Console.WriteLine("Hello, World!");

The classic full form, still valid everywhere

namespace HelloApp;

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

Async entry point, returns an exit code

static async Task<int> Main(string[] args)
{
    await Task.Delay(100);
    return 0;
}

Console Input and Output

core

Write with and without a line break

Console.Write("no newline ");
Console.WriteLine("with newline");

Read a line, then parse it safely

Console.Write("Age: ");
string? input = Console.ReadLine();
if (int.TryParse(input, out int age))
    Console.WriteLine($"Next year: {age + 1}");

Read a single key without waiting for Enter

ConsoleKeyInfo key = Console.ReadKey(intercept: true);
Console.WriteLine($"You pressed {key.Key}");

Command line arguments

// top level statements expose args automatically
foreach (string arg in args)
    Console.WriteLine(arg);

Comments and Regions

core

Single line, block and XML documentation

// single line comment

/* block comment
   across lines */

/// <summary>Adds two numbers.</summary>
/// <param name="a">First value.</param>
/// <returns>The sum.</returns>
int Add(int a, int b) => a + b;

Collapsible region, useful in generated files

#region Helpers
// folded in the IDE
#endregion

Where C# Runs

core

One language, and the template that starts each kind of project

BuildStackStart with
Console tool.NETdotnet new console
Web APIASP.NET Coredotnet new webapi
Server rendered siteASP.NET Core MVCdotnet new mvc
Web UI in C#Blazordotnet new blazor
Cross platform app.NET MAUIdotnet new maui
Windows desktopWPF or WinFormsdotnet new wpf
GamesUnity or Godotthe Unity Hub, C# is the scripting language
Background serviceGeneric Hostdotnet new worker
Reusable library.NETdotnet new classlib
TestsxUnitdotnet new xunit

The runtime is the same everywhere

// this compiles and runs unchanged in a console app,
// a web API, a MAUI app and a Unity script
var recent = orders
    .Where(o => o.PlacedAt > cutoff)
    .OrderByDescending(o => o.Total)
    .Take(10)
    .ToList();

Namespaces and Usings

C# 10+core

File scoped namespace, one less level of indentation

namespace MyApp.Services;

public class OrderService { }

Using directives, aliases and static imports

using System.Text;
using Json = System.Text.Json.JsonSerializer;
using static System.Math;

double r = Sqrt(16);          // no Math. prefix
string s = Json.Serialize(r);

Global using, applies to every file in the project

// GlobalUsings.cs
global using System.Collections.Generic;
global using System.Linq;

Alias any type, including generics and arrays

using Matrix = double[,];
using Pair = (int Id, string Name);   // C# 12
02

Types & Variables

primitives, nullability, conversion

Declaring Variables

core

Explicit type versus inferred type

int count = 42;
string name = "Ana";
var total = 19.99m;        // inferred as decimal
var items = new List<int>();

Constants and read only fields

const double Pi = 3.14159;        // compile time
static readonly DateTime Start = DateTime.Now;  // run time, set once

Default values and discards

int zero = default;         // 0
string? nothing = default;  // null
_ = ComputeAndIgnore();     // discard the result

Built in Value Types

core

Size, range and the alias to .NET type mapping

Keyword.NET typeSizeRange or precision
byteSystem.Byte8 bit0 to 255
sbyteSystem.SByte8 bit-128 to 127
shortSystem.Int1616 bit-32,768 to 32,767
ushortSystem.UInt1616 bit0 to 65,535
intSystem.Int3232 bit-2.1B to 2.1B
uintSystem.UInt3232 bit0 to 4.2B
longSystem.Int6464 bit-9.2E18 to 9.2E18
ulongSystem.UInt6464 bit0 to 1.8E19
floatSystem.Single32 bitabout 7 digits, suffix f
doubleSystem.Double64 bitabout 15 digits, default
decimalSystem.Decimal128 bit28 digits, money, suffix m
boolSystem.Boolean8 bittrue or false
charSystem.Char16 bita single UTF-16 unit
nint / nuintSystem.IntPtrplatformnative sized integer

Literal suffixes and digit separators

long big      = 9_000_000_000L;
float f       = 1.5f;
decimal money = 19.99m;
uint u        = 42U;
int hex       = 0xFF;         // 255
int binary    = 0b1010_1010;  // 170

Min and max values

Console.WriteLine(int.MaxValue);      // 2147483647
Console.WriteLine(double.Epsilon);
Console.WriteLine(decimal.MinValue);

Nullability

C# 8+core

Nullable value type, a struct that can hold null

int? maybe = null;
if (maybe.HasValue)
    Console.WriteLine(maybe.Value);

int safe = maybe ?? 0;               // fallback
int forced = maybe!.Value;           // you promise it is not null

Nullable reference types, a compiler contract not a runtime one

#nullable enable
string notNull = "always set";
string? mayBeNull = null;

int len = mayBeNull?.Length ?? 0;    // no exception

Enable it project wide in the csproj

<Nullable>enable</Nullable>

Null guards, one line each

ArgumentNullException.ThrowIfNull(order);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ObjectDisposedException.ThrowIf(_disposed, this);

Casting and Conversion

everyday

Implicit widening versus explicit narrowing

int i = 100;
long l = i;              // implicit, no data loss
double d = 9.87;
int truncated = (int)d;  // explicit, becomes 9

Parsing strings the safe way

int n = int.Parse("42");                 // throws on bad input
bool ok = int.TryParse("7", out int v);  // never throws
Console.WriteLine(n);
Console.WriteLine($"{ok} {v}");

Convert class handles null and rounding differently

int a = Convert.ToInt32("42");
int b = Convert.ToInt32(9.87);   // 10, rounds instead of truncating
int c = Convert.ToInt32(null);   // 0, no exception

Reference conversion with is and as

object box = "text";

if (box is string s1) Console.WriteLine(s1.Length);
string? s2 = box as string;      // null instead of throwing
string s3 = (string)box;         // InvalidCastException if wrong

Boxing and unboxing, the hidden allocation

int value = 5;
object boxed = value;        // heap allocation
int unboxed = (int)boxed;    // cast back

Culture aware parsing, essential for decimals

using System.Globalization;

decimal price = decimal.Parse("1,234.56", CultureInfo.InvariantCulture);
DateTime when = DateTime.ParseExact("2026-09-09", "yyyy-MM-dd",
    CultureInfo.InvariantCulture);

Enums

everyday

Named constants with an underlying integer

enum Status { Draft, Paid, Shipped, Closed }

Status s = Status.Shipped;
Console.WriteLine(s);
Console.WriteLine((int)s);

Explicit values and a different backing type

enum Http : ushort
{
    Ok = 200,
    NotFound = 404,
    ServerError = 500
}

Flags enum for bitwise combinations

[Flags]
enum Access { None = 0, Read = 1, Write = 2, Delete = 4 }

var perms = Access.Read | Access.Write;
bool canWrite = perms.HasFlag(Access.Write);   // true

Parsing and enumerating

Status parsed = Enum.Parse<Status>("Paid");
bool ok = Enum.TryParse("Draft", out Status d);
foreach (Status v in Enum.GetValues<Status>())
    Console.WriteLine(v);

Tuples

C# 7+everyday

Named tuple, a lightweight multi value

var person = (Name: "Ana", Age: 30);
Console.WriteLine($"{person.Name} is {person.Age}");

Return several values without a class

(int min, int max) Range(int[] xs) => (xs.Min(), xs.Max());

var (lo, hi) = Range(new[] { 4, 9, 1 });

Swap and compare in one expression

(a, b) = (b, a);
bool same = (1, "x") == (1, "x");   // true, element wise
03

Operators

arithmetic, null handling, ranges

Arithmetic and Assignment

core

Integer division truncates, watch the types

Console.WriteLine(7 / 2);         // 3
Console.WriteLine(7 % 2);         // 1
Console.WriteLine(7 / 2.0);       // 1.5

Compound assignment and increment

x += 5;  x -= 5;  x *= 2;  x /= 2;  x %= 3;
x++;     // post increment, returns then adds
++x;     // pre increment, adds then returns

Overflow checking is off by default

unchecked { int wrap = int.MaxValue + 1; }   // wraps to min
checked   { int boom = int.MaxValue + 1; }   // OverflowException

Comparison and Logical

core

Comparison operators return bool

a == b   a != b   a < b   a > b   a <= b   a >= b

Short circuiting versus always evaluating

if (user != null && user.IsActive) { }   // right side skipped if null
bool both = Check(a) & Check(b);         // both always run

Reference equality versus value equality

object x = new string("ab".ToCharArray());
object y = "ab";
Console.WriteLine(x.Equals(y));                  // True
Console.WriteLine(ReferenceEquals(x, y));        // False

Null Operators

C# 8+core

Null coalescing and null coalescing assignment

string display = name ?? "anonymous";
cache ??= LoadExpensive();          // assign only when null

Null conditional member, index and invoke

int? len = customer?.Address?.Street?.Length;
string? first = list?[0];
handler?.Invoke(this, EventArgs.Empty);

Null forgiving operator, silences the warning only

string definitely = maybeNull!;   // no runtime check at all

Conditional and Range

C# 8+everyday

Ternary conditional

string label = count == 1 ? "item" : "items";

Index from end and range slicing

var letters = new[] { 'a', 'b', 'c', 'd', 'e' };
Console.WriteLine(letters[^1]);              // last item
Console.WriteLine(new string(letters[1..4])); // bcd
Console.WriteLine(new string(letters[^3..])); // cde

Ranges are first class values

Range middle = 1..^1;
Index last = ^1;
var slice = numbers[middle];

Bitwise and Shift

advanced

The full set, on integers

Console.WriteLine(12 & 10);    // AND
Console.WriteLine(12 | 10);    // OR
Console.WriteLine(12 ^ 10);    // XOR
Console.WriteLine(~12);        // NOT
Console.WriteLine(12 << 1);    // left shift
Console.WriteLine(12 >> 2);    // right shift

Unsigned right shift, keeps the sign bit out

int r = -8 >>> 1;   // C# 11

Type and Meta Operators

everyday

typeof, nameof and sizeof

int count = 0;
Console.WriteLine(typeof(int));
Console.WriteLine(nameof(count));
Console.WriteLine(sizeof(int));

Get the runtime type of an instance

object o = "text";
Console.WriteLine(o.GetType().Name);   // String

nameof survives renames, string literals do not

throw new ArgumentNullException(nameof(order));
04

Strings & Text

interpolation, formatting, building

String Literals

C# 11+core

Interpolated string, the default choice

string name = "Ana";
int age = 30;
Console.WriteLine($"{name} is {age} years old");

Verbatim string, backslashes stay literal

string path = @"C:\Users\Ana\Documents";
string quoted = @"She said ""hi""";

Raw string literal, no escaping at all

string json = """
    {
      "name": "Ana",
      "path": "C:\Users\Ana"
    }
    """;

Raw plus interpolation, extra dollar signs raise the brace count

string body = $$"""
    { "user": "{{name}}", "css": "{ margin: 0 }" }
    """;

Escape sequences

"\n"  newline      "\t"  tab
"\\"  backslash    "\""  double quote
"\u00e9"  unicode  "\0"  null character

Common String Methods

core

Inspecting

s.Length              s.Contains("ab")
s.StartsWith("A")     s.EndsWith(".cs")
s.IndexOf('x')        s.LastIndexOf('x')
string.IsNullOrEmpty(s)
string.IsNullOrWhiteSpace(s)

Transforming, strings are immutable so each call returns a new one

s.ToUpper()           s.ToLower()
s.Trim()              s.TrimStart()   s.TrimEnd()
s.Replace("a", "b")   s.Insert(0, "x")
s.Substring(2, 5)     s.PadLeft(8, '0')

Split and join

string[] parts = "a,b,c".Split(',');
Console.WriteLine(string.Join("|", parts));

Split with options, drops empty entries and trims

var tags = " a , , b ".Split(',',
    StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// ["a", "b"]

Comparison that ignores case and culture

bool same = string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
int order = string.Compare(a, b, StringComparison.Ordinal);

Formatting

everyday

Numeric format strings

double v = 1234.5678;
Console.WriteLine(v.ToString("N2"));    // thousands, 2 decimals
Console.WriteLine(v.ToString("C2"));    // currency
Console.WriteLine(0.456.ToString("P2")); // percent

Alignment and format inside interpolation

Console.WriteLine($"{name,-10}|{price,8:F2}");
// left aligned in 10 cols, right aligned in 8 cols

Date and time patterns

DateTime now = DateTime.Now;
now.ToString("yyyy-MM-dd")        // 2026-09-09
now.ToString("HH:mm:ss")          // 14:05:09
now.ToString("dddd, dd MMMM yyyy")
now.ToString("O")                 // round trip ISO 8601

Common numeric specifiers

CodeMeaning1234.5678 becomes
C2currency$1,234.57
N0number, no decimals1,235
F3fixed point1234.568
E2scientific1.23E+003
Xhexadecimal (integers)4D2 for 1234
D5padded integer01234

StringBuilder and Spans

everyday

Build long strings in a loop without allocating each time

using System.Text;

var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
    sb.AppendLine($"row {i}");

string result = sb.ToString();

Slice without copying, ideal in hot paths

ReadOnlySpan<char> span = "2026-09-09".AsSpan();
ReadOnlySpan<char> year = span[..4];
int y = int.Parse(year);

Create a string in place, no intermediate buffers

string masked = string.Create(11, card, (dst, src) =>
{
    src.AsSpan(0, 4).CopyTo(dst);
    "-****-****".AsSpan().CopyTo(dst[4..]);
});

Regular Expressions

C# 11+advanced

Match and capture

using System.Text.RegularExpressions;

var rx = new Regex(@"^(\d{4})-(\d{2})-(\d{2})$");
Match m = rx.Match("2026-09-09");
Console.WriteLine(m.Success);
Console.WriteLine(m.Groups[1].Value);

Source generated regex, compiled at build time

public partial class Validators
{
    [GeneratedRegex(@"^[\w.-]+@[\w.-]+\.\w{2,}$", RegexOptions.IgnoreCase)]
    public static partial Regex Email();
}

bool ok = Validators.Email().IsMatch(input);

Replace with a callback

string masked = Regex.Replace(text, @"\d",
    match => "*");
05

Control Flow

branches, loops, jumps

Conditionals

core

if, else if, else

if (score >= 90)
    grade = "A";
else if (score >= 80)
    grade = "B";
else
    grade = "C";

Guard clause style, fewer nested blocks

if (order is null) return;
if (!order.IsValid) return;

Process(order);

Switch

C# 8+core

Classic switch statement, break is required

switch (day)
{
    case "Sat":
    case "Sun":
        Console.WriteLine("Weekend");
        break;
    default:
        Console.WriteLine("Weekday");
        break;
}

Switch expression, returns a value and must be exhaustive

string day = "Sat";
string kind = day switch
{
    "Sat" or "Sun" => "Weekend",
    _              => "Weekday"
};
Console.WriteLine(kind);

Guards with when

string band = temp switch
{
    < 0            => "freezing",
    >= 0 and < 15  => "cold",
    >= 15 and < 25 => "mild",
    _              => "hot"
};

Loops

core

for, when you need the index

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

foreach, when you only need the item

foreach (var item in collection)
    Console.WriteLine(item);

// with index, C# 8+
foreach (var (item, i) in collection.Select((x, i) => (x, i)))
    Console.WriteLine($"{i}: {item}");

while and do while

while (queue.Count > 0)
    Handle(queue.Dequeue());

do
{
    input = Console.ReadLine();
} while (input != "quit");

Nested loop control

foreach (var row in grid)
{
    foreach (var cell in row)
    {
        if (cell is null) continue;   // next cell
        if (cell.IsStop) goto done;   // break out of both
    }
}
done:
Console.WriteLine("finished");

Infinite loop with an explicit exit

while (true)
{
    var msg = Receive();
    if (msg is null) break;
    Handle(msg);
}

Iterators with yield

everyday

Lazy sequence, values produced on demand

IEnumerable<int> Odds(int max)
{
    for (int i = 1; i <= max; i += 2)
        yield return i;
}

Console.Write(string.Join(" ", Odds(10)));

Stop the sequence early

IEnumerable<string> ReadUntilBlank(TextReader r)
{
    while (true)
    {
        string? line = r.ReadLine();
        if (string.IsNullOrWhiteSpace(line)) yield break;
        yield return line;
    }
}
06

Pattern Matching

type, property, list and logical patterns

Type and Constant Patterns

C# 7+everyday

Test and bind in one step

if (shape is Circle c)
    Console.WriteLine(c.Radius);

Null checks read better as patterns

if (value is null) { }
if (value is not null) { }
if (value is { }) { }        // not null, any shape

Switch over types

double Area(object shape) => shape switch
{
    Circle c    => Math.PI * c.R * c.R,
    Rect r      => r.W * r.H,
    null        => 0,
    _           => throw new ArgumentException("unknown shape")
};

Relational and Logical Patterns

C# 9+everyday

and, or, not combine any two patterns

bool isDigit = ch is >= '0' and <= '9';
bool isVowel = ch is 'a' or 'e' or 'i' or 'o' or 'u';
bool notNull = obj is not null;

Ranges inside a switch expression

int age = 15;
string stage = age switch
{
    < 13          => "child",
    >= 13 and < 20 => "teen",
    _             => "adult"
};
Console.WriteLine(stage);

Property Patterns

C# 8+advanced

Match on the shape of an object

if (order is { Status: "paid", Total: > 100 })
    ApplyDiscount(order);

Nested and extended property paths, C# 10

bool local = person is { Address.Country: "RO", Age: >= 18 };

Bind while matching

string Describe(Order o) => o switch
{
    { Items.Count: 0 }                    => "empty",
    { Total: var t } when t > 1000        => $"large order {t:C}",
    { Customer.IsVip: true }              => "vip order",
    _                                     => "standard"
};

List and Positional Patterns

C# 11+advanced

Match the shape of a sequence, slice with two dots

int[] xs = { 1, 5, 7, 9 };
string s = xs switch
{
    []                    => "empty",
    [var only]            => $"single {only}",
    [1, .., var last]     => $"starts with 1, ends with {last}",
    _                     => "other"
};
Console.WriteLine(s);

Positional pattern on records and tuples

record Point(int X, int Y);

string Where(Point p) => p switch
{
    (0, 0)              => "origin",
    (var x, 0)          => $"on X at {x}",
    (0, var y)          => $"on Y at {y}",
    _                   => "somewhere"
};

Tuple pattern across two inputs

string Rps(string a, string b) => (a, b) switch
{
    ("rock", "scissors") or ("paper", "rock") => "first wins",
    (var x, var y) when x == y                => "draw",
    _                                          => "second wins"
};
07

Methods

parameters, returns, extensions

Declaring Methods

core

Standard body and expression body

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

public int Multiply(int a, int b) => a * b;
public void Log(string m) => Console.WriteLine(m);

Access modifiers

ModifierVisible from
publicanywhere
privatethe same type only (default for members)
protectedthe same type and derived types
internalthe same assembly (default for types)
protected internalsame assembly or derived types
private protectedderived types in the same assembly

Overloading, same name, different signature

void Print(int n) { }
void Print(string s) { }
void Print(int n, string unit) { }

Parameters

everyday

Optional and named arguments

void Send(string to, string subject = "(none)", bool urgent = false) { }

Send("a@b.com");
Send("a@b.com", urgent: true);   // skip the middle one

params takes any number of arguments

int Sum(params int[] numbers) => numbers.Sum();

Sum(1, 2, 3);
Sum();               // valid, empty array

ref passes a reference, in and out narrow the intent

void Double(ref int x) => x *= 2;
bool TryGet(string key, out string value) { value = "hit"; return true; }
double Distance(in Point a, in Point b) => 0;   // read only, no copy

int n = 5;
Double(ref n);
Console.WriteLine(n);

params over any collection type, C# 13

void Log(params ReadOnlySpan<string> lines) { }

Local Functions

C# 7+everyday

A helper scoped to one method, can capture locals

int Factorial(int n)
{
    if (n < 0) throw new ArgumentOutOfRangeException(nameof(n));
    return Compute(n);

    static int Compute(int x) => x <= 1 ? 1 : x * Compute(x - 1);
}

static prevents accidental captures and allocations

static bool IsEven(int x) => x % 2 == 0;

Extension Methods

everyday

Add methods to a type you do not own

public static class StringExtensions
{
    public static bool IsPalindrome(this string s)
    {
        var clean = s.ToLower().Replace(" ", "");
        return clean.SequenceEqual(clean.Reverse());
    }
}

Console.WriteLine("never odd or even".IsPalindrome());

Rules: static class, static method, this on the first parameter

public static IEnumerable<T> WhereNotNull<T>(
    this IEnumerable<T?> source) where T : class
    => source.Where(x => x is not null)!;
08

Classes & Objects

fields, properties, constructors

A Class From Top to Bottom

core

Fields, properties, constructor, method

public class BankAccount
{
    private decimal _balance;                  // field
    public string Owner { get; }               // read only property
    public decimal Balance => _balance;        // computed property

    public BankAccount(string owner, decimal opening)
    {
        Owner = owner;
        _balance = opening;
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("must be positive");
        _balance += amount;
    }
}

Creating and using instances

var acc = new BankAccount("Ana", 100m);
acc.Deposit(50m);
Console.WriteLine(acc.Balance);   // 150

Properties

C# 11+core

Auto property, computed property and full property

public string Name { get; set; } = "";
public string Upper => Name.ToUpper();

private int _age;
public int Age
{
    get => _age;
    set => _age = value >= 0 ? value : throw new ArgumentException();
}

Access levels, init only and required

public string Id { get; private set; }     // settable inside the class
public DateTime Created { get; init; }     // only during construction
public required string Email { get; init; } // caller must supply it

Object and collection initializers

var user = new User
{
    Name = "Ana",
    Email = "ana@example.com",
    Tags = { "admin", "beta" }
};

The field keyword removes the backing field boilerplate, C# 13 preview

public string Slug
{
    get => field;
    set => field = value.Trim().ToLower();
}

Constructors

C# 12+everyday

Chaining with this and calling a base constructor

public Order() : this(Guid.NewGuid()) { }
public Order(Guid id) { Id = id; }

public PaidOrder(Guid id) : base(id) { }

Primary constructor on a class, parameters are in scope everywhere

public class Greeter(string name, ILogger logger)
{
    public string Hello() => $"Hi {name}";
    public void Warn() => logger.LogWarning("from {Name}", name);
}

Static constructor runs once, before first use

static Config()
{
    Defaults = Load();
}

Static, Indexers, Deconstruction

everyday

Static members belong to the type, not an instance

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

MathHelpers.Square(4);

Indexer, make your type usable with square brackets

public class Grid
{
    private readonly int[,] _cells = new int[8, 8];
    public int this[int row, int col]
    {
        get => _cells[row, col];
        set => _cells[row, col] = value;
    }
}

var g = new Grid();
g[0, 0] = 1;

Deconstruct, so your type supports tuple assignment

public void Deconstruct(out string name, out int age)
{
    name = Name;
    age = Age;
}

var (n, a) = person;

Partial class, split one type across files

// Order.cs
public partial class Order { public Guid Id { get; set; } }
// Order.Validation.cs
public partial class Order { public bool IsValid() => Id != Guid.Empty; }
09

Records & Structs

value semantics and immutability

Records

C# 9+everyday

One line gives you equality, ToString and deconstruction

public record Person(string FirstName, string LastName);

var a = new Person("Ana", "Pop");
var b = new Person("Ana", "Pop");
Console.WriteLine(a == b);
Console.WriteLine(a);

with expression makes a modified copy

var married = a with { LastName = "Ionescu" };
// a is untouched

Add members like any class

public record Order(Guid Id, decimal Total)
{
    public bool IsLarge => Total > 1000m;
    public static Order Empty { get; } = new(Guid.Empty, 0m);
}

Record struct, value type with value equality

public readonly record struct Money(decimal Amount, string Currency);

Records inherit, and equality includes the runtime type

public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);

Structs

advanced

A value type, copied on assignment

public readonly struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) => (X, Y) = (x, y);
    public override string ToString() => $"({X}, {Y})";
}

ref struct lives on the stack only, Span is one

public ref struct Buffer
{
    private Span<byte> _data;   // cannot be boxed or captured
}

When to reach for a struct

// small (roughly 16 bytes or less)
// immutable
// short lived and allocated in large numbers
// otherwise use a class or a record

Class vs Struct vs Record

everyday

Pick by semantics, not by performance folklore

Traitclassstructrecordrecord struct
Categoryreferencevaluereferencevalue
Equalityreferencefield wisevalue basedvalue based
Can be nullyesonly with ?yesonly with ?
Inheritanceyesnoyesno
with expressionnonoyesyes
Generated ToStringnonoyesyes
Typical useservices, entitiescoordinates, moneyDTOs, messagessmall values
10

Inheritance & Interfaces

polymorphism and contracts

Inheritance

core

virtual in the base, override in the child

public class Animal
{
    public virtual string Speak() => "...";
}

public class Dog : Animal
{
    public override string Speak() => "Woof";
}

Animal a = new Dog();
Console.WriteLine(a.Speak());

Call the base implementation

public override string Speak() => base.Speak() + " Woof";

abstract cannot be instantiated, sealed cannot be extended

public abstract class Shape
{
    public abstract double Area();          // no body, must be overridden
    public virtual string Describe() => $"area {Area():F2}";
}

public sealed class Circle : Shape
{
    public double R { get; init; }
    public override double Area() => Math.PI * R * R;
}

new hides instead of overriding, usually a mistake

public new string Speak() => "hidden";   // base reference calls the base version

Interfaces

C# 8+core

A contract, a type can implement many

public interface IRepository<T>
{
    Task<T?> GetAsync(Guid id);
    Task SaveAsync(T item);
}

public class OrderRepo : IRepository<Order>, IDisposable
{
    public Task<Order?> GetAsync(Guid id) => Task.FromResult<Order?>(null);
    public Task SaveAsync(Order item) => Task.CompletedTask;
    public void Dispose() { }
}

Default implementation, so adding a member is not a breaking change

public interface ILogger
{
    void Log(string message);
    void LogError(string message) => Log($"ERROR: {message}");
}

Explicit implementation hides the member from the class surface

void IDisposable.Dispose() { }
// only reachable through an IDisposable reference

Static abstract members, generic math

public interface IAddable<T> where T : IAddable<T>
{
    static abstract T Zero { get; }
    static abstract T operator +(T a, T b);
}

Object Members and Operators

advanced

Override ToString, Equals and GetHashCode together

public override string ToString() => $"{Name} ({Id})";

public override bool Equals(object? obj) =>
    obj is User u && u.Id == Id;

public override int GetHashCode() => Id.GetHashCode();

Operator overloading, always in pairs

public static Money operator +(Money a, Money b) =>
    new(a.Amount + b.Amount, a.Currency);

public static bool operator ==(Money a, Money b) => a.Equals(b);
public static bool operator !=(Money a, Money b) => !a.Equals(b);

Custom conversions

public static implicit operator decimal(Money m) => m.Amount;
public static explicit operator Money(decimal d) => new(d, "EUR");

IComparable makes your type sortable

public int CompareTo(Money? other) =>
    Amount.CompareTo(other?.Amount ?? 0);
11

Generics

type parameters and constraints

Generic Types and Methods

everyday

One implementation, many types, no boxing

public class Box<T>
{
    public T? Value { get; set; }
    public bool HasValue => Value is not null;
}

var b = new Box<int> { Value = 42 };

Generic method, the type is usually inferred

T FirstOrDefaultSafe<T>(IEnumerable<T> xs, T fallback) =>
    xs.Any() ? xs.First() : fallback;

int n = FirstOrDefaultSafe(numbers, -1);   // T inferred as int

Multiple type parameters

public class Cache<TKey, TValue> where TKey : notnull
{
    private readonly Dictionary<TKey, TValue> _map = new();
}

Constraints

advanced

The full list

ConstraintRequires that T
where T : classis a reference type
where T : structis a non nullable value type
where T : notnullis never null
where T : new()has a public parameterless constructor
where T : BaseTypederives from BaseType
where T : IFooimplements IFoo
where T : Uderives from another parameter
where T : unmanagedcontains no references
where T : allows ref structcan be a ref struct (C# 13)

Combining constraints, order matters

public T Create<T>() where T : class, IEntity, new()
{
    var item = new T();
    item.Id = Guid.NewGuid();
    return item;
}

Generic math over any number type, C# 11

using System.Numerics;

T Sum<T>(IEnumerable<T> xs) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var x in xs) total += x;
    return total;
}

Variance, out for producers and in for consumers

IEnumerable<object> objects = new List<string>();  // covariant, out T

Action<object> printAny = o => Console.WriteLine(o);
Action<string> printText = printAny;               // contravariant, in T
12

Collections & Arrays

lists, dictionaries, sets, spans

Arrays

core

Declaring, fixed length once created

int[] a = new int[5];              // zero filled
int[] b = { 1, 2, 3 };
int[] c = new[] { 1, 2, 3 };
int[] d = [1, 2, 3];               // collection expression, C# 12

Multidimensional versus jagged

int[,] grid = new int[3, 4];       // rectangular
grid[1, 2] = 9;

int[][] rows = new int[3][];       // jagged, rows can differ
rows[0] = new int[] { 1, 2 };

Useful array operations

Array.Sort(a);
Array.Reverse(a);
Array.IndexOf(a, 3);
Array.Fill(a, 7);
int[] copy = (int[])a.Clone();
Array.Copy(a, copy, a.Length);

List of T

core

The default resizable collection

var list = new List<string> { "a", "b" };
list.Add("c");
list.Insert(0, "z");
list.Remove("z");
Console.WriteLine(list.Count);
Console.WriteLine(list[1]);

Searching and bulk operations

list.Contains("a");
list.IndexOf("b");
list.Find(s => s.StartsWith("a"));
list.RemoveAll(s => s.Length > 3);
list.AddRange(other);
list.Sort();
list.Clear();

Preallocate the capacity when you know the size

var big = new List<int>(capacity: 10_000);

Dictionary

core

Key to value lookup, average O(1)

var ages = new Dictionary<string, int>
{
    ["Ana"] = 1,
    ["Luca"] = 2
};

Console.WriteLine(ages["Ana"]);
Console.WriteLine($"{ages.TryGetValue("Luca", out int v)} {v}");

Safe add, update and remove

ages.TryAdd("Mira", 3);          // false if present, never throws
ages["Ana"] = 10;                // add or overwrite
ages.Remove("Luca");
ages.ContainsKey("Ana");

Iterate keys, values or pairs

foreach (var (name, age) in ages)
    Console.WriteLine($"{name}: {age}");

foreach (string k in ages.Keys) { }
foreach (int val in ages.Values) { }

Case insensitive keys and counting patterns

var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

foreach (var word in words)
    map[word] = map.GetValueOrDefault(word) + 1;

Sets, Queues and Stacks

everyday

HashSet holds unique values

var set = new HashSet<int> { 1, 2, 2 };
set.Add(2);                  // false, already there
Console.WriteLine(set.Count);

Set algebra in place

a.UnionWith(b);
a.IntersectWith(b);
a.ExceptWith(b);
bool sub = a.IsSubsetOf(b);

Queue is first in first out, Stack is last in first out

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

var st = new Stack<string>();
st.Push("bottom"); st.Push("top");
st.Pop();                    // "top"

PriorityQueue, .NET 6 and later

var pq = new PriorityQueue<string, int>();
pq.Enqueue("low", 5);
pq.Enqueue("urgent", 1);
pq.Dequeue();                // "urgent", lowest priority value wins

Which Collection to Use

everyday

Pick by the operation you repeat most

TypeLookupInsertOrderedUse it for
T[]O(1) by indexfixedyesknown fixed size, hot loops
List<T>O(1) by index, O(n) by valueO(1) amortisedyesthe default sequence
Dictionary<K,V>O(1) by keyO(1)nokeyed lookup
HashSet<T>O(1) containsO(1)nouniqueness, membership
SortedDictionary<K,V>O(log n)O(log n)by keyordered keyed access
Queue<T>front onlyO(1)FIFOwork pipelines
Stack<T>top onlyO(1)LIFOundo, traversal
LinkedList<T>O(n)O(1) at a nodeyesfrequent middle inserts
ImmutableArray<T>O(1) by indexcopiesyesshared read only data
ConcurrentDictionary<K,V>O(1)O(1)nomulti threaded access

Collection Expressions

C# 12+advanced

One syntax for arrays, lists and spans

int[] a         = [1, 2, 3];
List<int> b     = [1, 2, 3];
Span<int> c     = [1, 2, 3];
ImmutableArray<int> d = [1, 2, 3];

Spread another collection with two dots

int[] head = [1, 2];
int[] tail = [4, 5];
int[] all = [..head, 3, ..tail];
Console.Write(string.Join(" ", all));

Immutable and Concurrent

advanced

Immutable collections return a new instance on change

using System.Collections.Immutable;

var list = ImmutableList.Create(1, 2, 3);
var bigger = list.Add(4);     // list still has 3 items

Thread safe collections, no external lock needed

using System.Collections.Concurrent;

var cache = new ConcurrentDictionary<string, int>();
cache.AddOrUpdate("hits", 1, (_, old) => old + 1);
int v = cache.GetOrAdd("misses", _ => 0);

var bag = new ConcurrentBag<string>();
var queue = new BlockingCollection<string>();

Read only views, cheap and safe to hand out

IReadOnlyList<int> view = list.AsReadOnly();
ReadOnlyDictionary<string, int> ro = new(map);
13

LINQ

query, project, group, aggregate

Two Syntaxes, One Result

core

Method syntax is the one you will see most

var names = people
    .Where(p => p.Age >= 18)
    .OrderBy(p => p.LastName)
    .Select(p => p.FirstName)
    .ToList();

Query syntax reads well for joins and groups

var names = from p in people
            where p.Age >= 18
            orderby p.LastName
            select p.FirstName;

Queries are lazy until you materialise them

var q = numbers.Where(n => n > 10);   // nothing has run yet
var list = q.ToList();                // now it runs
// ToArray, ToList, ToDictionary, ToHashSet, Count, First all force execution

Filtering and Projection

core

Where filters, Select transforms

int[] xs = [1, 2, 3, 4, 5, 6];
var evenSquares = xs.Where(x => x % 2 == 0).Select(x => x * x);
Console.Write(string.Join(" ", evenSquares));

SelectMany flattens nested sequences

var allTags = posts.SelectMany(p => p.Tags).Distinct();

Project into an anonymous type or a record

var rows = orders.Select(o => new { o.Id, o.Total, Year = o.Date.Year });
var dtos = orders.Select(o => new OrderDto(o.Id, o.Total));

Where with the index

var everyOther = xs.Where((x, i) => i % 2 == 0);

Ordering, Paging, Sets

everyday

Sort by one key then another

var sorted = people
    .OrderBy(p => p.LastName)
    .ThenByDescending(p => p.Age);

Paging with Skip and Take

int page = 3, size = 20;
var slice = query.Skip((page - 1) * size).Take(size);

Set operations across sequences

a.Distinct()          a.Union(b)
a.Intersect(b)        a.Except(b)
a.Concat(b)           a.SequenceEqual(b)

Key based variants, .NET 6 and later

var unique = people.DistinctBy(p => p.Email);
var top = people.MaxBy(p => p.Score);
var chunks = people.Chunk(100);       // batches of 100

Grouping and Joining

everyday

GroupBy returns a sequence of groups with a Key

var byCity = people.GroupBy(p => p.City);

foreach (var group in byCity)
    Console.WriteLine($"{group.Key}: {group.Count()}");

Group and aggregate in one pass

var stats = orders
    .GroupBy(o => o.CustomerId)
    .Select(g => new
    {
        Customer = g.Key,
        Orders = g.Count(),
        Revenue = g.Sum(o => o.Total)
    })
    .OrderByDescending(x => x.Revenue);

Inner join on a shared key

var rows = orders.Join(
    customers,
    o => o.CustomerId,
    c => c.Id,
    (o, c) => new { c.Name, o.Total });

Left join through GroupJoin and DefaultIfEmpty

var left = from c in customers
           join o in orders on c.Id equals o.CustomerId into g
           from o in g.DefaultIfEmpty()
           select new { c.Name, Total = o?.Total ?? 0 };

ToLookup builds a reusable one to many index

var index = orders.ToLookup(o => o.CustomerId);
var mine = index[customerId];   // empty sequence if the key is missing

Aggregation and Element Access

core

The common aggregates

int[] xs = [1, 2, 3, 4, 5, 6];
Console.WriteLine(xs.Sum());
Console.WriteLine(xs.Average());
Console.WriteLine(xs.Max());
Console.WriteLine(xs.Min());

First, Single and their safe variants

xs.First();                  // throws if empty
xs.FirstOrDefault();         // 0 or null
xs.FirstOrDefault(-1);       // custom default, .NET 6+
xs.Single(x => x > 5);       // throws if not exactly one match
xs.SingleOrDefault();
xs.Last();  xs.ElementAtOrDefault(2);

Quantifiers and counting

bool anyAdults = people.Any(p => p.Age >= 18);
bool allValid  = orders.All(o => o.IsValid);
bool hasItems  = list.Any();          // cheaper than Count() > 0
int adults     = people.Count(p => p.Age >= 18);

Aggregate folds a sequence into one value

string csv = names.Aggregate((acc, n) => $"{acc}, {n}");
int product = xs.Aggregate(1, (acc, x) => acc * x);

Generators and pairing

Enumerable.Range(1, 10);
Enumerable.Repeat("x", 3);
Enumerable.Empty<int>();
names.Zip(scores, (n, s) => $"{n}: {s}");
14

Delegates & Events

functions as values

Lambdas

everyday

Expression lambda and statement lambda

Func<int, int> square = x => x * x;

Func<int, string> describe = x =>
{
    if (x < 0) return "negative";
    return x.ToString();
};

Console.WriteLine(square(5));

Explicit types, defaults and params, C# 12

var add = (int a, int b = 10) => a + b;
var log = (params string[] parts) => string.Join(" ", parts);

Closures capture variables, not values

int factor = 2;
Func<int, int> scale = x => x * factor;
factor = 10;
Console.WriteLine(scale(5));    // 50, not 10

static lambda refuses to capture, which avoids allocations

Func<int, int> pure = static x => x * 2;

Func, Action, Predicate

everyday

Built in delegate types, no custom declaration needed

Func<int, int, int> add = (a, b) => a + b;   // last parameter is the return
Action<string> print = s => Console.WriteLine(s);  // returns void
Predicate<int> isEven = n => n % 2 == 0;     // returns bool

Pass behaviour into a method

void Retry(int times, Action work)
{
    for (int i = 0; i < times; i++)
    {
        try { work(); return; }
        catch when (i < times - 1) { }
    }
}

Retry(3, () => CallApi());

Custom delegate and multicast invocation

public delegate void Notify(string message);

Notify chain = m => Console.WriteLine($"log: {m}");
chain += m => File.AppendAllText("app.log", m);
chain("started");         // both run, in order

Method group, shorter than a lambda that just forwards

names.ForEach(Console.WriteLine);     // instead of x => Console.WriteLine(x)

Events

advanced

Declare, raise and subscribe

public class Downloader
{
    public event EventHandler<int>? ProgressChanged;

    private void Report(int percent) =>
        ProgressChanged?.Invoke(this, percent);
}

var d = new Downloader();
d.ProgressChanged += (sender, pct) => Console.WriteLine($"{pct}%");

Custom event arguments

public class FileFoundEventArgs : EventArgs
{
    public required string Path { get; init; }
}

public event EventHandler<FileFoundEventArgs>? FileFound;

Always unsubscribe, otherwise the publisher keeps the subscriber alive

d.ProgressChanged -= Handler;
15

Async & Concurrency

tasks, cancellation, parallelism

async and await

core

Await releases the thread while the work is pending

public async Task<string> GetTitleAsync(string url)
{
    using var http = new HttpClient();
    string html = await http.GetStringAsync(url);
    return html[..50];
}

Return types

SignatureUse when
async Taskno result to return
async Task<T>a result of type T
async ValueTask<T>hot path that often completes synchronously
async voidevent handlers only, exceptions cannot be caught
async IAsyncEnumerable<T>streaming results as they arrive

Async all the way down, never block with .Result or .Wait()

// wrong, can deadlock
string s = GetTitleAsync(url).Result;

// right
string s = await GetTitleAsync(url);

Already finished results, without an await

Task.CompletedTask;
Task.FromResult(42);
ValueTask.FromResult(42);
Task.FromException(new IOException());

Running Work in Parallel

everyday

Start together, then await together

Task<string> a = GetAsync(url1);
Task<string> b = GetAsync(url2);

string[] both = await Task.WhenAll(a, b);

First one to finish wins

Task done = await Task.WhenAny(primary, fallback);
string result = await (Task<string>)done;

Fan out over a collection

var results = await Task.WhenAll(urls.Select(GetAsync));

Bounded concurrency, .NET 6 and later

await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 4 },
    async (url, ct) => await DownloadAsync(url, ct));

CPU bound work belongs on a background thread

int result = await Task.Run(() => HeavyComputation());

Cancellation and Timeouts

everyday

Pass the token down the whole call chain

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
    await LongRunningAsync(cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("timed out");
}

Check the token inside your own loops

foreach (var item in items)
{
    ct.ThrowIfCancellationRequested();
    await ProcessAsync(item, ct);
}

Combine several tokens into one

using var linked = CancellationTokenSource
    .CreateLinkedTokenSource(userToken, shutdownToken);

Add a timeout to any task, .NET 8 and later

await slowTask.WaitAsync(TimeSpan.FromSeconds(2));

Async Streams and Synchronisation

C# 8+advanced

Yield results as they arrive

async IAsyncEnumerable<string> ReadLinesAsync(
    string path, [EnumeratorCancellation] CancellationToken ct = default)
{
    using var reader = new StreamReader(path);
    while (await reader.ReadLineAsync(ct) is { } line)
        yield return line;
}

await foreach (string line in ReadLinesAsync("log.txt"))
    Console.WriteLine(line);

lock protects shared state on synchronous paths

private readonly object _gate = new();

lock (_gate)
{
    _counter++;
}
// never await inside a lock

SemaphoreSlim is the async friendly gate

private readonly SemaphoreSlim _slots = new(1, 1);

await _slots.WaitAsync(ct);
try { await MutateAsync(); }
finally { _slots.Release(); }

Atomic counters without a lock

Interlocked.Increment(ref _count);
Interlocked.Exchange(ref _flag, 1);
Interlocked.CompareExchange(ref _state, newValue, expected);

ConfigureAwait(false) in library code, skips the captured context

var data = await client.GetAsync(url).ConfigureAwait(false);
16

Exceptions & Files

error handling, disposal, I/O, JSON

try, catch, finally

core

Catch the specific type first, the general one last

try
{
    string text = File.ReadAllText(path);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"missing: {ex.FileName}");
}
catch (IOException ex)
{
    Console.WriteLine(ex.Message);
}
finally
{
    Console.WriteLine("always runs");
}

Exception filter with when, the stack is not unwound if it is false

catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null;
}

Rethrow, keep the original stack trace

catch (Exception ex)
{
    Log(ex);
    throw;            // correct
    // throw ex;      // wrong, resets the stack trace
}

Wrap with context, keep the inner exception

throw new InvalidOperationException("Could not import the order", ex);

Throwing and Custom Exceptions

everyday

Common built in exceptions

TypeThrow it when
ArgumentNullExceptiona required argument is null
ArgumentExceptionan argument is present but invalid
ArgumentOutOfRangeExceptiona value is outside the allowed range
InvalidOperationExceptionthe object state forbids the call
NotSupportedExceptionthe operation will never be supported
KeyNotFoundExceptiona dictionary key is missing
OperationCanceledExceptiona token was cancelled
TimeoutExceptionan operation exceeded its budget

Your own exception type

public class OrderNotFoundException : Exception
{
    public Guid OrderId { get; }

    public OrderNotFoundException(Guid id)
        : base($"Order {id} was not found")
        => OrderId = id;
}

throw as an expression

string name = input ?? throw new ArgumentNullException(nameof(input));
int code = status switch { "ok" => 200, _ => throw new ArgumentException() };

using and IDisposable

C# 8+everyday

Deterministic cleanup, even when an exception is thrown

using (var stream = File.OpenRead(path))
{
    // stream.Dispose() runs at the closing brace
}

// declaration form, disposed at the end of the scope
using var reader = new StreamReader(path);

Async disposal

await using var conn = new SqlConnection(cs);
await conn.OpenAsync(ct);

Implement it on your own type

public sealed class Session : IDisposable
{
    private bool _disposed;

    public void Dispose()
    {
        if (_disposed) return;
        _handle.Close();
        _disposed = true;
    }
}

Files and Directories

everyday

Whole file helpers, sync and async

string text = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);
byte[] bytes = File.ReadAllBytes(path);

await File.WriteAllTextAsync(path, text);
await File.AppendAllTextAsync(path, "\nmore");

Existence, copy, move, delete

File.Exists(path);
File.Copy(src, dst, overwrite: true);
File.Move(src, dst);
File.Delete(path);

Paths, always compose them with Path

string full = Path.Combine(root, "logs", "app.log");
string ext = Path.GetExtension(full);          // .log
string name = Path.GetFileNameWithoutExtension(full);
string dir = Path.GetDirectoryName(full)!;
string temp = Path.GetTempFileName();

Directories and enumeration

Directory.CreateDirectory(dir);
foreach (string f in Directory.EnumerateFiles(dir, "*.cs",
             SearchOption.AllDirectories))
    Console.WriteLine(f);

Stream a large file line by line, low memory

using var reader = new StreamReader(path);
while (await reader.ReadLineAsync() is { } line)
    Process(line);

JSON with System.Text.Json

everyday

Serialize and deserialize

using System.Text.Json;

var person = new { Name = "Ana", Age = 30 };
string json = JsonSerializer.Serialize(person);
Console.WriteLine(json);

var back = JsonSerializer.Deserialize<Person>(json);

Options you will almost always want

var opts = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    PropertyNameCaseInsensitive = true,
    WriteIndented = true,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

Attributes on the model

public class Product
{
    [JsonPropertyName("product_id")] public int Id { get; set; }
    [JsonIgnore] public string? Internal { get; set; }
}

Stream straight to and from a file

await using var fs = File.Create(path);
await JsonSerializer.SerializeAsync(fs, data, opts);
17

Everyday APIs

dates, ids, math, timing, http

Dates and Times

everyday

Prefer DateTimeOffset or the date only types

DateTime now = DateTime.Now;             // local, ambiguous
DateTime utc = DateTime.UtcNow;          // store this
DateTimeOffset off = DateTimeOffset.Now; // carries the offset
DateOnly day = DateOnly.FromDateTime(now);
TimeOnly clock = TimeOnly.FromDateTime(now);

Arithmetic and differences

DateTime tomorrow = now.AddDays(1);
TimeSpan age = now - birthday;
Console.WriteLine(age.TotalDays);
TimeSpan half = TimeSpan.FromMinutes(30);

Time zones and testable clocks

var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Bucharest");
var local = TimeZoneInfo.ConvertTime(utc, tz);

TimeProvider clock = TimeProvider.System;   // inject this, .NET 8+
DateTimeOffset nowTestable = clock.GetUtcNow();

Guid, Random and Environment

everyday

Identifiers

Guid id = Guid.NewGuid();
string text = id.ToString("N");        // no dashes
bool ok = Guid.TryParse(input, out Guid parsed);
Guid v7 = Guid.CreateVersion7();       // time ordered, .NET 9

Random numbers, use the shared instance

int roll = Random.Shared.Next(1, 7);       // 1 to 6
double unit = Random.Shared.NextDouble();  // 0.0 to 1.0
Random.Shared.Shuffle(items);              // .NET 8

var seeded = new Random(42);               // reproducible in tests

Cryptographically secure randomness

using System.Security.Cryptography;

int secure = RandomNumberGenerator.GetInt32(1, 7);
byte[] key = RandomNumberGenerator.GetBytes(32);
string token = Convert.ToHexString(key);

Environment and process information

Environment.GetEnvironmentVariable("PATH");
Environment.CurrentDirectory;
Environment.ProcessorCount;
Environment.NewLine;
Environment.Exit(1);
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);

Math and Numbers

everyday

Rounding, and the banker's rounding surprise

Console.WriteLine(Math.Round(3.5));                 // 4
Console.WriteLine(Math.Round(2.5));                 // 3 when away from zero
Console.WriteLine(Math.Floor(-2.1));                // -3
Console.WriteLine(Math.Ceiling(4.1));               // 5
Console.WriteLine(Math.Clamp(15, 0, 10));           // 10

Control the midpoint rule explicitly

Math.Round(2.5);                                  // 2, to even
Math.Round(2.5, MidpointRounding.AwayFromZero);   // 3
Math.Round(3.14159, 2);                           // 3.14

Common operations

Math.Abs(-7)        Math.Max(a, b)     Math.Min(a, b)
Math.Pow(2, 10)     Math.Sqrt(144)     Math.Cbrt(27)
Math.Log(e)         Math.Log10(1000)   Math.Sign(-4)
Math.DivRem(17, 5, out int rem)

Floating point comparison

// wrong
if (0.1 + 0.2 == 0.3) { }              // false

// right
if (Math.Abs((0.1 + 0.2) - 0.3) < 1e-9) { }
// or use decimal for money
if (0.1m + 0.2m == 0.3m) { }           // true

Timing and Diagnostics

everyday

Measure elapsed time

using System.Diagnostics;

var sw = Stopwatch.StartNew();
DoWork();
sw.Stop();
Console.WriteLine($"took {sw.ElapsedMilliseconds} ms");

// allocation free, .NET 7+
long start = Stopwatch.GetTimestamp();
TimeSpan spent = Stopwatch.GetElapsedTime(start);

Debug and trace output

Debug.WriteLine("only in debug builds");
Debug.Assert(count > 0, "count must be positive");
Trace.WriteLine("in debug and release");

Run an external process

var info = new ProcessStartInfo("git", "status")
{
    RedirectStandardOutput = true,
    UseShellExecute = false
};
using var proc = Process.Start(info)!;
string output = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();

HttpClient

everyday

GET and deserialize in one call

using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.example.com/") };
var user = await http.GetFromJsonAsync<User>("users/1", ct);

POST with a JSON body and check the status

HttpResponseMessage res = await http.PostAsJsonAsync("orders", newOrder, ct);
res.EnsureSuccessStatusCode();
var created = await res.Content.ReadFromJsonAsync<Order>(cancellationToken: ct);

Never create one per request

// in Program.cs
builder.Services.AddHttpClient<GitHubClient>(c =>
{
    c.BaseAddress = new Uri("https://api.github.com/");
    c.DefaultRequestHeaders.Add("User-Agent", "my-app");
});

// then inject HttpClient into GitHubClient

Headers, timeouts and cancellation

http.Timeout = TimeSpan.FromSeconds(10);
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", token);

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var res = await http.GetAsync("slow", cts.Token);
18

DI, Config & Logging

services, options, ILogger, hosting

Registering Services

everyday

Interface to implementation, in Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddTransient<IEmailSender, SmtpSender>();

The three lifetimes

LifetimeCreatedUse for
Singletononce per applicationcaches, clocks, config, HTTP clients
Scopedonce per requestDbContext, unit of work, per request state
Transientevery time it is resolvedcheap stateless helpers

Factory registration and instance registration

builder.Services.AddSingleton<IClock>(_ => new FixedClock(DateTime.UtcNow));
builder.Services.AddSingleton(new AppInfo("api", "1.4.0"));
builder.Services.TryAddScoped<ICache, MemoryCache>();   // skip if present

Catch lifetime mistakes at startup, not in production

builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

Consuming Services

C# 12+everyday

Constructor injection, the default and the one to reach for

public class OrderService(
    IOrderRepository repo,
    ILogger<OrderService> logger)
{
    public async Task<Order?> GetAsync(Guid id)
    {
        logger.LogInformation("Loading order {OrderId}", id);
        return await repo.GetAsync(id);
    }
}

Resolve a scoped service inside a singleton or a background job

public class Worker(IServiceScopeFactory scopes) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using IServiceScope scope = scopes.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        await db.SaveChangesAsync(ct);
    }
}

Get versus GetRequired

var maybe = provider.GetService<IThing>();          // null if unregistered
var must  = provider.GetRequiredService<IThing>();  // throws if unregistered
var many  = provider.GetServices<IHandler>();       // every registration

Configuration

everyday

appsettings.json and per environment overrides

{
  "ConnectionStrings": { "Default": "Server=.;Database=Shop" },
  "Smtp": { "Host": "mail.example.com", "Port": 587 }
}
// appsettings.Development.json overrides matching keys

Read a value, with a section path

string cs = builder.Configuration.GetConnectionString("Default")!;
int port = builder.Configuration.GetValue<int>("Smtp:Port");
IConfigurationSection smtp = builder.Configuration.GetSection("Smtp");

Bind a section to a typed class

public class SmtpOptions
{
    public required string Host { get; set; }
    public int Port { get; set; } = 25;
}

builder.Services.Configure<SmtpOptions>(
    builder.Configuration.GetSection("Smtp"));

// then inject IOptions<SmtpOptions> and read .Value

Validate options at startup

builder.Services.AddOptions<SmtpOptions>()
    .Bind(builder.Configuration.GetSection("Smtp"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Secrets stay out of source control

dotnet user-secrets init
dotnet user-secrets set "Smtp:Password" "hunter2"

# environment variables use a double underscore for nesting
export Smtp__Password=hunter2

Logging

everyday

Structured logging, keep the placeholders

logger.LogInformation("Loading order {OrderId} for {Customer}", id, name);

// not this, the values become unsearchable
logger.LogInformation($"Loading order {id} for {name}");

Levels, from most to least verbose

logger.LogTrace("...");        // step by step detail
logger.LogDebug("...");        // developer diagnostics
logger.LogInformation("...");  // normal flow
logger.LogWarning("...");      // unexpected but handled
logger.LogError(ex, "...");    // the operation failed
logger.LogCritical(ex, "..."); // the app cannot continue

Always pass the exception as the first argument

catch (Exception ex)
{
    logger.LogError(ex, "Import failed for {File}", path);
    throw;
}

Scopes attach context to everything inside

using (logger.BeginScope("Order {OrderId}", id))
{
    logger.LogInformation("validated");   // carries OrderId
    logger.LogInformation("charged");     // carries OrderId
}

Compile time logging, no boxing and no allocation

public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Warning,
        Message = "Retry {Attempt} for {Url}")]
    public static partial void Retry(ILogger logger, int attempt, string url);
}

Generic Host and Background Work

advanced

A console app with DI, config and logging

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddSingleton<IClock, SystemClock>();

IHost host = builder.Build();
await host.RunAsync();

A long running background service

public class Worker(ILogger<Worker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            logger.LogInformation("tick at {Now}", DateTimeOffset.UtcNow);
            await Task.Delay(TimeSpan.FromSeconds(30), ct);
        }
    }
}
19

ASP.NET Core

minimal APIs, controllers, middleware

A Minimal API

.NET 6+everyday

A complete working API in one file

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

var app = builder.Build();

app.MapGet("/orders/{id:guid}", async (Guid id, IOrderRepository repo) =>
    await repo.GetAsync(id) is { } order
        ? Results.Ok(order)
        : Results.NotFound());

app.MapPost("/orders", async (Order order, IOrderRepository repo) =>
{
    await repo.SaveAsync(order);
    return Results.Created($"/orders/{order.Id}", order);
});

app.Run();

Every verb, plus route groups

app.MapGet("/health", () => "ok");
app.MapPut("/orders/{id:guid}", Update);
app.MapDelete("/orders/{id:guid}", Delete);
app.MapPatch("/orders/{id:guid}", Patch);

var orders = app.MapGroup("/orders").RequireAuthorization();
orders.MapGet("/", ListAll);

Route constraints

"/users/{id:int}"          "/items/{id:guid}"
"/posts/{slug:alpha}"      "/page/{n:min(1)}"
"/files/{name:length(1,64)}"
"/archive/{year:int:range(1990,2100)}"
"/optional/{tag?}"

Binding and Results

everyday

Where each parameter comes from

app.MapGet("/search", (
    [FromRoute] int id,
    [FromQuery] string? q,
    [FromHeader(Name = "X-Tenant")] string tenant,
    [FromServices] ISearchService svc,
    CancellationToken ct) => svc.FindAsync(q, ct));

// simple types default to the query string,
// complex types default to the JSON body

Returning the right status code

Results.Ok(value)                Results.NoContent()
Results.Created(uri, value)      Results.Accepted()
Results.NotFound()               Results.BadRequest(problem)
Results.Conflict()               Results.Unauthorized()
Results.Forbid()                 Results.File(bytes, "application/pdf")
Results.Problem("Payment declined", statusCode: 402)

TypedResults documents the response for OpenAPI

app.MapGet("/orders/{id:guid}",
    async Task<Results<Ok<Order>, NotFound>> (Guid id, IOrderRepository repo)
        => await repo.GetAsync(id) is { } o
            ? TypedResults.Ok(o)
            : TypedResults.NotFound());

Model validation with data annotations

public record CreateOrder(
    [Required, StringLength(80)] string Customer,
    [Range(1, 999)] int Quantity,
    [EmailAddress] string Email);

Controllers

everyday

The MVC style, still the default for larger APIs

[ApiController]
[Route("api/[controller]")]
public class OrdersController(IOrderRepository repo) : ControllerBase
{
    [HttpGet("{id:guid}")]
    public async Task<ActionResult<Order>> Get(Guid id)
    {
        Order? order = await repo.GetAsync(id);
        return order is null ? NotFound() : Ok(order);
    }

    [HttpPost]
    public async Task<ActionResult<Order>> Create([FromBody] Order order)
    {
        await repo.SaveAsync(order);
        return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
    }
}

Wire controllers into the pipeline

builder.Services.AddControllers();
// ...
app.MapControllers();

ApiController gives you automatic 400 responses

// with [ApiController], invalid ModelState returns
// a ProblemDetails 400 before your action runs.
// to turn it off:
builder.Services.Configure<ApiBehaviorOptions>(o =>
    o.SuppressModelStateInvalidFilter = true);

Middleware Pipeline

everyday

A conventional order, and why it matters

app.UseExceptionHandler("/error");
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("frontend");
app.UseAuthentication();     // who are you
app.UseAuthorization();      // are you allowed
app.UseOutputCache();
app.MapControllers();        // endpoints last

Inline middleware

app.Use(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    await next(context);
    Console.WriteLine($"{context.Request.Path} took {sw.ElapsedMilliseconds} ms");
});

A middleware class

public class TenantMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext ctx, ITenantStore store)
    {
        ctx.Items["tenant"] = store.Resolve(ctx.Request.Host.Host);
        await next(ctx);
    }
}

app.UseMiddleware<TenantMiddleware>();

Global error handling as ProblemDetails

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<AppExceptionHandler>();
// ...
app.UseExceptionHandler();

CORS, Auth and OpenAPI

advanced

A named CORS policy

builder.Services.AddCors(o => o.AddPolicy("frontend", p =>
    p.WithOrigins("https://app.example.com")
     .AllowAnyHeader()
     .AllowAnyMethod()));

app.UseCors("frontend");

JWT bearer authentication

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o => o.Authority = "https://login.example.com");

builder.Services.AddAuthorization(o =>
    o.AddPolicy("admin", p => p.RequireRole("Admin")));

app.MapGet("/secret", () => "shh").RequireAuthorization("admin");

Read the signed in user

string? id = User.FindFirstValue(ClaimTypes.NameIdentifier);
bool isAdmin = User.IsInRole("Admin");

OpenAPI document, built in from .NET 9

builder.Services.AddOpenApi();
// ...
app.MapOpenApi();   // served at /openapi/v1.json
20

Entity Framework Core

DbContext, queries, migrations

DbContext

everyday

Define the context and its sets

public class ShopContext(DbContextOptions<ShopContext> options)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
}

Register it, scoped by default

builder.Services.AddDbContext<ShopContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// UseNpgsql, UseSqlite, UseCosmos, UseInMemoryDatabase

An entity

public class Order
{
    public Guid Id { get; set; }
    public required string Reference { get; set; }
    public decimal Total { get; set; }
    public DateTime PlacedAt { get; set; }

    public Guid CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
    public List<OrderLine> Lines { get; set; } = [];
}

Querying

everyday

Reading single rows

Order? byKey = await db.Orders.FindAsync(id);          // checks the cache first
Order? first = await db.Orders.FirstOrDefaultAsync(o => o.Total > 100, ct);
Order one    = await db.Orders.SingleAsync(o => o.Reference == code, ct);

Loading related data

var orders = await db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .ToListAsync(ct);

Project to exactly the columns you need

var rows = await db.Orders
    .Where(o => o.PlacedAt >= from)
    .OrderByDescending(o => o.PlacedAt)
    .Select(o => new OrderSummary(o.Id, o.Reference, o.Customer.Name, o.Total))
    .Take(50)
    .ToListAsync(ct);

Turn off tracking for read only queries

var report = await db.Orders
    .AsNoTracking()
    .Where(o => o.Total > 1000)
    .ToListAsync(ct);

Paging and aggregates run in SQL

var page = await db.Orders.OrderBy(o => o.Id)
    .Skip((n - 1) * size).Take(size).ToListAsync(ct);

int count = await db.Orders.CountAsync(o => o.Total > 100, ct);
decimal sum = await db.Orders.SumAsync(o => o.Total, ct);
bool any = await db.Orders.AnyAsync(o => o.Reference == code, ct);

Raw SQL when LINQ is not enough

var rows = await db.Orders
    .FromSql($"SELECT * FROM Orders WHERE Total > {threshold}")
    .ToListAsync(ct);
// interpolation here is parameterised, not concatenated

Writing

everyday

Insert, update, delete

db.Orders.Add(order);
db.Orders.AddRange(batch);

Order existing = await db.Orders.FindAsync(id);
existing.Total = 99m;              // tracked, no Update call needed

db.Orders.Remove(existing);

await db.SaveChangesAsync(ct);     // one transaction

Bulk update and delete without loading rows

await db.Orders
    .Where(o => o.PlacedAt < cutoff)
    .ExecuteDeleteAsync(ct);                       // EF 7+

await db.Orders
    .Where(o => o.Status == "pending")
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "expired"), ct);

Explicit transactions across several saves

await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
    await db.SaveChangesAsync(ct);
    await other.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
}
catch
{
    await tx.RollbackAsync(ct);
    throw;
}

Handle a concurrency conflict

try { await db.SaveChangesAsync(ct); }
catch (DbUpdateConcurrencyException ex)
{
    foreach (var entry in ex.Entries)
        await entry.ReloadAsync(ct);
}

Migrations and Mapping

everyday

The commands you will run most

dotnet tool install --global dotnet-ef
dotnet ef migrations add AddOrderStatus
dotnet ef database update
dotnet ef migrations remove
dotnet ef migrations script --idempotent -o deploy.sql
dotnet ef dbcontext scaffold "..." Microsoft.EntityFrameworkCore.SqlServer

Fluent configuration

protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Order>(e =>
    {
        e.HasKey(o => o.Id);
        e.Property(o => o.Reference).HasMaxLength(40).IsRequired();
        e.Property(o => o.Total).HasPrecision(18, 2);
        e.HasIndex(o => o.Reference).IsUnique();
        e.HasMany(o => o.Lines)
         .WithOne(l => l.Order)
         .OnDelete(DeleteBehavior.Cascade);
    });
}

Data annotations, the shorter alternative

[Table("orders")]
public class Order
{
    [Key] public Guid Id { get; set; }
    [MaxLength(40), Required] public string Reference { get; set; } = "";
    [Precision(18, 2)] public decimal Total { get; set; }
    [NotMapped] public bool IsLarge => Total > 1000m;
}

EF Core Pitfalls

advanced

The failures that show up in production, not in dev

SymptomCauseFix
Hundreds of queries per requestN plus 1 from lazy loading a navigation in a loopInclude, or project with Select
Whole table pulled into memoryToList before Where, so filtering runs in C#Filter on IQueryable, materialise last
Query throws at runtimeA C# method EF cannot translate to SQLMove it after AsEnumerable, or rewrite it
A second operation started on this contextTwo awaits on one DbContext at the same timeAwait sequentially, or use a context per task
Cannot resolve scoped DbContextInjected into a singleton or a hosted serviceInject IServiceScopeFactory and create a scope
Decimal rounding differencesNo precision configured, defaults to 18,2 silentlySet HasPrecision explicitly
Migration drops a column of live dataA rename detected as delete plus addEdit the migration to use RenameColumn
21

Testing

xUnit, mocks, integration tests

xUnit Basics

everyday

A single test, arrange act assert

public class BasketTests
{
    [Fact]
    public void Total_sums_every_line()
    {
        var basket = new Basket();               // arrange
        basket.Add(new Line("apple", 2, 1.50m));

        decimal total = basket.Total;            // act

        Assert.Equal(3.00m, total);              // assert
    }
}

One test, many inputs

[Theory]
[InlineData(0, "empty")]
[InlineData(1, "single")]
[InlineData(9, "many")]
public void Describes_the_count(int count, string expected)
    => Assert.Equal(expected, Basket.Describe(count));

Complex cases from a member or class source

public static TheoryData<decimal, decimal> Discounts => new()
{
    { 100m, 0m },
    { 500m, 25m },
    { 2000m, 200m }
};

[Theory]
[MemberData(nameof(Discounts))]
public void Applies_the_right_discount(decimal total, decimal expected)
    => Assert.Equal(expected, Pricing.Discount(total));

Setup and teardown are the constructor and Dispose

public class DbTests : IDisposable
{
    private readonly SqliteConnection _conn;
    public DbTests() => _conn = OpenTestDatabase();   // before each test
    public void Dispose() => _conn.Dispose();         // after each test
}

Assertions

everyday

The xUnit assertions worth memorising

AssertionPasses when
Assert.Equal(a, b)values are equal, expected comes first
Assert.Equal(a, b, 2)doubles match to 2 decimal places
Assert.Same(a, b)the same reference, not just equal
Assert.True / Falsea boolean, pass a message for context
Assert.Null / NotNullreference is or is not null
Assert.Contains(x, list)the sequence contains the item
Assert.Empty / NotEmptythe sequence has no or some items
Assert.Collection(list, ...)each item matches its own inspector in order
Assert.Throws<T>(() => ...)exactly that exception type is thrown
Assert.ThrowsAsync<T>(...)the awaited call throws, must be awaited
Assert.IsType<T>(x)the runtime type matches exactly
Assert.Matches(pattern, s)the string matches a regular expression

Testing that something throws

var ex = Assert.Throws<ArgumentException>(() => new Basket().Add(null!));
Assert.Equal("line", ex.ParamName);

await Assert.ThrowsAsync<HttpRequestException>(
    () => client.GetAsync("/missing"));

Test Doubles

advanced

Moq, the most common mocking library

var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.GetAsync(It.IsAny<Guid>()))
    .ReturnsAsync(new Order { Total = 50m });

var service = new OrderService(repo.Object, NullLogger<OrderService>.Instance);
await service.GetAsync(Guid.NewGuid());

repo.Verify(r => r.GetAsync(It.IsAny<Guid>()), Times.Once);

NSubstitute reads a little lighter

var repo = Substitute.For<IOrderRepository>();
repo.GetAsync(Arg.Any<Guid>()).Returns(new Order());

await repo.Received(1).GetAsync(Arg.Any<Guid>());

A hand written fake, often the better option

public class FakeClock(DateTimeOffset now) : IClock
{
    public DateTimeOffset UtcNow { get; set; } = now;
    public void Advance(TimeSpan by) => UtcNow = UtcNow.Add(by);
}

Fake the clock with TimeProvider, .NET 8

var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-01Z"));
var service = new TrialService(time);
time.Advance(TimeSpan.FromDays(31));
Assert.True(service.HasExpired());

Integration Tests

advanced

Spin up the real app in memory

public class OrdersApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Get_unknown_order_returns_404()
    {
        HttpClient client = factory.CreateClient();
        var res = await client.GetAsync($"/orders/{Guid.NewGuid()}");
        Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
    }
}

Swap a real dependency for a test one

var client = factory.WithWebHostBuilder(b =>
    b.ConfigureServices(s =>
    {
        s.RemoveAll<IEmailSender>();
        s.AddSingleton<IEmailSender, RecordingSender>();
    })).CreateClient();

An in memory database for EF tests

var options = new DbContextOptionsBuilder<ShopContext>()
    .UseSqlite("DataSource=:memory:")
    .Options;

using var db = new ShopContext(options);
await db.Database.OpenConnectionAsync();
await db.Database.EnsureCreatedAsync();

Running Tests

everyday

Create the project and reference the code under test

dotnet new xunit -o Shop.Tests
dotnet add Shop.Tests reference Shop/Shop.csproj
dotnet add Shop.Tests package Moq

Run, filter and collect coverage

dotnet test
dotnet test --filter "FullyQualifiedName~BasketTests"
dotnet test --filter "Category=Integration"
dotnet test --logger "console;verbosity=detailed"
dotnet test --collect:"XPlat Code Coverage"

Skip a test, and group them with traits

[Fact(Skip = "flaky until the sandbox is fixed")]
public void Charges_the_card() { }

[Trait("Category", "Integration")]
public class SlowTests { }
22

Advanced & CLI

attributes, reflection, dates, tooling

Attributes and Reflection

advanced

Apply metadata to any member

[Obsolete("Use SendAsync instead")]
[Serializable]
public void Send() { }

Define your own

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class RouteAttribute : Attribute
{
    public string Template { get; }
    public RouteAttribute(string template) => Template = template;
}

Read types and members at runtime

Type t = typeof(Order);
foreach (var p in t.GetProperties())
    Console.WriteLine($"{p.PropertyType.Name} {p.Name}");

var attr = t.GetCustomAttribute<RouteAttribute>();
object? instance = Activator.CreateInstance(t);

Caller info, free diagnostics

void Trace(string msg,
    [CallerMemberName] string caller = "",
    [CallerLineNumber] int line = 0)
    => Console.WriteLine($"{caller}:{line} {msg}");

Preprocessor and Unsafe

advanced

Conditional compilation

#if DEBUG
    Console.WriteLine("debug build");
#elif RELEASE
    Log.Minimal();
#endif

#warning revisit before release
#pragma warning disable CS8618

Pointers, only inside an unsafe context

unsafe
{
    int value = 42;
    int* p = &value;
    Console.WriteLine(*p);
}
// requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

Stack allocation, no garbage collector pressure

Span<byte> buffer = stackalloc byte[256];

dotnet CLI

core

Create, build, run

dotnet new console -o MyApp
dotnet new webapi -o MyApi
dotnet new classlib -o MyLib
dotnet build
dotnet run
dotnet watch run

Packages, references, solutions

dotnet add package Serilog
dotnet remove package Serilog
dotnet add reference ../MyLib/MyLib.csproj
dotnet new sln -n MySolution
dotnet sln add MyApp/MyApp.csproj
dotnet restore

Test, format, publish

dotnet test
dotnet test --filter "FullyQualifiedName~Orders"
dotnet format
dotnet publish -c Release -r linux-x64 --self-contained
dotnet --list-sdks

A minimal project file

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

C# Version Highlights

everyday

What shipped when, and the .NET release that carries it

VersionShips withHeadline features
C# 8.NET Core 3.1nullable reference types, async streams, using declarations, switch expressions, default interface methods
C# 9.NET 5records, init only setters, top level statements, relational and logical patterns
C# 10.NET 6file scoped namespaces, global usings, record struct, extended property patterns
C# 11.NET 7raw string literals, list patterns, required members, generic math, UTF-8 literals
C# 12.NET 8primary constructors, collection expressions, alias any type, default lambda parameters
C# 13.NET 9params collections, new lock type, implicit index in initializers, ref struct in generics
23

Compiler Errors

what the code means and how to fix it

Type and Member Errors

core

The compiler cannot find something

CodeMessageUsual cause and fix
CS0246type or namespace could not be foundA missing using, or a missing NuGet or project reference. Check the spelling, then add the using or the package.
CS0234type does not exist in the namespaceThe namespace resolves but the type does not. Usually the wrong package version, or the type moved.
CS1061type does not contain a definition for XA typo, the wrong type, or an extension method whose namespace is not imported. LINQ needs using System.Linq.
CS0117type does not contain a definition for XYou used an instance member as if it were static, or the other way round.
CS0103the name X does not exist in the current contextOut of scope. The variable was declared inside a block, a loop or a different method.
CS0165use of unassigned local variableA path exists where the variable is never set. Give it an initial value, or assign it in every branch.
CS0122X is inaccessible due to its protection levelThe member is private or internal. Make it public, or move the caller into the same assembly.
CS0120an object reference is required for the non static fieldYou called an instance member from a static method. Create an instance, or make the member static.

Conversion Errors

core

Types do not line up

CodeMessageUsual cause and fix
CS0029cannot implicitly convert type A to BAn assignment across incompatible types. Add an explicit cast if it is safe, or parse instead.
CS0266an explicit conversion exists, are you missing a castThe conversion is possible but lossy. Add the cast, and think about whether the loss is acceptable.
CS1503argument N cannot convert from A to BArguments in the wrong order, or the wrong overload. Check the parameter list.
CS1502the best overloaded method has invalid argumentsComes with CS1503. Read the second error, it names the offending argument.
CS0019operator cannot be applied to operands of type A and BComparing or adding incompatible types, often a string and a number. Parse first.
CS0021cannot apply indexing to an expression of type XIndexing something that is not indexable, often an IEnumerable. Call ToList or ElementAt.
CS1929type does not contain a definition, the best extension overload requires a receiver of type YAn extension method exists but for a different type, often IEnumerable of T rather than T.

CS0029 in practice

// error CS0029: cannot implicitly convert type 'string' to 'int'
int age = Console.ReadLine();

// fix
int age = int.Parse(Console.ReadLine()!);
// better, it never throws
if (!int.TryParse(Console.ReadLine(), out int age)) age = 0;

Nullability Warnings

C# 8+everyday

These appear the moment you enable nullable reference types

CodeMessageUsual cause and fix
CS8618non nullable property must contain a non null value when exiting the constructorMark it required, give it a default, set it in the constructor, or make the type nullable.
CS8602dereference of a possibly null referenceGuard with a null check, use ?. or ??, or narrow with is not null first.
CS8600converting null literal or a possible null value to a non nullable typeThe right hand side may be null. Declare the variable as nullable.
CS8603possible null reference returnThe method promises non null but a path returns null. Change the return type to T?.
CS8604possible null reference argumentYou are passing a maybe null value into a parameter that does not accept null.
CS8625cannot convert null literal to a non nullable reference typeYou passed null explicitly. Make the parameter nullable, or pass a real value.

The four ways to silence CS8618, in order of preference

public required string Name { get; init; }      // caller must supply it
public string Name { get; set; } = "";          // sensible default
public Order(string name) => Name = name;       // set in the constructor
public string Name { get; set; } = null!;       // last resort, a promise only

Async Errors

everyday

await and Task problems

CodeMessageUsual cause and fix
CS4032await can only be used within an async methodAdd async to the enclosing method and change its return type to Task.
CS4033await can only be used within an async methodSame, but in a void returning method. Return Task instead of void.
CS1998this async method lacks await operators and will run synchronouslyEither await something, or drop async and return Task.FromResult or Task.CompletedTask.
CS4014because this call is not awaited, execution continues before the call is completedYou forgot an await. If it is deliberate fire and forget, assign to a discard and handle the exceptions.
CS1061Task does not contain a definition for the result property you wantYou forgot to await, so you are looking at the Task rather than its value.
CS8419the body of an async iterator must contain a yieldAn async IAsyncEnumerable method with no yield return.

CS4014, and what to do when it is intentional

// warning CS4014
SendEmailAsync(user);

// fix, await it
await SendEmailAsync(user);

// deliberate fire and forget, never let it throw unobserved
_ = SendEmailAsync(user).ContinueWith(
    t => logger.LogError(t.Exception, "email failed"),
    TaskContinuationOptions.OnlyOnFaulted);

Runtime Exceptions You Will Meet

core

These compile fine and fail in production

ExceptionUsual cause and fix
NullReferenceExceptionSomething in the chain was null. Turn on nullable reference types, then use ?. and null checks rather than hunting at runtime.
InvalidOperationException: collection was modifiedYou added or removed inside a foreach over the same collection. Iterate a copy with ToList, or build a second list.
InvalidOperationException: sequence contains no elementsFirst, Single, Max or Average on an empty sequence. Use FirstOrDefault, or check Any first.
InvalidOperationException: sequence contains more than one elementSingle matched twice. Use First if duplicates are acceptable, otherwise fix the data or the filter.
KeyNotFoundExceptionReading a dictionary key that is not there. Use TryGetValue or GetValueOrDefault.
ObjectDisposedExceptionUsing a stream, connection or HttpClient after its using block ended, often because an async call outlived the scope.
IndexOutOfRangeExceptionAn off by one on an array. Remember the last index is Length minus 1, or use the ^1 index.
StackOverflowExceptionUnbounded recursion, or a property whose getter returns itself instead of the backing field.
FormatExceptionParse on text that is not a number or date, often a culture difference in the decimal separator. Use TryParse with an explicit culture.
TaskCanceledExceptionA token fired, or an HttpClient timeout elapsed. Check Timeout before blaming the token.
24

Java & Python to C#

translation tables for switchers

Java to C#

everyday

Syntax that looks familiar but is not identical

JavaC#Note
package com.app;namespace App;No folder to namespace requirement
import java.util.List;using System.Collections.Generic;Imports a namespace, not a type
final int x = 1;const int x = 1;readonly for run time, const for compile time
final class Foosealed class FooCannot be inherited
@OverrideoverrideA keyword, and it is mandatory
getName() / setName()Name { get; set; }Properties replace accessor pairs
StringstringLowercase alias for System.String
ArrayList<T>List<T>Same idea, different name
HashMap<K,V>Dictionary<K,V>TryGetValue instead of get returning null
stream().filter().map().Where().Select()LINQ, and it is lazy too
Optional<T>T? plus nullable contextNo wrapper object
throws IOExceptionnothingC# has no checked exceptions
interface with defaultinterface with defaultSame feature since C# 8
record Point(int x)record Point(int X)Nearly identical, C# adds with
var (Java 10)varSame, but C# has had it since 3.0
System.out.printlnConsole.WriteLine
instanceof Foo fis Foo fPattern matching, C# goes much further
synchronizedlockSame monitor idea
CompletableFuture<T>Task<T>With async and await on top

The three habits to unlearn

// 1. properties, not accessor pairs
public string Name { get; set; }

// 2. no checked exceptions, so document what you throw
/// <exception cref="IOException">the file is locked</exception>

// 3. value types exist, a struct is copied on assignment
struct Point { public int X, Y; }

Python to C#

everyday

The everyday equivalents

PythonC#Note
print(x)Console.WriteLine(x);Semicolons and braces, not indentation
f"hi {name}"$"hi {name}"Same idea, dollar instead of f
listList<T>Homogeneous, the element type is fixed
dictDictionary<K,V>TryGetValue instead of dict.get
setHashSet<T>
tuple(int, string) or recordNamed elements are supported
[x*2 for x in xs if x>0]xs.Where(x => x > 0).Select(x => x * 2)LINQ replaces comprehensions
len(xs)xs.Count or xs.LengthCount for collections, Length for arrays
xs[-1]xs[^1]Index from end
xs[1:4]xs[1..4]Range, end exclusive in both
def __init__(self)public Foo()Constructor, no explicit self
selfthisImplicit, not a parameter
@decorator[Attribute]Metadata, not a wrapper function
with open(p) as f:using var f = File.OpenRead(p);Deterministic disposal
yieldyield returnSame lazy generator idea
NonenullOnly reference types and T? can hold it
try / except / finallytry / catch / finallycatch, not except
async def / awaitasync Task / awaitVery close, no event loop to start
pip install xdotnet add package x
if __name__ == "__main__"top level statementsThe file that has them is the entry point

The mental shift

// Python: shape is discovered at run time
// C#: shape is declared, then checked at build time

var names = new List<string>();   // var still infers
names.Add("Ana");
names.Add(42);                    // error CS1503 at build time

First Week Gotchas

core

Things that surprise almost every newcomer

7 / 2            // 3, not 3.5, both operands are int
"a" == "a"       // true, string overloads ==
obj1 == obj2     // reference equality unless overloaded
struct copies    // assignment copies the whole value
foreach var      // the loop variable is per iteration since C# 5
switch fallthrough  // not allowed, every case needs break
array.Length     // arrays use Length, lists use Count

Naming conventions differ from both languages

PascalCase   types, methods, properties, constants, enum members
camelCase    locals and parameters
_camelCase   private fields
IPascalCase  interfaces
TPascalCase  generic type parameters
NameAsync    asynchronous methods

What C# Is

C# is a statically typed, object oriented language that compiles to intermediate language and runs on the .NET runtime. Anders Hejlsberg designed it at Microsoft, and version 1.0 shipped in 2002.

Static typing means the compiler knows the type of every expression before the program runs. Most mistakes surface at build time rather than in production.

The name is written C#, and spelled csharp in URLs, file paths, package names and tags, because the sharp sign has no place in a web address. Both refer to the same language, so a search for either lands you here.

The language has picked up functional features along the way: lambdas, LINQ, records, pattern matching, immutability by default in new types. It is no longer purely object oriented, and modern C# code often reads closer to F# than to Java.

Everything on this page runs on .NET, which is free, open source and cross platform. Windows, Linux and macOS all run the same binaries.

Where C# Runs

  • Web APIs and sites: ASP.NET Core, Minimal APIs, Blazor for browser side code compiled to WebAssembly.
  • Desktop: WPF, WinForms, WinUI 3 and Avalonia for cross platform desktop.
  • Mobile and multi platform: .NET MAUI targets Android, iOS, macOS and Windows from one project.
  • Games: Unity uses C# as its scripting language, which makes it one of the most widely used game development languages in the world.
  • Cloud and services: Azure Functions, AWS Lambda, worker services, gRPC and background processing.
  • Tooling: command line utilities, source generators, Roslyn analyzers and build tasks.

C# Syntax Basics

A C# file is a sequence of type declarations inside namespaces. Since C# 9 a simple program can skip all of that.

The Two Program Shapes

Top level statements let a console app be one line. The compiler generates the class and the Main method for you.

Console.WriteLine("Hello");

The explicit form is still what you get in libraries and larger apps:

namespace MyApp;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello");
    }
}

Both compile to the same thing. Pick top level statements for scripts, samples and small tools, and the explicit form when a file holds more than one type.

Statements, Blocks and Semicolons

Every statement ends with a semicolon. Blocks use braces. Whitespace is not significant, so indentation is style rather than syntax.

C# is case sensitive. Total and total are two different identifiers, and the convention is PascalCase for types and public members, camelCase for locals and parameters, and a leading underscore for private fields.

Value Types and Reference Types

This is the distinction that explains most surprising behaviour in C#.

Value types (int, double, bool, char, struct, enum, record struct) hold their data directly. Assigning one copies the data. They usually live on the stack or inline inside another object.

Reference types (class, record, interface, delegate, string, array) hold a reference to data on the heap. Assigning one copies the reference, so both variables now point at the same object.

var a = new List<int> { 1 };
var b = a;
b.Add(2);
Console.WriteLine(a.Count);   // 2, same list

int x = 1;
int y = x;
y++;
Console.WriteLine(x);         // 1, separate copies

Strings are the exception that trips people up: they are reference types, but they are immutable, so they behave like values. Every operation that looks like a mutation returns a new string.


Nullability in C#

Null reference exceptions were the single most common runtime failure in C# for two decades. C# 8 introduced nullable reference types to move that problem to compile time.

With <Nullable>enable</Nullable> in the project file, string means "never null" and string? means "may be null". The compiler warns when you dereference something it cannot prove is non null.

This is a compile time analysis only. Nothing is checked at runtime, and a value that arrives from JSON, a database or an older library can still be null despite the annotation. Treat the warnings as a strong hint, not a guarantee.

The Null Operators

OperatorMeaningExample
?.member access, short circuits to nulluser?.Name
?[]indexer access, short circuits to nullitems?[0]
??fallback when the left side is nullname ?? "guest"
??=assign only when currently nullcache ??= Load()
!suppress the warning, no runtime checkvalue!.Length
is nullnull test that ignores operator overloadsif (x is null)

Prefer is null and is not null over == null. A type can overload ==, and the pattern form always does the plain reference check.


Collections and LINQ

Almost every C# program manipulates sequences, and LINQ is the query language built into the framework for doing it.

Choosing a Collection

List<T> is the default. Reach for Dictionary<TKey, TValue> the moment you find yourself scanning a list to find something by a key, and for HashSet<T> when you only care whether an item is present.

The cost difference is real. Looking up an item in a list of 10,000 entries scans on average 5,000 of them. The same lookup in a dictionary hashes the key once.

Deferred Execution

LINQ operators do not run when you write them. They build a query that runs when you enumerate it.

var query = people.Where(p => p.IsActive);   // nothing happened yet
foreach (var p in query) { }                 // runs here
var list = query.ToList();                   // runs again

Two consequences follow. First, enumerating twice does the work twice, so materialise with ToList() when you need the results more than once. Second, if the source changes between building the query and running it, the results reflect the new state.

IEnumerable versus IQueryable

IEnumerable<T> runs in memory using delegates. IQueryable<T> builds an expression tree that a provider such as Entity Framework Core translates into SQL.

The practical rule: call ToList() as late as possible when querying a database, so filtering happens in SQL rather than after pulling every row into memory.


Async and Await Explained

An async method returns a Task that represents work in progress. await suspends the method, returns control to the caller, and resumes when the task completes.

Nothing here creates a thread. For an HTTP call or a database query, the thread is returned to the pool while the network does its work, which is why a small pool can serve thousands of concurrent requests.

The Rules Worth Memorising

  • Async all the way down. Calling .Result or .Wait() on a task blocks the thread and can deadlock in contexts that capture a synchronisation context.
  • Return Task, not void. An async void method cannot be awaited and its exceptions crash the process. Event handlers are the only exception.
  • Start before you await. To run two calls concurrently, start both tasks and then await them, or use Task.WhenAll.
  • Pass the CancellationToken. A token that stops at the first layer is a token that does nothing.
  • Do not await inside a lock. Use SemaphoreSlim when the critical section contains asynchronous work.

Awaiting sequentially in a loop is a common cause of slow endpoints. Two calls that each take 200 ms take 400 ms in sequence and roughly 200 ms with Task.WhenAll.


Records, Classes and Structs

C# now offers four ways to declare a type, and the choice is about semantics rather than speed.

Use a class for anything with identity and behaviour: services, entities, controllers, anything you would describe as "the" thing rather than "a" value.

Use a record for data that is defined by its contents: DTOs, API contracts, messages, query results, configuration. You get value equality, a readable ToString, deconstruction and the with expression at no cost.

Use a struct for small immutable values that are created in large numbers, such as coordinates, identifiers and money. The rough guideline is 16 bytes or less, because larger structs are expensive to copy.

Use a record struct when you want both value semantics and value equality.

The with Expression

var original = new Person("Ana", "Pop");
var updated = original with { LastName = "Ionescu" };

This creates a copy with one property changed. The original is untouched, which is what makes records safe to share across threads and cache without defensive copying.


Pattern Matching

Pattern matching started as a nicer if (x is Type t) and has grown into a small sublanguage. It is now the clearest way to express branching logic over shapes of data.

string Describe(object o) => o switch
{
    null                          => "nothing",
    int n when n < 0              => "negative number",
    int n                         => $"number {n}",
    string { Length: 0 }          => "empty string",
    string s                      => $"text of {s.Length}",
    int[] { Length: > 0 } arr     => $"array starting at {arr[0]}",
    _                             => "something else"
};

The compiler checks exhaustiveness on switch expressions. If no arm matches at runtime you get a SwitchExpressionException, so a discard arm is usually worth adding.


Common C# Mistakes

  • Concatenating strings in a loop. Each += allocates a new string. Use StringBuilder past a handful of iterations.
  • Comparing floating point numbers with ==. Use a tolerance, or use decimal for money.
  • Catching Exception and swallowing it. An empty catch block hides bugs. Catch the specific type, or let it propagate.
  • Using throw ex instead of throw. The first resets the stack trace and destroys the evidence.
  • Creating a new HttpClient per request. Sockets leak. Use IHttpClientFactory or a single shared static instance.
  • Modifying a collection while iterating it. Build a second list, or iterate a snapshot with ToList().
  • Calling Count() on an IEnumerable to test emptiness. Any() stops at the first element.
  • Forgetting to dispose. Streams, connections and HTTP responses need using.
  • Overriding Equals without GetHashCode. Dictionary and HashSet lookups then fail silently.
  • Public mutable fields. Use properties, so you can add validation later without a breaking change.

C# Naming Conventions

ElementConventionExample
Class, record, structPascalCaseOrderService
InterfacePascalCase with an I prefixIOrderRepository
Method, property, eventPascalCaseCalculateTotal
Local variable, parametercamelCaseorderTotal
Private fieldunderscore then camelCase_repository
ConstantPascalCaseMaxRetries
Type parameterT, or T then a nameTKey, TResult
Async methodPascalCase with an Async suffixGetOrderAsync
Enum type and membersPascalCase, singular unless flagsOrderStatus.Paid

These come from the Microsoft coding conventions, and every mainstream analyzer enforces them. Add an .editorconfig to your solution and the IDE will apply them for you.


How to Practise C#

Reading a reference page is not the same as writing code. A few things that work:

  • Install the .NET SDK and run dotnet new console. The whole loop from empty folder to running program takes under a minute.
  • Use dotnet watch run so the app restarts on every save.
  • Write small programs against real data. Parse a CSV, call a public API, count words in a text file.
  • Turn on <TreatWarningsAsErrors>true</TreatWarningsAsErrors> early. The compiler is a better teacher than a tutorial.
  • Read the source. The .NET runtime and libraries are on GitHub, and the standard library is written in idiomatic C#.

When something on this page is unclear, the official C# documentation is thorough and kept current with each release. For the other half of the job, our SQL cheat sheet covers the queries your C# code will be sending to the database, and the regex cheat sheet covers the patterns you will pass to System.Text.RegularExpressions.


C# Questions People Actually Ask

What is C# used for?

C# is a statically typed, object oriented language that runs on .NET. It is used for web APIs and sites with ASP.NET Core, desktop apps with WPF and WinUI, cross platform apps with .NET MAUI, games with Unity, cloud functions, background services and command line tools. The same code runs on Windows, Linux and macOS.

What is the difference between a class, a struct and a record in C#?

A class is a reference type with reference equality. A struct is a value type that is copied on assignment and compared field by field. A record is a reference type by default that adds value based equality, a generated ToString, deconstruction and non destructive copying with the with expression. Use record struct when you want value semantics and value equality together.

When should I use async and await in C#?

Use async and await for input and output bound work such as HTTP calls, database queries and file access, so the calling thread is released while the operation is pending. For CPU bound work use Task.Run or the Parallel class instead. Return Task or Task<T> rather than void, except in event handlers, and never block on a task with .Result or .Wait().

What does the question mark mean in C# types?

On a value type, int? means Nullable<int>, a value that can also hold null. On a reference type, string? is a nullable reference annotation that tells the compiler null is expected there. The ?. operator short circuits member access when the left side is null, ?? supplies a fallback value, and ??= assigns only when the target is currently null.

What is LINQ in C#?

LINQ is a set of query operators built into .NET for filtering, projecting, ordering, grouping and aggregating sequences. It comes in method syntax, such as items.Where(x => x.IsActive).Select(x => x.Name), and query syntax that reads like SQL. LINQ queries are lazy, so nothing runs until you enumerate the result or call ToList, ToArray, Count or First.

Which C# version should I target?

The language version follows the target framework. Targeting net8.0 gives you C# 12 with primary constructors and collection expressions, and net9.0 gives you C# 13. For new projects pick the latest long term support release of .NET, and set Nullable and ImplicitUsings to enable in the project file so you start with modern defaults.