Engineering Publication · Syed Omar Ibn Feroj← Back to portfolio
C#
Engineering Notes · Open Knowledge Repository
C# — Engineering
Notes for Modern .NET
A working reference for the C# worth writing on modern .NET — value vs reference semantics, records and immutability, LINQ, and async/await without the deadlocks.
28
Chapters
.NET 8
Baseline
Living
Document
Free
License
Ch. 01
Build & Runtime
The .NET CLI, the project file, and what 'managed' actually means.
1.1 — One CLI for everything
Modern C# is built and run through the dotnet CLI. The runtime is managed: a JIT/AOT compiler turns IL into native code and a tracing GC reclaims memory — you allocate, you rarely free.
Shell
$ dotnet new console -o app $ dotnet run $ dotnet build -c Release $ dotnet test
Enable
<Nullable>enable</Nullable> and <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in the .csproj on day one. Retro-fitting nullable annotations into a large codebase later is far more painful.Ch. 02
Types & Semantics
Value types vs reference types — the distinction everything else hangs on.
2.1 — Where it lives and how it copies
C#
int a = 1; int b = a; b++; // a stays 1 — value copy var p1 = new Point(); var p2 = p1; p2.X = 9; // p1.X is 9 too — same object
Value types (
struct, int, enum, bool) copy on assignment and live on the stack/inline. Reference types (class, record class, arrays, strings) copy the reference, not the object. Almost every "why did my change affect the other variable" question reduces to this.Ch. 03
Variables
var, const, readonly, and target-typed new.
3.1 — var is static typing with inference
C#
var list = new List<int>(); // type is List<int>, fixed at compile time List<int> l2 = new(); // target-typed new (C# 9+) const int Max = 100; // compile-time constant readonly int _id; // set once, in ctor
var is not dynamic — the type is decided at compile time and never changes. Use it when the type is obvious from the right-hand side; spell the type out when it aids readability.Ch. 04
Strings
Immutability, interpolation, and the StringBuilder rule.
4.1 — Strings are immutable
C#
string name = "omar"; string msg = $"hi {name}, x{n:D3}"; // interpolation var sb = new StringBuilder(); foreach (var s in parts) sb.Append(s); // O(n), not O(n²)
Every
+= on a string allocates a new string. Building a string in a loop with += is O(n²) garbage. Use StringBuilder or string.Join for anything iterative.Ch. 05
Operators & Null
The null-handling operators that remove most NullReferenceExceptions.
5.1 — ?. ?? ??=
C#
int? len = user?.Name?.Length; // null if any link is null string n = name ?? "anonymous"; // null-coalescing cache ??= new(); // assign if currently null var first = list?[0]; // null-conditional index
The null-conditional chain short-circuits the moment any segment is null — there's no partial evaluation or exception. Combined with nullable reference types (Ch. 21) this eliminates most NREs at compile time.
Ch. 06
Control Flow
switch expressions and the loop variable capture trap.
6.1 — switch expression
C#
string label = code switch { 200 => "ok", >= 400 and < 500 => "client error", _ => "other" };
Pre-C#5, a lambda inside a
foreach captured the shared loop variable. Modern C# captures per-iteration for foreach — but a classic for (int i…) still captures the single i. Copy to a local if you close over a for index.Ch. 07
Methods
Overloading, optional/named args, ref/out/in, expression bodies.
7.1 — Parameter modifiers
C#
void Swap(ref int a, ref int b); // modify caller's vars bool TryParse(string s, out int v); // extra return channel int Hash(in BigStruct s); // pass by readonly ref int Square(int x) => x * x; // expression body
Ch. 08
Classes
Constructors, primary constructors, static members.
8.1 — Primary constructors (C# 12)
C#
class Account(string owner) { private decimal _balance; public string Owner { get; } = owner; public void Deposit(decimal n) => _balance += n; }
Use
decimal, never double, for money — double is binary floating point and 0.1 + 0.2 != 0.3. decimal is base-10 and exact for currency.Ch. 09
Properties
Auto-properties, init-only, computed, and the backing field.
9.1 — get/set/init
C#
public string Name { get; set; } public string Id { get; init; } // set only during init public int Area => Width * Height; // computed, read-only
init accessors give you immutability with object-initializer ergonomics: settable in new T { Id = ... } but read-only thereafter. Prefer it over public set for identity/config fields.Ch. 10
Structs
Value types — fast, copied, and easy to misuse.
10.1 — readonly struct and the mutation trap
C#
readonly struct Money(decimal amount) { public decimal Amount { get; } = amount; }
Mutating a struct stored in a
List<T> or returned from a property changes a copy, silently doing nothing. Make structs readonly and small (≤16 bytes); for anything that "has identity" use a class.Ch. 11
Records
Value-equality reference types — the default for data.
11.1 — record and with-expressions
C#
public record User(string Id, string Name); var u1 = new User("1", "Omar"); var u2 = u1 with { Name = "Rupam" }; // non-destructive copy u1 == new User("1", "Omar"); // true — value equality
Records give you value equality,
ToString, deconstruction and with for free. Use them for DTOs, domain values and anything immutable — they remove a class of boilerplate and equality bugs.Ch. 12
Interfaces
Contracts, default implementations, explicit implementation.
12.1 — Program to the interface
C#
interface IRepository<T> { Task<T?> GetAsync(string id); Task SaveAsync(T item); }
Depend on interfaces at boundaries (constructors, method params) so implementations are swappable and tests can substitute fakes. This is the whole basis of dependency injection in .NET.
Ch. 13
Inheritance
virtual/override/sealed, abstract, and base calls.
13.1 — new vs override
C#
class Base { public virtual void F() {} } class Derived : Base { public override void F() {} }
new (method hiding) is not override — it picks the method by the static type, so a base-typed reference calls the base method even on a derived object. Almost always a bug; prefer override, and sealed classes you don't intend to extend.Ch. 14
Generics
Type parameters, constraints, variance.
14.1 — Constraints make generics useful
C#
T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b; class Cache<T> where T : class, new() { /* ... */ }
out/in variance: IEnumerable<out T> is covariant (an IEnumerable<string> is an IEnumerable<object>), Action<in T> contravariant. Only interfaces and delegates can be variant, never classes.Ch. 15
Delegates & Events
Typed function pointers and the publish/subscribe pattern.
15.1 — Func/Action and event unsubscription
C#
Func<int, int, int> add = (a, b) => a + b; Action<string> log = Console.WriteLine; bus.OnJob += Handler; bus.OnJob -= Handler; // MUST unsubscribe
A subscriber that never unsubscribes keeps the publisher's reference to it alive forever — a managed memory leak. Long-lived publishers + short-lived subscribers is the #1 leak source in .NET; always pair
+= with -= (or use weak events).Ch. 16
Lambdas
Closures, expression vs statement bodies, capture cost.
16.1 — Captures allocate
C#
int factor = 3; Func<int,int> f = x => x * factor; // captures factor → heap closure Func<int,int> g = static x => x * 3; // no capture, no alloc
A lambda that captures a variable allocates a closure object. In a hot path, mark non-capturing lambdas
static (C# 9+) so the compiler guarantees zero allocation.Ch. 17
Collections
Pick by access pattern; know the O() you're paying.
17.1 — The decision table
| Need | Type |
|---|---|
| Default list | List<T> |
| Key→value O(1) | Dictionary<K,V> |
| Uniqueness / set ops | HashSet<T> |
| FIFO / LIFO | Queue<T> / Stack<T> |
| Immutable / read-only API | ImmutableArray<T> / IReadOnlyList<T> |
| Thread-safe | ConcurrentDictionary<K,V> |
Expose
IReadOnlyList<T>/IEnumerable<T> from APIs, not List<T> — returning the concrete list lets callers mutate your internal state.Ch. 18
LINQ
Declarative queries — lazy, composable, and occasionally a trap.
18.1 — Deferred execution
C#
var q = nums.Where(n => n > 2).Select(n => n * n); // nothing has run yet var list = q.ToList(); // NOW it executes
LINQ is lazy: the query runs each time you enumerate it, not when you define it. Enumerating a query twice does the work twice (and re-hits the database for EF). Materialise with
ToList()/ToArray() when you'll iterate more than once.Ch. 19
Tuples & Deconstruction
Lightweight multiple returns.
19.1 — Named tuples
C#
(int min, int max) Range(int[] a) => (a.Min(), a.Max()); var (lo, hi) = Range(data); // deconstruct
Tuples are great for private/local multiple returns. For a public API that will live a while, a
record is better — it's named, discoverable and versionable.Ch. 20
Pattern Matching
Type, property, list and relational patterns.
20.1 — Patterns everywhere
C#
string Describe(object o) => o switch { int n when n < 0 => "negative", string { Length: > 0 } s => $"text {s}", [0, .., 9] => "0…9 list", null => "null", _ => "other" };
Ch. 21
Nullable References
Compile-time null safety — turn it on.
21.1 — The annotations and the warnings
C#
string always; // must not be null — warned if it can be string? maybe; // may be null — must check before use var len = maybe!.Length; // ! = "trust me" (use rarely)
The null-forgiving operator
! silences the compiler without changing runtime behaviour — it's a promise, not a check. Every ! is tech debt; if you can't prove non-null, handle the null instead.Ch. 22
Exceptions
Catch specific, filter, and never swallow.
22.1 — Exception filters
C#
try { Risky(); } catch (HttpRequestException e) when (e.StatusCode == 429) { Backoff(); } catch (Exception e) { _logger.LogError(e, "failed"); throw; } // rethrow, keep stack
throw; rethrows preserving the original stack trace; throw e; resets it and hides the real origin. Never catch (Exception) {} — a swallowed exception is a bug you'll debug blind.Ch. 23
IDisposable
Deterministic cleanup of unmanaged resources.
23.1 — using ensures Dispose runs
C#
using var conn = new SqlConnection(cs); // disposed at scope end await using var stream = File.OpenRead(p); // async dispose
The GC does not call
Dispose for you (only finalizers, non-deterministically). Anything holding a file handle, socket, or DB connection must be wrapped in using or you'll exhaust the resource under load.Ch. 24
async / await
The deadlocks and the leaks, and how to avoid both.
24.1 — Async all the way; no .Result
C#
async Task<User> LoadAsync(string id) { using var r = await _http.GetAsync($"/u/{id}"); r.EnsureSuccessStatusCode(); return await r.Content.ReadFromJsonAsync<User>(); }
Calling
.Result or .Wait() on a Task can deadlock (classic in UI/ASP.NET sync contexts) and always blocks a thread. Make the whole call chain async. Use Task.WhenAll for independent work; never async void except for event handlers.Ch. 25
Spans & Memory
Zero-allocation slicing for hot paths.
25.1 — Span<T> / ReadOnlySpan<T>
C#
ReadOnlySpan<char> s = "2026-05-17"; var year = s[..4]; // slice — no substring allocation int.Parse(s.Slice(5, 2)); // parse without allocating
Span<T> slices arrays/strings/stack memory with no copy — the fastest way to parse hot paths. Constraint: it's a ref struct, so it can't be a field, boxed, or used across an await.Ch. 26
Attributes & Reflection
Metadata, and why reflection is a last resort in hot code.
26.1 — Declare and read
C#
[Obsolete("Use V2")] void OldApi() {} var props = typeof(User).GetProperties(); // reflection
Reflection is flexible but slow and AOT-hostile. For repeated work, cache the
MemberInfo, compile an expression/delegate once, or prefer source generators — reflection in a per-request path is a measurable cost.Ch. 27
Common Pitfalls
The bugs that recur across every C# codebase.
27.1 — The recurring list
async void(un-awaitable, exceptions crash the process).Result/.Wait()on a Task (deadlock + blocked thread)- Event handlers never unsubscribed (managed leak)
- Mutating a struct stored in a collection (silent no-op)
- Multiple enumeration of a deferred LINQ query
throw e;instead ofthrow;(lost stack trace)doublefor money instead ofdecimal- String building with
+=in a loop (O(n²))
Ch. 28
Modern Guidelines
The defaults that keep modern C# clean.
28.1 — The short list
- Nullable reference types on; warnings as errors
recordfor data,readonly structfor small values,classfor behaviour- Async all the way; never block on a Task
- Expose interfaces and read-only collection types at boundaries
using/await usingfor everyIDisposable- Materialise LINQ before iterating twice
- Prefer immutability (
init, records) — mutate only with reason
REF
C# Cheatsheet
Value vs reference — the table that answers most questions.
Value vs reference
| Aspect | Value type (struct) | Reference type (class) |
|---|---|---|
| Assignment | copies the value | copies the reference |
| Default | zeroed instance | null |
| Equality (default) | field-by-field | reference identity |
| Lives | inline / stack | heap |
| Examples | int, bool, enum, struct, record struct | class, record, string, array |