Chapter 01
C# syntax you use every day
The parts of modern C# that show up in every ASP.NET Core codebase. C# 14 ships with .NET 10; most of what is here works from C# 9 onwards.
Value types and reference types
Also called struct, class, stack and heap
- What
- A struct (int, decimal, DateTime, your own structs) is copied when you assign or pass it. A class is a reference type: assigning it copies the reference, so two variables can point at one object.
- Why
- It explains why changing an object inside a method changes it for the caller, while changing an int does not, and why
decimalis the type for money.
var a = new Account { Balance = 100m };
var b = a; // same object
b.Balance = 50m; // a.Balance is now 50
int x = 1;
int y = x; // a copy
y = 2; // x is still 1Watch out
Using double for money: 0.1 + 0.2 is not exactly 0.3. Use decimal for amounts.
Records
Also called record, record struct, with expression, DTO
- What
- A record is a type built for data: the compiler writes value equality, a readable ToString and, with positional syntax, an immutable constructor and properties.
withmakes a changed copy. - Why
- Records are the natural shape for request and response DTOs, events and value objects, where two instances with the same data should be equal.
public record OrderDto(int Id, decimal Total, string Status);
var a = new OrderDto(1, 250m, "Paid");
var b = a with { Status = "Refunded" };
Console.WriteLine(a == b); // False: values differWatch out
Using records as EF Core entities: tracked entities need identity, not value equality, so keep entities as classes.
Pattern matching and switch expressions
Also called switch expression, is pattern, property pattern
- What
- Patterns test the shape of a value: its type, a constant, a range or its properties. A switch expression returns a value from the first arm that matches.
- Why
- It replaces long if/else chains with a compact, checked list of cases, and the compiler warns when a switch over an enum misses a value.
decimal Fee(Payment p) => p switch
{
{ Method: "card", Amount: > 10_000m } => 0m,
{ Method: "card" } => p.Amount * 0.02m,
BankTransfer => 25m,
null => throw new ArgumentNullException(nameof(p)),
_ => 0m,
};Watch out
Putting the catch-all _ arm first, so the specific arms below it never run.
Nullable reference types
Also called NRT, string?, null-forgiving operator, <Nullable>enable
- What
- With
<Nullable>enable</Nullable>(on in every new template)stringmeans never null andstring?means may be null. The compiler warns when you might dereference a null. - Why
- NullReferenceException is the most common .NET crash. Treating these warnings as errors moves that crash from production to compile time.
public User? FindByEmail(string email) =>
_users.FirstOrDefault(u => u.Email == email);
var user = FindByEmail("a@b.com");
Console.WriteLine(user?.Name ?? "not found");
user!.Name = "x"; // ! silences the warning: avoidWatch out
Sprinkling ! to make warnings disappear: it hides the bug instead of handling the null.
Primary constructors
Also called class primary constructor, C# 12
- What
- Since C# 12, a class can declare constructor parameters next to its name. The parameters are in scope in the whole class body.
- Why
- Services that take dependencies through DI become short: no field declarations and no assignment boilerplate.
public class OrderService(AppDb db, ILogger<OrderService> log)
{
public async Task<Order?> Get(int id)
{
log.LogInformation("Loading {OrderId}", id);
return await db.Orders.FindAsync(id);
}
}Watch out
Assigning to the parameter inside a method: it is mutable and captured, so the change leaks into every later call.
Collection expressions
Also called [1, 2, 3], spread operator, C# 12
- What
- Since C# 12,
[a, b, c]creates an array, List, Span or other collection, chosen by the target type...spreads another collection into it. - Why
- One short syntax for every collection type, which the compiler can also optimise.
int[] ids = [1, 2, 3];
List<string> roles = ["admin", "member"];
List<string> all = [..roles, "auditor"];
int[] none = [];Watch out
Using var x = [1, 2];: there is no target type, so it does not compile.
Generics and constraints
Also called generic type, where T, List<T>
- What
- Generics let a class or method work with any type while staying type-safe:
List<Order>only holds orders. Constraints such aswhere T : classlimit which types are allowed. - Why
- Collections, repositories,
Task<T>andILogger<T>are all generic. Reading generic signatures is a daily skill in .NET.
public interface IRepository<T> where T : class, IEntity
{
Task<T?> GetAsync(int id, CancellationToken ct);
Task AddAsync(T item, CancellationToken ct);
}
T Max<T>(T a, T b) where T : IComparable<T> =>
a.CompareTo(b) >= 0 ? a : b;Watch out
Wrapping EF Core in a generic repository that hides IQueryable, then fetching whole tables to filter in memory.
Extension methods and extension members
Also called extension block, this parameter, C# 14
- What
- An extension method adds a method to a type you do not own, through a static class and a
thisparameter. C# 14 adds extension blocks, which can also declare extension properties. - Why
- All of LINQ is extension methods on IEnumerable, and every
builder.Services.AddX()call in Program.cs is one.
public static class StringExtensions
{
public static string Mask(this string card) =>
card.Length < 4 ? "****" : $"**** {card[^4..]}";
}
Console.WriteLine("4111111111111111".Mask());
// **** 1111Watch out
Hiding real business logic in extension methods, where it is hard to find and cannot be mocked.
Exceptions, using and finally
Also called try catch, throw, IDisposable, using declaration
- What
- Code throws an exception for a situation it cannot handle; the nearest matching
catchup the call stack handles it.usingdisposes connections, streams and other resources even when an exception is thrown. - Why
- Catch only what you can handle, let the rest reach one global handler, and never leak a connection or file handle.
try
{
await using var tx =
await db.Database.BeginTransactionAsync();
await Transfer(from, to, amount);
await tx.CommitAsync();
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict();
}Watch out
Writing throw ex; instead of throw;, which resets the stack trace and hides where the error started.