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

Rule: an object guards its own state. Nobody outside can put it into a state that makes no sense.

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

Rule: show what something does and hide how it does it.

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

Rule: inherit only when the child truly is a kind of the parent, in every situation.

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

Rule: one call, many behaviours. The caller talks to the base type and the right implementation runs.
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

Rule: a class should have one reason to change.

"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

Rule: open for extension, closed for modification. Adding a feature should mean adding code, not editing code that already works.
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

Rule: anywhere the base type works, every derived type must work too, without the caller noticing.

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

Rule: many small interfaces beat one large one. Nobody should depend on methods they do not use.
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

Rule: business code depends on abstractions; the details depend on them too. The details are plugged in from outside.
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

Rule: know whether you are holding the thing or a reference to the thing.
Value type (struct, int, bool, DateTime, enums)Reference type (class, string, arrays, delegates)
Assignmentcopies the whole valuecopies the reference; both variables see the same object
Defaultall zerosnull
Equality by defaultby contentby identity (same object)
Good forsmall, immutable data such as a point or money amountanything 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

Rule: data that cannot change cannot be changed by mistake.

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

Rule: an interface says what something can do. An abstract class is a half-built thing that shares code with its children.
InterfaceAbstract class
How many can a class havemanyone base class
State (fields)noneyes
Constructorsnoyes
Typical usea capability: IComparable, IDisposable, IOrderRepositorya 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

Rule: write the algorithm once and let the compiler check it for every type.

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

Rule: behaviour can be passed around like data.

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

Rule: say what you want from a collection and let LINQ do the looping.
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:

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

9. async and await

Rule: while you wait for something outside the CPU (disk, network, database), give the thread back.

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

  1. Async all the way. Never block on async code with .Result or .Wait(). It wastes a thread at best and deadlocks at worst.
  2. Return Task, not void. async void cannot be awaited and its exceptions crash the process. The only exception is event handlers.
  3. Pass a CancellationToken through every async call, so abandoned work can stop.
  4. Run independent work together with Task.WhenAll rather than awaiting one after another.
  5. Do not wrap synchronous code in Task.Run inside a web request to "make it async". It only moves the work to another thread from the same pool.
  6. 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

Rule: throw for things that should not happen, catch only what you can handle, and never lose the original error.
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

Rule: whoever creates a disposable object is responsible for disposing it.

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

12. Dependency injection

Rule: a class asks for what it needs in its constructor. It does not go and fetch it.

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

LifetimeOne instance perUse for
Singletonapplicationstateless services, caches, configuration. Must be thread-safe.
Scopedrequestanything tied to one unit of work, such as a DbContext
Transientresolutionsmall, 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

Rule: a test proves one behaviour, reads like a sentence and fails for exactly one reason.

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)

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

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:

  1. Can each class be described in one sentence without the word "and"?
  2. Can any object be put into an invalid state from outside?
  3. Does adding the next variant mean a new class, or editing a switch?
  4. Does every derived type keep every promise of its base type?
  5. Do the constructors show everything the class depends on?
  6. Is every IDisposable disposed by whoever created it?
  7. Is there any .Result, .Wait() or async void?
  8. Is any query enumerated twice, or materialised before it is filtered?
  9. Is any exception caught and ignored?
  10. Could a new teammate understand the names without asking you?
  11. Is there a test that would fail if the rule you just wrote were wrong?
  12. 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.