C# principles: a study guide
The ideas I come back to every working day, in the order I would teach them. Each one has a short rule, the reason behind it and a small example you can type and run.
How to use this guide
Read one section at a time and type the examples yourself. Reading code and writing code are different skills, and only the second one sticks. Every section opens with the rule in one sentence; if you only have five minutes, read those boxes and the checklist at the end.
The examples use modern C# (C# 10 and later, .NET 6 and later). Nothing here depends on a framework: a console project created with dotnet new console is enough.
1. The four pillars of object-oriented programming
Object-oriented design is a way of splitting a program into small parts that each own some data and the behaviour that belongs to it. The four pillars are the four tools you get for doing that.
Encapsulation
Encapsulation is not "make the fields private and add getters and setters". It means the class is the only place that knows the rules of its data. A bank account whose balance anyone can set is a number with a name. An account that only changes through Deposit and Withdraw can promise that the balance is never negative.
// Anyone can break it
public class Account
{
public decimal Balance { get; set; }
}
account.Balance = -500; // compiles, and now the data is wrong
public class Account
{
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
Balance += amount;
}
public void Withdraw(decimal amount)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (amount > Balance) throw new InvalidOperationException("Insufficient funds.");
Balance -= amount;
}
}
The test for good encapsulation: can you list every way the state can change by reading one file? If yes, a bug in that state can only live in that file.
Abstraction
An abstraction is a simpler picture of something complicated. When you call File.ReadAllText(path) you do not think about file handles, buffers and encodings. Good abstractions let the caller think in the words of the problem ("send the invoice") rather than the words of the machine ("open an SMTP connection").
public interface INotifier
{
Task SendAsync(string recipient, string message);
}
// The caller does not know, or care, whether this is email, SMS or a push message.
await notifier.SendAsync(customer.Contact, "Your order has shipped.");
A leaky abstraction is one where the caller has to know the hidden part anyway. If every user of INotifier must remember that messages over 160 characters fail, the abstraction has leaked SMS into the rest of the system.
Inheritance
Inheritance lets a class reuse and extend another class. It is the strongest coupling C# offers: the child depends on everything the parent does, including the parts you did not look at. Use it for real "is a" relationships and keep hierarchies shallow.
public abstract class Shape
{
public abstract double Area();
public override string ToString() => $"{GetType().Name} with area {Area():F2}";
}
public sealed class Circle : Shape
{
public double Radius { get; }
public Circle(double radius) => Radius = radius;
public override double Area() => Math.PI * Radius * Radius;
}
public sealed class Rectangle : Shape
{
public double Width { get; }
public double Height { get; }
public Rectangle(double width, double height) => (Width, Height) = (width, height);
public override double Area() => Width * Height;
}
Mark classes sealed unless you have designed them to be inherited. It documents intent and prevents someone from extending a class in a way you never tested.
Polymorphism
Shape[] shapes = { new Circle(1), new Rectangle(2, 3) };
foreach (var shape in shapes)
Console.WriteLine(shape); // each one computes its own area
double total = shapes.Sum(s => s.Area());
Polymorphism is what removes long if and switch chains on a "type" field. When a new shape appears you add a class; you do not go hunting for every switch in the code base. C# also has compile-time polymorphism: method overloads and generics, covered below.
2. SOLID
SOLID is five rules for classes that stay easy to change. They are guidelines, not laws. Apply them where change is likely, and do not build an interface for something that will only ever have one shape.
S — Single responsibility
"One reason" means one group of people who might ask for a change. An invoice class that calculates totals, saves itself to the database and prints itself as PDF has three: finance, the database team and whoever owns the layout.
public class Invoice
{
public decimal Total() { /* tax rules */ }
public void Save() { /* SQL */ }
public byte[] ToPdf() { /* layout */ }
}
public class Invoice { public decimal Total() { /* tax rules */ } }
public class InvoiceRepository { public Task SaveAsync(Invoice invoice) { /* SQL */ } }
public class InvoicePdfRenderer { public byte[] Render(Invoice invoice) { /* layout */ } }
O — Open/closed
public decimal Discount(Customer c, decimal amount)
{
switch (c.Type)
{
case "Regular": return 0;
case "Gold": return amount * 0.10m;
case "Staff": return amount * 0.30m; // every new type edits this method
default: throw new NotSupportedException();
}
}
public interface IDiscountPolicy { decimal For(decimal amount); }
public class NoDiscount : IDiscountPolicy { public decimal For(decimal amount) => 0; }
public class GoldDiscount : IDiscountPolicy { public decimal For(decimal amount) => amount * 0.10m; }
public class StaffDiscount : IDiscountPolicy { public decimal For(decimal amount) => amount * 0.30m; }
// A new customer type is a new class. Nothing that already works is touched.
L — Liskov substitution
The classic failure is a square that inherits from a rectangle. Setting the width of a square silently changes its height, so code written for rectangles gets wrong results. The same mistake in everyday code looks like a derived class that throws NotSupportedException from a method the base promised.
public class ReadOnlyList<T> : List<T>
{
public new void Add(T item) => throw new NotSupportedException(); // breaks every caller that expects a List<T>
}
If a subtype cannot keep the base type's promises, it is not a subtype. Model it as a separate type, or depend on a smaller interface (for example IReadOnlyList<T>).
I — Interface segregation
public interface IMachine { void Print(); void Scan(); void Fax(); }
public class BasicPrinter : IMachine
{
public void Print() { /* ok */ }
public void Scan() => throw new NotSupportedException();
public void Fax() => throw new NotSupportedException();
}
public interface IPrinter { void Print(); }
public interface IScanner { void Scan(); }
public class BasicPrinter : IPrinter { public void Print() { } }
public class OfficeMachine : IPrinter, IScanner { public void Print() { } public void Scan() { } }
D — Dependency inversion
public class OrderService
{
private readonly SqlOrderRepository _orders = new(); // hard-wired to SQL
private readonly SmtpClient _mail = new("smtp.example.com");
}
public class OrderService
{
private readonly IOrderRepository _orders;
private readonly INotifier _notifier;
public OrderService(IOrderRepository orders, INotifier notifier)
{
_orders = orders;
_notifier = notifier;
}
}
Now the service can be tested with an in-memory repository, and switching the database does not touch the order logic. Section 12 shows how the pieces are wired together.
3. Everyday design rules
DRY — do not repeat yourself
Every piece of knowledge should live in one place. DRY is about rules ("VAT is 20%"), not about text that happens to look alike. Two methods with the same three lines that change for different reasons are not duplication, and merging them creates a dependency that will hurt later.
KISS — keep it simple
The simplest code that solves today's problem wins. A plain if is better than a strategy pattern with one strategy. Clever code costs every reader extra time, including you in six months.
YAGNI — you are not going to need it
Do not build for requirements nobody has asked for. Speculative flexibility has a certain cost (more code to read, test and maintain) and an uncertain benefit. Make the change easy when it arrives, then make the change.
Composition over inheritance
Prefer building behaviour from small objects that are passed in ("has a") over inheriting it ("is a"). Composition can change at run time and does not tie you to a parent's internals.
public class Order
{
private readonly IDiscountPolicy _discount; // has a policy, is not a kind of policy
public Order(IDiscountPolicy discount) => _discount = discount;
public decimal Total(decimal amount) => amount - _discount.For(amount);
}
Separation of concerns
Keep different jobs in different places: HTTP handling in controllers, rules in the domain, storage in repositories. A controller that builds SQL strings mixes three concerns, and a change in any of them risks the other two.
Tell, don't ask
Instead of asking an object for its data and deciding for it, tell it what you want. account.Withdraw(50) keeps the rule inside the account. if (account.Balance >= 50) account.Balance -= 50; copies the rule to every caller.
Law of Demeter
Talk to your direct collaborators only. A chain such as order.Customer.Address.City.Name ties the caller to four classes. Ask the order for what you need (order.ShippingCity) and let it deal with its own insides.
Fail fast
Check inputs at the door and stop immediately when something is wrong. An exception at the point of the mistake is a gift; a wrong number that surfaces three layers later is a day of debugging.
public Order(Customer customer, IReadOnlyList<OrderLine> lines)
{
ArgumentNullException.ThrowIfNull(customer);
if (lines.Count == 0) throw new ArgumentException("An order needs at least one line.", nameof(lines));
Customer = customer;
Lines = lines;
}
4. The type system
Value types and reference types
Value type (struct, int, bool, DateTime, enums) | Reference type (class, string, arrays, delegates) | |
|---|---|---|
| Assignment | copies the whole value | copies the reference; both variables see the same object |
| Default | all zeros | null |
| Equality by default | by content | by identity (same object) |
| Good for | small, immutable data such as a point or money amount | anything with identity or shared, changing state |
var a = new Point(1, 2); // struct
var b = a; // a copy
b.X = 99; // a.X is still 1
var first = new List<int> { 1 };
var second = first; // same list
second.Add(2); // first.Count is now 2
Records
A record is a type whose equality is based on its values, with a readable ToString and a with expression for making changed copies. Use records for data that describes something: DTOs, messages, value objects.
public record Money(decimal Amount, string Currency);
var price = new Money(10, "EUR");
var same = new Money(10, "EUR");
Console.WriteLine(price == same); // True: compared by value
var dearer = price with { Amount = 12 }; // a new object; price is unchanged
Immutability
Immutable objects are safe to share between threads, safe to cache and easy to reason about. Reach for readonly fields, init-only properties and records first, and add mutability only where the model needs it.
public class Customer
{
public required string Name { get; init; } // set once, when the object is created
public string? Phone { get; init; }
}
Nullable reference types
With <Nullable>enable</Nullable> in the project file, string means "never null" and string? means "may be null". The compiler then warns you before a NullReferenceException can happen. Treat those warnings as errors and do not silence them with ! unless you can explain why the value cannot be null.
string? FindEmail(int id) => /* may return null */;
var email = FindEmail(42);
if (email is null) return;
Send(email); // the compiler knows it is not null here
var length = FindEmail(7)?.Length ?? 0;
5. Interfaces and abstract classes
| Interface | Abstract class | |
|---|---|---|
| How many can a class have | many | one base class |
| State (fields) | none | yes |
| Constructors | no | yes |
| Typical use | a capability: IComparable, IDisposable, IOrderRepository | a family with shared code: Stream, Shape |
Start with an interface. Introduce an abstract base class only when two or more implementations really share code, and keep the interface so callers do not depend on the base class.
6. Generics
Generics give you reuse without giving up type safety. List<int> cannot hold a string, and reading from it needs no cast. Compare that with the old ArrayList, which stored everything as object and failed at run time.
public class Result<T>
{
public T? Value { get; }
public string? Error { get; }
public bool IsSuccess => Error is null;
private Result(T? value, string? error) => (Value, Error) = (value, error);
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string error) => new(default, error);
}
// A constraint says what T must be able to do
public static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
Useful constraints: where T : class, where T : struct, where T : new(), where T : SomeBase, where T : ISomeInterface, where T : notnull.
7. Delegates, lambdas and events
A delegate is a type that holds a method. Func<T, TResult> returns a value, Action<T> returns nothing and Predicate<T> returns a bool. A lambda is a short way to write the method on the spot.
Func<int, int> square = x => x * x;
Action<string> log = message => Console.WriteLine($"[{DateTime.Now:T}] {message}");
int Apply(int value, Func<int, int> operation) => operation(value);
log(Apply(5, square).ToString()); // 25
An event is a delegate that only the owner can raise. It lets a class announce that something happened without knowing who is listening.
public class Order
{
public event EventHandler<OrderShippedEventArgs>? Shipped;
public void Ship()
{
// ... ship it ...
Shipped?.Invoke(this, new OrderShippedEventArgs(Id));
}
}
Always unsubscribe (-=) from events of long-lived objects. A forgotten subscription keeps the subscriber alive and is the most common memory leak in .NET applications.
8. LINQ
var topCustomers = orders
.Where(o => o.Date.Year == 2026)
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Amount) })
.OrderByDescending(x => x.Total)
.Take(10)
.ToList();
Deferred execution
A LINQ query is a recipe. Nothing runs until you enumerate it with foreach, ToList, Count, First and similar. This has two consequences you must remember:
- Enumerating the same query twice does the work twice. If the result is used more than once, materialise it with
ToList(). - The query sees the data as it is when it runs, not as it was when you wrote it.
var numbers = new List<int> { 1, 2, 3 };
var big = numbers.Where(n => n > 1); // nothing has run yet
numbers.Add(10);
Console.WriteLine(big.Count()); // 3, because 10 is included
IEnumerable and IQueryable
IEnumerable<T> runs in memory, in your process. IQueryable<T> (Entity Framework) is translated to SQL and runs in the database. Calling ToList() or AsEnumerable() too early pulls the whole table into memory and filters it there. Filter first, materialise last.
Choose the right operator
Any()to ask "is there at least one", neverCount() > 0.First()throws on empty,FirstOrDefault()returns the default,Single()also throws if there is more than one. Pick the one that matches what you expect to be true.- Keep side effects out of queries. A
Selectthat writes to a database is a trap.
9. async and await
await does not create a thread. It pauses the method, frees the current thread to do other work and continues when the operation is finished. That is how a web server handles thousands of requests with a handful of threads.
public async Task<Order?> GetOrderAsync(int id, CancellationToken ct)
{
var response = await _http.GetAsync($"/orders/{id}", ct);
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<Order>(cancellationToken: ct);
}
The rules that prevent most async bugs
- Async all the way. Never block on async code with
.Resultor.Wait(). It wastes a thread at best and deadlocks at worst. - Return
Task, notvoid.async voidcannot be awaited and its exceptions crash the process. The only exception is event handlers. - Pass a
CancellationTokenthrough every async call, so abandoned work can stop. - Run independent work together with
Task.WhenAllrather than awaiting one after another. - Do not wrap synchronous code in
Task.Runinside a web request to "make it async". It only moves the work to another thread from the same pool. - Never fire and forget without handling errors: an unobserved exception is a silent failure.
// One after another: about 3 seconds
var user = await GetUserAsync(id, ct);
var orders = await GetOrdersAsync(id, ct);
var offers = await GetOffersAsync(id, ct);
// Together: about 1 second
var userTask = GetUserAsync(id, ct);
var ordersTask = GetOrdersAsync(id, ct);
var offersTask = GetOffersAsync(id, ct);
await Task.WhenAll(userTask, ordersTask, offersTask);
Async is for waiting. For heavy calculations that keep the CPU busy, use Parallel.ForEach, PLINQ or a background service; await will not make them faster.
10. Exceptions
- Exceptions are for exceptional cases. "The user typed a wrong password" is an expected outcome; return a result. "The database is gone" is exceptional; throw.
- Catch specific types. A bare
catch (Exception)also swallows the bugs you wanted to hear about. - Rethrow with
throw;, neverthrow ex;, which resets the stack trace and hides where the problem started. - Clean up in
finallyor withusing, so cleanup happens on both the happy and the failing path. - Add context when you wrap. Put the original exception in
InnerException.
try { Process(order); }
catch (Exception) { } // the error is gone and nobody will ever know
try
{
Process(order);
}
catch (PaymentDeclinedException ex)
{
_logger.LogWarning(ex, "Payment declined for order {OrderId}", order.Id);
return Result.Fail("Your card was declined.");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Order {OrderId} failed", order.Id);
throw; // let the top-level handler turn it into a 500
}
11. Resources and memory
The garbage collector
The .NET garbage collector frees memory that nothing references any more. You do not delete objects. You do have to stop referencing them: static collections that only grow, forgotten event subscriptions and caches without limits all keep objects alive forever.
IDisposable and using
The collector knows about memory. It knows nothing about files, sockets and database connections. Types that hold such resources implement IDisposable, and using guarantees that Dispose runs, even when an exception is thrown.
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
using var reader = new StreamReader(path);
var text = await reader.ReadToEndAsync(ct);
// both are disposed here, at the end of the scope
Small habits that matter at scale
- Build long strings in loops with
StringBuilder. Strings are immutable, so every+=creates a new one. - Give collections a starting capacity when you know the size.
- Reuse one
HttpClient(or useIHttpClientFactory); creating one per request exhausts sockets. - Measure before optimising. A profiler or BenchmarkDotNet beats a guess every time.
12. Dependency injection
Dependency injection is dependency inversion in practice. The classes declare their needs, and one place at the start of the program (the composition root) decides which implementations they get.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddTransient<INotifier, EmailNotifier>();
builder.Services.AddScoped<OrderService>();
Lifetimes
| Lifetime | One instance per | Use for |
|---|---|---|
Singleton | application | stateless services, caches, configuration. Must be thread-safe. |
Scoped | request | anything tied to one unit of work, such as a DbContext |
Transient | resolution | small, cheap, stateless helpers |
The classic mistake is a captive dependency: a singleton that takes a scoped service in its constructor keeps that one instance for the life of the application. A DbContext captured this way ends up shared between requests and fails in ways that are hard to reproduce.
Avoid the service locator pattern (asking IServiceProvider for things in the middle of a method). It hides what a class depends on, and the constructor stops telling the truth.
13. Testing
Arrange, Act, Assert
[Fact]
public void Withdraw_more_than_the_balance_is_refused()
{
// Arrange
var account = new Account();
account.Deposit(100);
// Act
var act = () => account.Withdraw(150);
// Assert
Assert.Throws<InvalidOperationException>(act);
Assert.Equal(100, account.Balance);
}
What good tests have in common (FIRST)
- Fast: thousands of them run in seconds, so you run them all the time.
- Independent: no test relies on another one running first.
- Repeatable: the same result on every machine. No real clock, network or random numbers; inject them (
IClock). - Self-validating: pass or fail, with no log to read.
- Timely: written with the code, not a month later.
What to test
Test behaviour through the public surface, not private methods. A test that breaks when you rename a private field is testing the implementation, and it will punish every refactoring. Use unit tests for rules, integration tests for the places where your code meets a database or another service, and a few end-to-end tests for the paths that earn the money.
Replace slow or unreliable collaborators with fakes, and only those. Mocking everything produces tests that pass while the real system is broken.
14. Readable code
- Names carry the meaning.
daysUntilExpiryneeds no comment;ddoes. Methods are verbs (CalculateTotal), booleans read as questions (IsActive,HasAccess). - Small methods, one level of abstraction. A method should either coordinate steps or do one step, not both.
- Guard clauses beat nesting. Handle the special cases first and return; the main path stays at the left margin.
- Few parameters. More than three is a sign that a type is hiding in the list.
- No magic numbers.
const int MaxLoginAttempts = 5;explains itself and changes in one place. - Comments say why, the code says what. If a comment repeats the code, delete it. If the code needs a comment to be understood, improve the code first.
- Follow the conventions. PascalCase for types and methods, camelCase for locals and parameters,
_camelCasefor private fields, anIprefix for interfaces, anAsyncsuffix for methods that return a task.
public decimal Calc(Order o)
{
if (o != null)
{
if (o.Lines.Count > 0)
{
if (!o.IsCancelled)
{
return o.Lines.Sum(l => l.Price * l.Quantity) * 1.2m;
}
}
}
return 0;
}
private const decimal VatRate = 0.20m;
public decimal TotalWithVat(Order order)
{
ArgumentNullException.ThrowIfNull(order);
if (order.IsCancelled || order.Lines.Count == 0) return 0;
var net = order.Lines.Sum(line => line.Price * line.Quantity);
return net * (1 + VatRate);
}
15. Review checklist
Before you call a change finished, read it once more with these questions:
- Can each class be described in one sentence without the word "and"?
- Can any object be put into an invalid state from outside?
- Does adding the next variant mean a new class, or editing a
switch? - Does every derived type keep every promise of its base type?
- Do the constructors show everything the class depends on?
- Is every
IDisposabledisposed by whoever created it? - Is there any
.Result,.Wait()orasync void? - Is any query enumerated twice, or materialised before it is filtered?
- Is any exception caught and ignored?
- Could a new teammate understand the names without asking you?
- Is there a test that would fail if the rule you just wrote were wrong?
- Is there anything here that nobody asked for?
None of these rules is the goal. The goal is software that people can change safely next year. The principles are the habits that make it likely.