Skip to content
Free public beta until December 4, 2026: every course is open, no card needed. After that, the first chapter of each course stays free and full access is a one-time $39 ($19 for up to 100 founding members chosen for helpful feedback). Your progress is kept. How the beta works

Language & library reference

Concept clusters →

Every named result in this course, grouped by chapter. Each one links back to where it's derived.

Set up and run C#

Rule

Build, then run

also known as: dotnet build, dotnet run, what the Run button does

Formal. dotnet build restores packages if needed, then compiles the project's source files into an assembly under bin/<Configuration>/<TargetFramework>/. dotnet run builds first when anything changed, then starts the built program. A file-based app (dotnet run app.cs) does the same with a project the SDK creates for you behind the scenes. An IDE's Build and Run commands call these same SDK steps.

In plain English. Building turns your text into a program file. Running starts that program file. dotnet run does both, in that order, and skips the build when nothing changed. Your IDE's green Run button is dotnet run wearing a nicer coat.

Example. Edit Program.cs, then dotnet run: you see a build step, then your output. Run it again without editing: the build step is skipped and the output appears at once.

Why this rule exists. When "it runs" and "it builds" feel like one action, a stale build or a build error hiding behind an old program is baffling. Knowing they are two steps tells you where to look.

Rule

Source to IL to machine code

also known as: two-stage compilation, JIT compilation, managed execution

Formal. The C# compiler translates source code into Common Intermediate Language (IL) plus metadata describing every type, stored in an assembly (.dll). At run time the .NET runtime loads the assembly and its just-in-time (JIT) compiler translates each method's IL into machine code for the current processor the first time that method is called, then reuses that machine code. Publishing with Native AOT moves the second translation to build time.

In plain English. Your code is translated twice. First into a universal in-between language that any computer running .NET can read, then, while the program runs, into the exact instructions your processor understands. The second step happens on demand, one method at a time.

Example. The same app.dll runs on an Intel Windows laptop and an ARM Mac, because each machine's runtime does its own final translation.

Why this rule exists. It explains why a .NET program needs a runtime installed (or bundled), why the first call to a method is slightly slower than the next, and why one build can run on several kinds of processor.

Values, types, and operators

Rule

Definite assignment

also known as: use of unassigned local variable, CS0165

