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
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.