Formal. A local variable must be definitely assigned on every possible path before its value is read, or the program does not compile (error CS0165). Variables that are not locals (you'll meet them later in the course) start at their type's default value instead (0, false, and so on).

In plain English. C# refuses to let you read a local variable that might not have been given a value yet. It checks every route through your code, not just the one you expect to happen.

Example. int total; if (ok) total = 5; Console.WriteLine(total); is error CS0165, because when ok is false nothing ever set total.

Why this rule exists. Reading leftover garbage from memory is a classic bug in older languages. C# turns it into a compile error you fix in seconds.

Rule

Widening is implicit, narrowing is explicit

also known as: implicit and explicit numeric conversions, casts

Formal. C# converts a value to another numeric type automatically only when every value of the source type fits in the target type (int → long, int → double). A conversion that could lose information (double → int, long → int) requires an explicit cast, (int)x. Casting from floating-point to an integer type truncates toward zero. Text never converts to a number implicitly; it must be parsed.

In plain English. Pouring a small cup into a big jug is always safe, so C# does it for you. Pouring a big jug into a small cup might spill, so C# makes you say "I know, do it anyway" by writing a cast.

Example. long big = 42; compiles. int small = 3.9; is error CS0266; int small = (int)3.9; compiles and stores 3.

Why this rule exists. Silent data loss is one of the oldest sources of bugs. Making lossy conversions visible in the source keeps them deliberate.

Rule

Integer division truncates

also known as: int / int, remainder operator %

Formal. When both operands of / are integers, the result is an integer: the exact quotient truncated toward zero. % gives the matching remainder, whose sign follows the left operand, so (a / b) * b + a % b == a. Dividing an integer by zero throws DivideByZeroException; floating-point division by zero gives infinity or NaN instead.

In plain English. Divide two whole numbers and C# gives you a whole number, dropping whatever comes after the decimal point. It does not round. If you want the fraction, make at least one of the numbers a decimal number first.

Example. 7 / 2 is 3, 7 % 2 is 1, -7 / 2 is -3, 7 / 2.0 is 3.5.

Why this rule exists. Averages that come out too small and percentages that come out as zero are nearly always this rule.

Rule

Short-circuit evaluation

also known as: && and ||, conditional logical operators

Formal. a && b evaluates b only when a is true; a || b evaluates b only when a is false. The null-coalescing operator a ?? b evaluates b only when a is null, and a?.M() skips the member access when a is null. The non-short-circuit & and | on bool always evaluate both operands.

In plain English. C# stops as soon as it knows the answer. If the first half of an "and" is already false, the whole thing is false, so the second half never runs.

Example. if (name != null && name.Length > 0) is safe: when name is null, name.Length is never touched.

Why this rule exists. It makes guard conditions safe to write in one line, and it means code on the right-hand side is not guaranteed to run.

Rule

Precedence is not evaluation order

also known as: operator precedence, associativity, left-to-right evaluation

Formal. Precedence and associativity determine how an expression is grouped: a + b * c means a + (b * c), and a - b - c means (a - b) - c. Independently, C# evaluates operands from left to right, so in F() + G() * H() the calls happen in the order F, G, H, even though the multiplication is performed before the addition. Assignment and ?? group right to left.

In plain English. Precedence tells you where the invisible parentheses go. It doesn't tell you what happens first. C# still reads the pieces left to right, then combines them according to the parentheses.

Example. With int i = 1;, the expression i + i++ * 10 is 1 + (1 * 10), which is 11, and i ends up 2.

Why this rule exists. Code that changes a variable and reads it in the same expression surprises people who think precedence means "done first". Writing one change per statement avoids the question entirely.

Control flow

Rule

First match wins

also known as: switch expression arms, exhaustiveness

Formal. A switch expression tests its arms in the order written and produces the value of the first arm whose pattern (and optional when guard) matches. An arm that can never be reached because earlier arms already cover it is a compile error. If the arms don't cover every possible input the compiler warns (CS8509), and an unmatched value throws SwitchExpressionException at run time. A discard arm _ => covers everything that is left.

In plain English. C# checks the choices top to bottom and takes the first one that fits. Put the specific cases first and the catch-all last, and the compiler will tell you if you forgot a case.

Example. score switch { >= 90 => "A", >= 80 => "B", _ => "C" } gives "A" for 95, because >= 90 is tried before >= 80.

Why this rule exists. Order-dependent matching is what lets arms overlap safely, and the coverage check catches the forgotten case before a user does.

Methods

Rule

Arguments are passed by value

also known as: pass by value, ref, out, in

Formal. By default a method receives a copy of each argument's value. Reassigning a parameter inside the method never changes the caller's variable. Passing with ref or out gives the method an alias for the caller's variable itself; in passes an alias the method may only read. (When the value is a reference to an object, the copy still refers to the same object; that case is taught with classes.)

In plain English. A method gets its own copy of what you hand it. It can scribble on its copy all day and your original stays the same, unless you deliberately lend it the original with ref or out.

Example. void Bump(int n) { n++; } then int x = 1; Bump(x); leaves x as 1. With void Bump(ref int n) and Bump(ref x), x becomes 2.

Why this rule exists. Knowing a call can't change your variables unless the call site says ref or out makes code much easier to reason about.

Rule

How C# picks an overload

also known as: overload resolution, best overload, ambiguous call

Formal. For a call, the compiler keeps only the overloads the arguments can be passed to: the right number of arguments (after defaults and params), each convertible implicitly to its parameter's type. From those it picks the one whose parameter types fit best in every position: an exact type match beats a conversion, and a widening to a closer type (char to int) beats one to a farther type (char to double). No applicable overload is a compile error; two that tie is CS0121, an ambiguous call. Return types never take part.

In plain English. C# throws out every version of the method that can't take your values, then picks the version that fits them most closely. A version that takes your value exactly as it is wins over one that would have to convert it. If two versions fit equally well, C# won't guess, and the program doesn't build.

Example. With Show(int) and Show(double): Show(5) calls the int one, Show(5.0) and Show(5L) call the double one, and Show('A') calls the int one with 65.

Why this rule exists. Overloads let one well-chosen name cover several kinds of input, and a fixed, compile-time rule means the choice is predictable and checked before the program ever runs.

Rule

Every recursive method needs a base case it always reaches

also known as: base case, recursive case, termination

Formal. A recursive method has at least one base case, an input it answers directly without calling itself, and one or more recursive cases that call the method on an input strictly closer to a base case. Each active call occupies a frame on the call stack until it returns, so if some input never reaches a base case, frames pile up until the thread's stack is exhausted and .NET terminates the process with a stack overflow, which no catch can intercept.

In plain English. A method that calls itself must have a stopping point: a case simple enough to answer straight away. Every time it calls itself, it must hand over a smaller problem, one step nearer that stopping point. Miss either part and the calls never end, and the program crashes in a way nothing can rescue.

Example. In int Factorial(int n) => n <= 1 ? 1 : n * Factorial(n - 1); the base case is n <= 1, and n - 1 moves every call toward it. Factorial(n + 1) in its place would never stop.

Why this rule exists. Recursion only works because each call waits for a smaller one to finish. The base case is where the waiting ends and the answers start flowing back up.

Strings and text

Rule

Strings are immutable

also known as: string immutability, StringBuilder

Formal. A string object's characters never change after it is created. Every method that appears to modify a string (ToUpper, Replace, Trim, +) returns a new string and leaves the original untouched. StringBuilder is the mutable buffer for building text in many steps.

In plain English. You can't edit a string, only make a new one. If you don't keep the new one, the change is lost.

Example. string s = "hi"; s.ToUpper(); Console.WriteLine(s); prints hi. s = s.ToUpper(); prints HI.

Why this rule exists. Immutable strings can be shared safely between variables and threads, and a string used as a dictionary key can't change behind the dictionary's back.

Arrays and collections

Rule

Don't change a collection's membership while enumerating it

also known as: collection was modified, InvalidOperationException in foreach

Formal. A List<T>'s enumerator is invalidated by any change to the list while a foreach loop is running over it, including Add, Remove, and even replacing an item with list[i] = x; the loop's next step throws InvalidOperationException ("Collection was modified"). Dictionary<TKey,TValue> and HashSet<T> are more permissive on modern .NET (since .NET Core 3.0): Remove and overwriting the value of an existing key are allowed during enumeration, but Add (and any change that adds a new key) still throws.

In plain English. A foreach loop is walking through a collection. If you add or remove items while it walks, it can lose its place, so .NET stops the program instead of guessing. A list is strict about this: change it in any way and the loop fails. A dictionary or set lets you remove as you go, but adding new entries still fails.

Example. foreach (var n in numbers) if (n < 0) numbers.Remove(n); throws on a List<int>. numbers.RemoveAll(...), a loop over a copy ([.. numbers]), or collecting the changes and applying them after the loop all work.

Why this rule exists. Failing loudly at the first bad step is far better than silently skipping or repeating items.

Classes and objects

Rule

Value types copy data, reference types copy references

also known as: value semantics, reference semantics

Formal. Assigning, passing, or returning a value-type value (a number, bool, a struct, an enum, a tuple) copies the data, so the two variables are independent afterwards. Assigning a reference-type value (a class instance, an array, a string, a delegate) copies only the reference; both variables then refer to the same object, and a change made through one is visible through the other. string is a reference type but immutable, so this sharing is never observable for text.

In plain English. A value type is like handing someone a photocopy: they can scribble on it and your page is untouched. A reference type is like handing someone the address of a house: you both now visit the same house, so if they repaint it, you see the new paint.

Example. int a = 1; int b = a; b = 2; leaves a as 1. With var p = new Player(); var q = p; q.Score = 10;, p.Score is also 10.

Why this rule exists. Small values are cheap to copy and safer when copied; large or shared things (a document, a bank account) need one identity that everyone refers to.

Rule

Arguments are passed by value

also known as: pass by value, ref, out, in

Formal. By default a method receives a copy of each argument's value. Reassigning a parameter inside the method never changes the caller's variable. Passing with ref or out gives the method an alias for the caller's variable itself; in passes an alias the method may only read. (When the value is a reference to an object, the copy still refers to the same object; that case is taught with classes.)

In plain English. A method gets its own copy of what you hand it. It can scribble on its copy all day and your original stays the same, unless you deliberately lend it the original with ref or out.

Example. void Bump(int n) { n++; } then int x = 1; Bump(x); leaves x as 1. With void Bump(ref int n) and Bump(ref x), x becomes 2.

Why this rule exists. Knowing a call can't change your variables unless the call site says ref or out makes code much easier to reason about.

Inheritance and polymorphism

Rule

Override replaces, new hides

also known as: virtual dispatch, method hiding, CS0114

Formal. When a base method is virtual and a derived class declares it override, a call through any reference (base-typed or derived-typed) runs the override chosen by the object's run-time type. A derived member declared new (or with neither keyword, which draws warning CS0114) hides the base member instead: the call is chosen by the variable's compile-time type, so a base-typed reference still runs the base version.

In plain English. override means "whenever anyone asks this object, do it my way". new means "only when someone knows they're talking to the derived type". Most of the time you want override.

Example. With Animal a = new Dog();, a.Speak() prints the Dog's sound if Speak is virtual/override, and the Animal's sound if the Dog's version is new.

Why this rule exists. Polymorphism depends on calls reaching the real object's behavior; hiding exists for rare versioning situations and should be a deliberate choice.

Rule

What == means depends on the kind of type

also known as: reference equality, value equality, Equals

Formal. For classes, == and the default Equals compare references (the same object) unless the class overloads them. string overloads == to compare contents ordinally. Records (class and struct) get compiler-generated value equality: equal when every member is equal, comparing reference-type members by their own equality. A plain struct has a field-by-field Equals but no == operator unless it declares one.

In plain English. For most objects, "equal" means "the very same object", not "the same data". Strings and records are the exceptions you use most: two strings with the same letters are equal, and two records holding the same values are equal.

Example. new Point(1, 2) == new Point(1, 2) is true for record Point(int X, int Y) and false for an ordinary class Point.

Why this rule exists. Identity matters for things like accounts and windows; value matters for data like coordinates and money. C# makes you pick by picking the kind of type.

Interfaces

Rule

Implement every member, publicly

also known as: interface implementation, CS0535, CS0737

Formal. A class that lists an interface in its base list must provide an accessible implementation of every abstract member the interface declares, with a matching name, return type and parameter types. A missing member is error CS0535; an implicit implementation that isn't public is error CS0737. A call through an interface-typed reference runs the implementation of the object's run-time class.

In plain English. Signing an interface is signing every line of it. Leave one method out, or hide it with private, and the program doesn't build. Once it builds, any code holding the object as that interface gets the object's own version of each method.

Example. class Circle : IShape that has Name but no Area() fails with CS0535, "'Circle' does not implement interface member 'IShape.Area()'". Adding public double Area() => ... fixes it.

Why this rule exists. Code written against the interface calls its members without knowing the class. The compiler checks every implementing class up front, so such a call can never find a member missing at run time.

Records, structs, and modern types

Rule

An enum variable can hold any value of its underlying type

also known as: enum range, undefined enum value

Formal. An enum type is a set of named constants over an underlying integral type (int unless you say otherwise). Any value of that underlying type converts to the enum with an explicit cast, and Enum.Parse/Enum.TryParse accept numeric text, so an enum variable may hold a value that matches no named member. The only implicit conversion from a number is the literal 0.

In plain English. The names are labels stuck on a few numbers. The variable underneath is still a plain whole number, and a cast or a parsed "42" can put a number there that has no label at all.

Example. With enum Size { Small, Medium, Large }, Size s = (Size)7; builds and runs, and Console.WriteLine(s) prints 7.

Why this rule exists. Enums have to round-trip through files, databases and other programs as plain numbers, and C# doesn't check every conversion at run time. Code that receives an enum from outside checks it with Enum.IsDefined, and a switch over an enum keeps a default arm.

Rule

Value types copy data, reference types copy references

also known as: value semantics, reference semantics

Formal. Assigning, passing, or returning a value-type value (a number, bool, a struct, an enum, a tuple) copies the data, so the two variables are independent afterwards. Assigning a reference-type value (a class instance, an array, a string, a delegate) copies only the reference; both variables then refer to the same object, and a change made through one is visible through the other. string is a reference type but immutable, so this sharing is never observable for text.

In plain English. A value type is like handing someone a photocopy: they can scribble on it and your page is untouched. A reference type is like handing someone the address of a house: you both now visit the same house, so if they repaint it, you see the new paint.

Example. int a = 1; int b = a; b = 2; leaves a as 1. With var p = new Player(); var q = p; q.Score = 10;, p.Score is also 10.

Why this rule exists. Small values are cheap to copy and safer when copied; large or shared things (a document, a bank account) need one identity that everyone refers to.

Rule

What == means depends on the kind of type

also known as: reference equality, value equality, Equals

Formal. For classes, == and the default Equals compare references (the same object) unless the class overloads them. string overloads == to compare contents ordinally. Records (class and struct) get compiler-generated value equality: equal when every member is equal, comparing reference-type members by their own equality. A plain struct has a field-by-field Equals but no == operator unless it declares one.

In plain English. For most objects, "equal" means "the very same object", not "the same data". Strings and records are the exceptions you use most: two strings with the same letters are equal, and two records holding the same values are equal.

Example. new Point(1, 2) == new Point(1, 2) is true for record Point(int X, int Y) and false for an ordinary class Point.

Why this rule exists. Identity matters for things like accounts and windows; value matters for data like coordinates and money. C# makes you pick by picking the kind of type.

Rule

A with expression makes a shallow copy

also known as: nondestructive mutation, shallow copy

Formal. source with { P = v, ... } copies every field of source into a new instance (for a record, through its synthesized copy constructor) and then assigns the listed members. Fields are copied as values. A field of reference type therefore copies the reference, and the original and the copy share the object it refers to. Nothing is cloned recursively.

In plain English. with photocopies the top sheet only. Numbers and text on that sheet are copied. A line that says "the shopping list is in the kitchen" is copied as that sentence, so both copies send you to the same list in the same kitchen.

Example. For record Cart(string Owner, List<string> Items), var b = a with { Owner = "Raj" }; b.Items.Add("eggs"); adds eggs to a.Items as well.

Why this rule exists. A deep copy would have to guess how to copy every kind of object, and would be slow for large graphs. A shallow copy is cheap and predictable. When the copies must not share a collection, copy it yourself: a with { Items = [.. a.Items] }.

Nullable reference types

Rule

Short-circuit evaluation

also known as: && and ||, conditional logical operators

Formal. a && b evaluates b only when a is true; a || b evaluates b only when a is false. The null-coalescing operator a ?? b evaluates b only when a is null, and a?.M() skips the member access when a is null. The non-short-circuit & and | on bool always evaluate both operands.

In plain English. C# stops as soon as it knows the answer. If the first half of an "and" is already false, the whole thing is false, so the second half never runs.

Example. if (name != null && name.Length > 0) is safe: when name is null, name.Length is never touched.

Why this rule exists. It makes guard conditions safe to write in one line, and it means code on the right-hand side is not guaranteed to run.

Rule

Null-state analysis warns, it doesn't enforce

also known as: nullable reference types, CS8602, null-forgiving operator

Formal. With nullable reference types enabled, the compiler tracks each reference as not-null or maybe-null along every code path. It warns when a maybe-null value is dereferenced (CS8602), or stored or passed where a non-nullable type is expected (CS8600, CS8601, CS8604, CS8625). A null check (is not null, != null, a type pattern, an early return) moves the variable's state to not-null, and so does x ??= value; an expression like x ?? "default" produces a not-null value but leaves x itself maybe-null. The null-forgiving ! changes the state to not-null for the compiler only; it emits no run-time check. The analysis produces warnings, never different generated code.

In plain English. The compiler reads your code like a careful reviewer and flags every place a missing value could crash. Checking for null satisfies it. Writing ! only tells the reviewer to stop complaining; it doesn't make the value exist.

Example. string? name = Find(); Console.WriteLine(name.Length); warns CS8602; if (name is not null) Console.WriteLine(name.Length); does not.

Why this rule exists. Null dereferences were among the most common run-time crashes; catching them while you type is far cheaper than in production.

Pattern matching

Rule

Pattern combinators -- not, then and, then or

also known as: logical patterns, pattern precedence

Formal. Inside a pattern, not binds most tightly, then and, then or. So not A or B and C means (not A) or (B and C). Parentheses override the grouping.

In plain English. Read not first, then join the and parts, then the or parts, just like "not" before "and" before "or" in ordinary logic. When in doubt, add parentheses.

Example. c is not 'a' or 'b' is true for 'b' and for every character except 'a'; c is not ('a' or 'b') excludes both.

Why this rule exists. Patterns read almost like English, which makes their grouping easy to misread; a fixed precedence keeps them unambiguous.

Rule

First match wins

also known as: switch expression arms, exhaustiveness

Formal. A switch expression tests its arms in the order written and produces the value of the first arm whose pattern (and optional when guard) matches. An arm that can never be reached because earlier arms already cover it is a compile error. If the arms don't cover every possible input the compiler warns (CS8509), and an unmatched value throws SwitchExpressionException at run time. A discard arm _ => covers everything that is left.

In plain English. C# checks the choices top to bottom and takes the first one that fits. Put the specific cases first and the catch-all last, and the compiler will tell you if you forgot a case.

Example. score switch { >= 90 => "A", >= 80 => "B", _ => "C" } gives "A" for 95, because >= 90 is tried before >= 80.

Why this rule exists. Order-dependent matching is what lets arms overlap safely, and the coverage check catches the forgotten case before a user does.

Generics

Rule

Only interfaces and delegates are variant

also known as: covariance, contravariance, in and out type parameters

Formal. A type parameter marked out (covariant) allows a conversion from I<Derived> to I<Base>; one marked in (contravariant) allows I<Base> to I<Derived>. Variance is permitted only on interface and delegate type parameters, and applies only when the type arguments are reference types. Classes and structs, including List<T>, are invariant.

In plain English. A read-only sequence of strings can safely be treated as a read-only sequence of objects, because you only ever take things out. A list you can also add to can't, because someone could put a number into your list of strings.

Example. IEnumerable<object> a = new List<string>(); compiles; List<object> b = new List<string>(); is error CS0029.

Why this rule exists. Variance is only safe in the direction that can't let a wrong type in; the in/out markers make that direction explicit and checked.

Delegates, lambdas, and events

Rule

Lambdas capture variables, not values

also known as: closures, captured variables

Formal. A lambda or local function that uses a local variable from its enclosing scope captures that variable itself. The lambda reads the variable's current value each time it runs and can assign to it; the variable's lifetime is extended as long as the lambda is reachable. A foreach loop variable is a fresh variable per iteration, while a for loop's counter is one variable shared by every iteration.

In plain English. A lambda doesn't take a snapshot. It keeps a line to the variable, so if the variable changes before the lambda runs, the lambda sees the change.

Example. Adding () => i in a for (int i = 0; i < 3; i++) loop and calling the lambdas afterwards gives 3, 3, 3; copying int copy = i; inside the loop and capturing copy gives 0, 1, 2.

Why this rule exists. Capturing the variable is what lets a lambda share state with the code around it, which is what makes callbacks and counters work.

Rule

Delegates combine; events restrict who can raise them

also known as: multicast delegates, event keyword, CS0070

Formal. A delegate instance can hold an invocation list of several methods; += and -= add and remove targets, and invoking the delegate calls them in order. Declaring a delegate field with the event keyword restricts code outside the declaring type to += and -=: only the declaring type can invoke (raise) the event or replace its list (error CS0070 otherwise).

In plain English. A delegate is a list of methods to call. An event is that list with a lock on it: anyone can sign up or leave, but only the owner can ring the bell.

Example. button.Clicked += OnClicked; compiles anywhere; button.Clicked(this, EventArgs.Empty); compiles only inside Button.

Why this rule exists. Without the restriction, any subscriber could fire the event or wipe out every other subscriber by assigning with =.

LINQ

Rule

Deferred LINQ operators run when enumerated, every time

also known as: deferred execution, lazy evaluation, multiple enumeration

Formal. Operators that return a sequence (Where, Select, OrderBy, Take, GroupBy …) do no work when called; they return a query object. The query runs when something enumerates it (foreach, ToList, Count, First …), against the source as it is at that moment, and it runs again in full on every enumeration, repeating any side effects in its lambdas. Operators that return a single value or a materialized collection (Count, Sum, First, Any, ToList, ToArray, ToDictionary) execute immediately.

In plain English. Writing var adults = people.Where(p => p.Age >= 18); doesn't find any adults yet. It writes down a recipe for finding them. The recipe is followed only when you ask for the results, and it is followed again each time you ask. Add a person to the list first, and the next run includes them. Call ToList() when you want the answers frozen at one moment.

Example. With var q = nums.Where(n => n > 1); over [1, 2, 3], adding 4 to nums and then calling q.Count() gives 3, not 2. Calling q.Count() twice runs the filter lambda over the list twice.

Why this rule exists. Deferring lets queries be composed cheaply, stream large or endless sources, and stop early (Take, First) without touching every item.

Rule

Lambdas capture variables, not values

also known as: closures, captured variables

Formal. A lambda or local function that uses a local variable from its enclosing scope captures that variable itself. The lambda reads the variable's current value each time it runs and can assign to it; the variable's lifetime is extended as long as the lambda is reachable. A foreach loop variable is a fresh variable per iteration, while a for loop's counter is one variable shared by every iteration.

In plain English. A lambda doesn't take a snapshot. It keeps a line to the variable, so if the variable changes before the lambda runs, the lambda sees the change.

Example. Adding () => i in a for (int i = 0; i < 3; i++) loop and calling the lambdas afterwards gives 3, 3, 3; copying int copy = i; inside the loop and capturing copy gives 0, 1, 2.

Why this rule exists. Capturing the variable is what lets a lambda share state with the code around it, which is what makes callbacks and counters work.

Rule

Don't change a collection's membership while enumerating it

also known as: collection was modified, InvalidOperationException in foreach

Formal. A List<T>'s enumerator is invalidated by any change to the list while a foreach loop is running over it, including Add, Remove, and even replacing an item with list[i] = x; the loop's next step throws InvalidOperationException ("Collection was modified"). Dictionary<TKey,TValue> and HashSet<T> are more permissive on modern .NET (since .NET Core 3.0): Remove and overwriting the value of an existing key are allowed during enumeration, but Add (and any change that adds a new key) still throws.

In plain English. A foreach loop is walking through a collection. If you add or remove items while it walks, it can lose its place, so .NET stops the program instead of guessing. A list is strict about this: change it in any way and the loop fails. A dictionary or set lets you remove as you go, but adding new entries still fails.

Example. foreach (var n in numbers) if (n < 0) numbers.Remove(n); throws on a List<int>. numbers.RemoveAll(...), a loop over a copy ([.. numbers]), or collecting the changes and applying them after the loop all work.

Why this rule exists. Failing loudly at the first bad step is far better than silently skipping or repeating items.

Exceptions

Rule

An exception travels up the call stack to the nearest matching catch

also known as: exception propagation, stack unwinding

Formal. When an exception is thrown, the runtime searches the call stack from the throwing method outward for a try whose catch clauses match the exception's type (and whose when filter, if any, is true). Within one try, catch clauses are tested top to bottom and the first match wins. Every frame between the throw and that handler is abandoned, and the finally blocks of those frames run as control leaves them. The search, including any when filters, happens before the inner finally blocks run. With no matching handler the exception is unhandled: the runtime prints it with its stack trace and ends the process.

In plain English. A thrown exception is like an alarm going off in the method that's running. That method stops. If it has no plan for that alarm, the method that called it stops too, and so on up the chain of calls, until one of them has a catch for that kind of alarm. Any finally cleanup along the way still runs. If no method has a plan, the whole program stops and prints the alarm and the chain of calls.

Example. Main calls LoadOrders, which calls ParseLine, which throws FormatException. If only Main has catch (FormatException), both ParseLine and LoadOrders stop at once and Main's catch runs next.

Why this rule exists. The method that detects a problem is often not the one that knows what to do about it; propagation lets the failure reach the code that can decide.

Rule

throw; keeps the stack trace, throw ex; replaces it

also known as: rethrowing an exception, CA2200

Formal. Inside a catch block, a bare throw; rethrows the caught exception with its original stack trace intact. throw ex; throws the same object as if it were new, so its stack trace restarts at the rethrow and the frames where it originated are lost; the .NET analyzers report this as warning CA2200. To add context, throw a new exception and pass the original as its InnerException.

In plain English. A stack trace is the record of where an error started. throw; passes the error on with that record untouched. throw ex; wipes the record and makes it look as if the error started in your catch block, which sends whoever debugs it to the wrong place.

Example. catch (Exception ex) { Log(ex); throw; } keeps the original line numbers. catch (IOException ex) { throw new ImportException("Couldn't read orders.csv", ex); } adds a message and keeps the original inside it.

Why this rule exists. The origin of a failure is the single most useful fact for fixing it.

Files and serialization

Rule

Writers buffer; disposing them flushes and closes the file

also known as: using, IDisposable, deterministic disposal

Formal. StreamWriter and FileStream hold written data in an in-memory buffer and write it to the operating system when the buffer fills, on Flush(), or on Dispose(). Dispose also releases the file handle. The runtime does not flush a writer that was never disposed when the process exits, so its buffered text is lost. A using statement or declaration calls Dispose when its scope ends, even when an exception is thrown.

In plain English. A writer collects your text in a holding area and sends it to the file in batches. Closing the writer sends the last batch and lets go of the file. Forget to close it, and the last batch never arrives. The file can be left empty. Writing using in front of the writer makes C# close it for you at the end of the block, even if something goes wrong.

Example. var w = new StreamWriter("log.txt"); w.WriteLine("hi"); with no Dispose can leave log.txt at 0 bytes. using var w = new StreamWriter("log.txt"); writes the line when w goes out of scope.

Why this rule exists. Writing to disk one character at a time would be very slow; buffering is fast, and disposal is how the program says "I'm done, finish up."

Dates and times

Rule

Store instants in UTC; convert to local time only for display

also known as: UTC everywhere, DateTimeOffset for instants

Formal. A moment in time (when an order was placed, when a log line was written) should be stored as UTC, preferably as a DateTimeOffset (or a DateTime with Kind == Utc), and converted to a user's time zone with TimeZoneInfo only when shown. Local times are ambiguous across daylight-saving changes and meaningless without their zone. DateTime equality and ordering compare ticks and ignore Kind, so mixing local and UTC values gives wrong answers silently; DateTimeOffset compares the instants themselves. Calendar values that aren't instants (a birthday, a store's opening time) belong in DateOnly and TimeOnly instead.

In plain English. Save every "when did this happen" in the one world clock, UTC, that never jumps for daylight saving. Turn it into someone's local time only at the moment you show it to them. If you save local clock times, 1:30 a.m. can happen twice on the night clocks go back, and two servers in different countries disagree about what time it is.

Example. new DateTime(2026,1,1,12,0,0,DateTimeKind.Utc) == new DateTime(2026,1,1,12,0,0,DateTimeKind.Local) is true, although those are different moments. The DateTimeOffset values 12:00+00:00 and 13:00+01:00 are equal because they are the same moment.

Why this rule exists. One unambiguous reference makes comparing, sorting and subtracting times correct everywhere.

Rule

Store instants in UTC; convert to local time only for display

also known as: UTC everywhere, DateTimeOffset for instants

Formal. A moment in time (when an order was placed, when a log line was written) should be stored as UTC, preferably as a DateTimeOffset (or a DateTime with Kind == Utc), and converted to a user's time zone with TimeZoneInfo only when shown. Local times are ambiguous across daylight-saving changes and meaningless without their zone. DateTime equality and ordering compare ticks and ignore Kind, so mixing local and UTC values gives wrong answers silently; DateTimeOffset compares the instants themselves. Calendar values that aren't instants (a birthday, a store's opening time) belong in DateOnly and TimeOnly instead.

In plain English. Save every "when did this happen" in the one world clock, UTC, that never jumps for daylight saving. Turn it into someone's local time only at the moment you show it to them. If you save local clock times, 1:30 a.m. can happen twice on the night clocks go back, and two servers in different countries disagree about what time it is.

Example. new DateTime(2026,1,1,12,0,0,DateTimeKind.Utc) == new DateTime(2026,1,1,12,0,0,DateTimeKind.Local) is true, although those are different moments. The DateTimeOffset values 12:00+00:00 and 13:00+01:00 are equal because they are the same moment.

Why this rule exists. One unambiguous reference makes comparing, sorting and subtracting times correct everywhere.

Asynchronous programming

Rule

Async is about waiting; parallel is about computing

also known as: concurrency vs parallelism, I/O-bound vs CPU-bound

Formal. async/await makes waiting non-blocking; it does not by itself create threads or run code on several cores. I/O-bound work (network, disk, timers) benefits from async, and independent waits overlap when their tasks are started before being awaited (Task.WhenAll). CPU-bound work only finishes sooner when it runs on several cores at once, with Parallel.For/ForEach, PLINQ or Task.Run, and only when each piece of work outweighs the cost of scheduling it and there is no contention on shared state.

In plain English. There are two different kinds of slow. Waiting slow is waiting for a download or a database. Async helps there, because you can start several waits and let them overlap. Working slow is the processor crunching numbers. Only splitting the crunching across several processor cores helps there, and only if each piece is big enough to be worth handing out.

Example. Three downloads of 1 s each take about 3 s awaited one by one, but about 1 s started together and awaited with WhenAll. Summing a million numbers with Parallel.For and a lock around each addition is slower than a plain loop.

Why this rule exists. Using the wrong tool costs you: parallel loops around I/O waste threads, and async around CPU work saves nothing.

Rule

await suspends the method, not the thread

also known as: asynchronous waiting, non-blocking await

Formal. When an async method reaches await on an operation that hasn't completed, the method is suspended: the compiler-generated state machine records where it stopped, returns an incomplete Task to its caller, and the current thread goes back to other work. When the awaited operation completes, the rest of the method runs as a continuation (in a console app, on a thread-pool thread). Calling .Result or .Wait() instead blocks the thread until the task finishes, and wraps a failure in AggregateException, where await rethrows the original exception.

In plain English. await means "come back to this line when the result is ready". The method pauses, but the thread running it doesn't stand around waiting; it leaves and does other useful work. When the result arrives, the method picks up where it left off. .Result is the opposite: it makes the thread stand still until the answer comes.

Example. A web server awaiting a 200 ms database query can serve other requests with that thread in the meantime. With .Result, the thread sits idle for the full 200 ms.

Why this rule exists. Threads are expensive. Waiting for disks, networks and timers without holding one is what lets a program handle thousands of waits at once.

Concurrency and parallelism

Rule

Shared mutable state needs synchronization

also known as: race condition, lock, Interlocked

Formal. When two or more threads access the same variable and at least one writes to it, the accesses must be synchronized (a lock, Interlocked, a concurrent collection, or a channel), or the result depends on timing. Even count++ is a read-modify-write of three steps, so unsynchronized increments from several threads lose updates. Every thread that touches the state must use the same synchronization; a lock that only some writers take protects nothing.

In plain English. If several threads change the same value, they can trip over each other. Two threads both read 5, both add 1, both write 6, and one of the additions is lost. You need a rule that makes them take turns, such as a lock or Interlocked.Increment, and every thread has to follow it.

Example. Eight tasks each running count++ 100,000 times usually finish with far fewer than 800,000. With Interlocked.Increment(ref count) the total is 800,000 every time.

Why this rule exists. Without synchronization, the processor and compiler are free to interleave and reorder the steps, and the bug shows up only sometimes, which makes it very hard to find.

Rule

Async is about waiting; parallel is about computing

also known as: concurrency vs parallelism, I/O-bound vs CPU-bound

Formal. async/await makes waiting non-blocking; it does not by itself create threads or run code on several cores. I/O-bound work (network, disk, timers) benefits from async, and independent waits overlap when their tasks are started before being awaited (Task.WhenAll). CPU-bound work only finishes sooner when it runs on several cores at once, with Parallel.For/ForEach, PLINQ or Task.Run, and only when each piece of work outweighs the cost of scheduling it and there is no contention on shared state.

In plain English. There are two different kinds of slow. Waiting slow is waiting for a download or a database. Async helps there, because you can start several waits and let them overlap. Working slow is the processor crunching numbers. Only splitting the crunching across several processor cores helps there, and only if each piece is big enough to be worth handing out.

Example. Three downloads of 1 s each take about 3 s awaited one by one, but about 1 s started together and awaited with WhenAll. Summing a million numbers with Parallel.For and a lock around each addition is slower than a plain loop.

Why this rule exists. Using the wrong tool costs you: parallel loops around I/O waste threads, and async around CPU work saves nothing.

Memory, resources, and low-level code

Rule

The garbage collector frees objects nothing can reach

also known as: reachability, GC roots, generations

Formal. An object on the managed heap may be collected once no chain of references leads to it from a root (a live local variable or parameter, a static field, a CPU register, a GC handle). When the collector runs is up to the runtime, mostly driven by allocation. The .NET GC is generational: new objects start in generation 0; objects that survive a collection are typically promoted to generation 1 and then 2, which are collected less often. The GC reclaims memory only; files, sockets and other resources are released by Dispose, not by collection.

In plain English. .NET cleans up objects for you, but only the ones your program can no longer reach by following references from anything it's still using. It does the cleanup when it decides to, not the moment you stop using something. New objects are checked often; objects that keep surviving get checked less, because long-lived objects tend to stay alive. The cleanup is for memory only. Open files still need closing yourself.

Example. After customers.Clear(), every customer object that nothing else refers to becomes collectable. An object still referenced from a static list or an event subscription never is, however long ago you last used it.

Why this rule exists. Freeing exactly what can't be reached is always safe, and checking young objects most often finds most garbage cheaply.

Solutions, projects, and MSBuild

Rule

MSBuild properties are evaluated in order; the last assignment wins

also known as: MSBuild evaluation order, Directory.Build.props vs targets

Formal. MSBuild evaluates a project in passes: first every property (in file order, following imports where they appear), then every item, and only afterwards runs targets. A property assigned twice keeps its last value. An SDK-style project is implicitly wrapped: Sdk.props (which imports Directory.Build.props) comes before the project's own content and Sdk.targets (which imports Directory.Build.targets) after it. So Directory.Build.props supplies defaults the project can override, while Directory.Build.targets can override the project. A Condition such as '$(Nullable)' == '' sets a value only if nothing earlier did.

In plain English. A project file is read from top to bottom, together with the shared files it pulls in. If a setting is written twice, the one read last is the one that counts. The shared Directory.Build.props file is read before your project, so your project can change its settings. Directory.Build.targets is read after, so its settings win.

Example. With <Nullable>disable</Nullable> in Directory.Build.props and <Nullable>enable</Nullable> in the .csproj, the project builds with nullable enabled; dotnet msbuild -getProperty:Nullable prints enable.

Why this rule exists. Defaults at the top and overrides below is a simple, predictable way for many projects to share settings while each keeps the right to differ.

Rule

MSBuild properties are evaluated in order; the last assignment wins

also known as: MSBuild evaluation order, Directory.Build.props vs targets

Formal. MSBuild evaluates a project in passes: first every property (in file order, following imports where they appear), then every item, and only afterwards runs targets. A property assigned twice keeps its last value. An SDK-style project is implicitly wrapped: Sdk.props (which imports Directory.Build.props) comes before the project's own content and Sdk.targets (which imports Directory.Build.targets) after it. So Directory.Build.props supplies defaults the project can override, while Directory.Build.targets can override the project. A Condition such as '$(Nullable)' == '' sets a value only if nothing earlier did.

In plain English. A project file is read from top to bottom, together with the shared files it pulls in. If a setting is written twice, the one read last is the one that counts. The shared Directory.Build.props file is read before your project, so your project can change its settings. Directory.Build.targets is read after, so its settings win.

Example. With <Nullable>disable</Nullable> in Directory.Build.props and <Nullable>enable</Nullable> in the .csproj, the project builds with nullable enabled; dotnet msbuild -getProperty:Nullable prints enable.

Why this rule exists. Defaults at the top and overrides below is a simple, predictable way for many projects to share settings while each keeps the right to differ.

NuGet

Rule

NuGet picks the lowest version that satisfies every requirement

also known as: lowest applicable version, nearest wins, NU1605, NU1603

Formal. A PackageReference version such as Version="2.1.0" is a minimum, the range >= 2.1.0. Restore resolves each package to the lowest available version that satisfies the ranges applied to it, not the newest; if that exact version doesn't exist it takes the next one up and warns NU1603. When the same package is required at several depths of the graph, the requirement nearest the project wins ("nearest wins"), so a direct reference overrides a transitive one. If the direct reference asks for a lower version than a dependency needs, restore reports NU1605, a package downgrade, which SDK projects treat as an error.

In plain English. Writing a version number in your project means "this version or newer". NuGet then picks the oldest version that works for everyone, which keeps builds predictable: a new release on nuget.org doesn't change your build by itself. If you ask for a version directly, your request beats what your packages ask for. Ask for an older version than one of your packages needs, though, and restore stops with an error instead of quietly breaking that package.

Example. Humanizer.Core Version="2.14.0" resolves to 2.14.1 (2.14.0 was never published) with warning NU1603, even though version 3 exists. Referencing Serilog.Sinks.Console 6.0.0 (which needs Serilog >= 4.0.0) together with Serilog 3.1.1 fails restore with NU1605.

Why this rule exists. Choosing the lowest satisfying version makes a restore repeatable, and upgrades happen only when someone changes a version on purpose.

Dependency injection, configuration, and logging

Rule

A service may depend only on services that live at least as long

also known as: DI lifetimes, captive dependency, scope validation

Formal. The .NET container creates a transient service on every resolution, a scoped service once per scope (in ASP.NET Core, once per request), and a singleton once for the container's lifetime. A service must not depend on a shorter-lived one: a singleton that takes a scoped service in its constructor gets an instance created from the root provider, outside every request's scope, and holds it until the app shuts down, sharing it across all requests (a captive dependency). With ValidateScopes/ValidateOnBuild the container rejects this at build time ("Cannot consume scoped service 'Cart' from singleton 'Checkout'"), and resolving a scoped service from the root provider throws. The generic host and ASP.NET Core turn this validation on only in the Development environment.

In plain English. The container can give out a fresh object every time, one per request, or one shared object for the whole app. A shared, app-wide object must not hold on to a per-request object. If it does, it gets one copy of that object that is never thrown away, and every request shares it, which is exactly what "per request" was meant to prevent. .NET catches this mistake when it runs in Development. It stays quiet in Production, so run and test in Development.

Example. AddScoped<Cart>() plus AddSingleton<Checkout>() where Checkout(Cart cart) builds fine with Host.CreateApplicationBuilder in Production and fails at Build() in Development with an AggregateException naming both services.

Why this rule exists. Lifetimes exist so short-lived state (a database session, the current user) stays short-lived; a longer-lived holder quietly undoes that.

Rule

Configuration sources are layered; the last source to set a key wins

also known as: configuration precedence, environment overrides

Formal. Host.CreateApplicationBuilder and WebApplication.CreateBuilder add configuration sources in order: appsettings.json, appsettings.{Environment}.json, user secrets (in Development only), environment variables, then command-line arguments. A key set by a later source overrides the same key from an earlier one; keys nobody overrides keep their earlier value. Hierarchical keys use : (Shop:PageSize); in environment variables, where : isn't allowed everywhere, __ stands for it (Shop__PageSize).

In plain English. Settings come from several places stacked on top of each other: the main settings file, then a file for the current environment, then private developer secrets, then environment variables, then whatever you type on the command line. Each layer can replace values from the layers under it, and the top one wins. That lets one build run everywhere with different settings.

Example. With PageSize 10 in appsettings.json and 25 in appsettings.Development.json, the app reads 10 in Production and 25 in Development; Shop__PageSize=50 makes it 50, and --Shop:PageSize=99 on the command line makes it 99. Currency, set only in the base file, stays USD throughout.

Why this rule exists. Defaults live with the code; each environment overrides only what differs, and secrets never need to be in a file in the repository.

Performance

Rule

Measure before you optimize, and measure again after

also known as: premature optimization, benchmark first, profile first

Formal. Performance work starts from a measurement that identifies a real bottleneck: a profiler trace or a benchmark of a Release build with warmup and many iterations (BenchmarkDotNet). Change one thing, measure again under the same conditions, and keep the change only if the gain is real and worth its cost in clarity. Debug builds, single Stopwatch runs and first-call timings (JIT, cold caches) are not measurements.

In plain English. Don't speed up code because you suspect it's slow. Find out where the time really goes, fix that one place, and check the fix actually helped. A single quick timing of a debug build is mostly noise.

Example. A profiler shows 80% of a report's time in one string concatenation loop; switching it to StringBuilder and re-benchmarking confirms the win. The LINQ query you suspected took 1%.

Why this rule exists. Guesses about bottlenecks are usually wrong, and every optimization makes code harder to read; measurement is what makes the trade worth it.

Security

Rule

Store passwords as slow, salted, one-way hashes

also known as: password hashing, PBKDF2, PasswordHasher, never store plaintext passwords

Formal. Never store a password or a reversibly encrypted password. Store the output of a deliberately slow key-derivation function (PBKDF2, bcrypt, scrypt or Argon2) computed with a unique random salt and a high work factor, along with the salt and parameters. To verify, recompute with the stored salt and compare in constant time (CryptographicOperations.FixedTimeEquals). In .NET, ASP.NET Core Identity's PasswordHasher<TUser> or Rfc2898DeriveBytes.Pbkdf2 do this; general-purpose hashes such as SHA-256 are too fast on their own.

In plain English. Keep only a scrambled fingerprint of each password that can't be turned back into the password. Make that scrambling slow on purpose, and mix in a random value per user, so a thief who steals the table can't try billions of guesses or look answers up in a ready-made list. To log someone in, scramble what they typed the same way and compare.

Example. Two users with the password hunter2 get completely different stored hashes because their salts differ.

Why this rule exists. Password tables get stolen; a slow salted hash is what turns a breach from "every password is exposed" into "guessing even one is expensive".

Rule

Never build SQL from input; pass values as parameters

also known as: SQL injection, parameterized queries

Formal. A value concatenated into SQL text becomes part of the command and can change its meaning (SQL injection). A parameterized query sends the command text and the values separately; the database treats each value as data of its declared type, never as SQL. EF Core LINQ queries send captured variables as parameters; a constant written inside the lambda (p => p.Price > 5m) is embedded as a literal, which is safe because it is part of your code, not input. For raw SQL, FromSql/ SqlQuery/ExecuteSql take an interpolated string and turn each {value} into a parameter, while FromSqlRaw with string concatenation does not. Dapper parameterizes @name placeholders from an object.

In plain English. If you paste what a user typed into a database command, a crafted input can rewrite the command, for example to delete a table or read other people's data. Sending the values separately, as parameters, makes the database treat them only as values. EF Core does this for you as long as you use LINQ or its interpolated raw-SQL methods.

Example. db.Products.FromSql($"SELECT * FROM Products WHERE Name = {name}") sends @p0 with the name as a parameter. FromSqlRaw("... WHERE Name = '" + name + "'") lets x' OR '1'='1 return every row.

Why this rule exists. Keeping code and data apart is the only reliable defence; escaping by hand is easy to get wrong.

Principles, patterns, and architecture

Rule

Dependencies point toward the business rules

also known as: dependency direction, clean architecture, layers, dependency inversion

Formal. In a layered design the domain (business rules) depends on nothing application-specific; the application layer (use cases) depends on the domain; infrastructure (databases, HTTP, files) and the host (API, UI) depend on the inner layers, never the reverse. When an inner layer needs an outer capability, it declares an interface that the outer layer implements and dependency injection supplies at run time (dependency inversion). Project references encode the direction, so a wrong-way reference is a build-time fact you can see.

In plain English. The code that describes what your business does shouldn't know which database or web framework you picked. The outer parts plug into the inner parts, not the other way round. When the inner code needs, say, storage, it describes what it needs as an interface, and the outer code provides the real thing.

Example. Orders.Domain has no project references; Orders.Infrastructure references Orders.Application and implements its IOrderRepository with EF Core; Orders.Api wires them together in Program.cs.

Why this rule exists. Keeping the core free of infrastructure details makes it testable without a database and lets you replace a database or framework without rewriting the rules.

AI-assisted development

Rule

The compiler, tests, debugger and docs outrank the AI

also known as: verify AI output, AI-assisted development, hallucinated APIs

Formal. Code or explanations produced by an AI assistant are unverified proposals. Accept them only after the same checks as any other change: it compiles without new warnings, every API it uses exists in the official documentation for your target version, tests covering normal, edge and failure cases pass, stepping through it in the debugger matches your expectations, and it introduces no security weakness or leaked secret. You remain responsible for every line you commit.

In plain English. An AI can write code that looks right and is wrong, or that uses methods that don't exist. Treat what it gives you as a suggestion from a fast but careless helper. Let the compiler, your tests, the debugger and the real documentation decide whether it's right, and remember it's your name on the commit.

Example. An assistant's IsLeapYear passes the tests for 2024 and 2023 but returns true for 1900; one extra test case for century years exposes it.

Why this rule exists. Models produce fluent, plausible output, not checked output; verification is the part of the job an assistant can't do for you.

Capstone: a complete application

Rule

Dependencies point toward the business rules

also known as: dependency direction, clean architecture, layers, dependency inversion

Formal. In a layered design the domain (business rules) depends on nothing application-specific; the application layer (use cases) depends on the domain; infrastructure (databases, HTTP, files) and the host (API, UI) depend on the inner layers, never the reverse. When an inner layer needs an outer capability, it declares an interface that the outer layer implements and dependency injection supplies at run time (dependency inversion). Project references encode the direction, so a wrong-way reference is a build-time fact you can see.

In plain English. The code that describes what your business does shouldn't know which database or web framework you picked. The outer parts plug into the inner parts, not the other way round. When the inner code needs, say, storage, it describes what it needs as an interface, and the outer code provides the real thing.

Example. Orders.Domain has no project references; Orders.Infrastructure references Orders.Application and implements its IOrderRepository with EF Core; Orders.Api wires them together in Program.cs.

Why this rule exists. Keeping the core free of infrastructure details makes it testable without a database and lets you replace a database or framework without rewriting the rules.

Rule

The compiler, tests, debugger and docs outrank the AI

also known as: verify AI output, AI-assisted development, hallucinated APIs

Formal. Code or explanations produced by an AI assistant are unverified proposals. Accept them only after the same checks as any other change: it compiles without new warnings, every API it uses exists in the official documentation for your target version, tests covering normal, edge and failure cases pass, stepping through it in the debugger matches your expectations, and it introduces no security weakness or leaked secret. You remain responsible for every line you commit.

In plain English. An AI can write code that looks right and is wrong, or that uses methods that don't exist. Treat what it gives you as a suggestion from a fast but careless helper. Let the compiler, your tests, the debugger and the real documentation decide whether it's right, and remember it's your name on the commit.

Example. An assistant's IsLeapYear passes the tests for 2024 and 2023 but returns true for 1900; one extra test case for century years exposes it.

Why this rule exists. Models produce fluent, plausible output, not checked output; verification is the part of the job an assistant can't do for you.