C# & .NET

My C# Revision Journal for Modern .NET Development

Afzal AhmedFaz Ahmed
·27 July 2026·18 min read
C#.NETOOPSOLIDLINQAsync/AwaitEF CoreASP.NET CoreWeb API

Why This Matters

Focused revision notes connecting core C# knowledge to modern .NET work: types, OOP, SOLID, delegates, LINQ, async, EF Core, ASP.NET Core and APIs.

Imagine we are sitting down together for a focused C# mentoring session.

I am not going to bury you in every language feature or ask you to memorise an API reference. My aim is to help you connect the fundamentals to the way modern .NET applications are actually built: object-oriented design, delegates and lambdas, collections and LINQ, asynchronous code, Entity Framework Core, ASP.NET Core, API clients and web services.

Whether you are preparing for an exam, an interview or your next production feature, I want you to reach the point where you can look at a piece of C# and explain what it is doing, why the design exists and how it will behave at runtime.

Let’s work through it together.


Chapter 1: Hello C# — the foundation

First, C# runs on .NET. Think of C# as the language and .NET as the platform that gives you the compiler, runtime, libraries, project system, CLI, web framework, dependency injection, file handling, async support, and much more.

When you create a console app:

dotnet new console -n RevisionApp
dotnet run --project RevisionApp

you are asking the .NET CLI to create a project, compile it, and run it.

Modern C# allows top-level statements, so instead of writing a full Program class and Main method, you can write:

Console.WriteLine("Hello, C# revision!");

But underneath, the runtime still needs an entry point. The compiler simply generates the surrounding structure for you.

Now let’s revise variables.

string name = "Faz";
int age = 55;
decimal hourlyRate = 75.50m;
bool isAvailable = true;

Console.WriteLine($"Name: {name}, Age: {age}, Rate: {hourlyRate}, Available: {isAvailable}");

A variable has a type, a name, and a value. C# is strongly typed, meaning once age is an int, you cannot suddenly store "hello" inside it.

You can use var:

var city = "London";     // compiler infers string
var count = 10;          // compiler infers int
var price = 19.99m;      // compiler infers decimal

But var does not mean dynamic. The compiler still knows the real type.

Strings are important. They are reference types, but they behave specially because they are immutable. When you “change” a string, you are actually creating a new string.

string greeting = "Hello World";
string updated = greeting.Replace("World", "C#");

Console.WriteLine(greeting); // Hello World
Console.WriteLine(updated);  // Hello C#

That is exam-worthy. A method like Replace does not modify the original string. It returns a new one.

For numbers, remember the practical rule:

Use int for normal whole numbers. Use long for very large whole numbers. Use double for scientific/general decimal calculation. Use decimal for money and finance.

decimal loanAmount = 250000.00m;
decimal interestRate = 0.0525m;
decimal yearlyInterest = loanAmount * interestRate;

Console.WriteLine($"Yearly interest: {yearlyInterest:C}");

The m suffix matters. Without it, C# treats decimal-looking numbers as double.

Now conditions:

int score = 78;

if (score >= 80)
{
    Console.WriteLine("Excellent");
}
else if (score >= 60)
{
    Console.WriteLine("Passed");
}
else
{
    Console.WriteLine("Failed");
}

Use if when logic is more flexible. Use switch when checking one value against known cases:

string role = "Admin";

switch (role)
{
    case "Admin":
        Console.WriteLine("Full access");
        break;

    case "Manager":
        Console.WriteLine("Team access");
        break;

    default:
        Console.WriteLine("Basic access");
        break;
}

Loops are repeated execution.

string[] modules = { "C#", "OOP", "LINQ", "Async", "Web API" };

for (int i = 0; i < modules.Length; i++)
{
    Console.WriteLine($"{i + 1}. {modules[i]}");
}

foreach (var module in modules)
{
    Console.WriteLine($"Revise: {module}");
}

Use for when you need the index. Use foreach when you just need each item.

Now value types and reference types. This is one of the most important early C# concepts.

int a = 10;
int b = a;
b = 20;

Console.WriteLine(a); // 10
Console.WriteLine(b); // 20

int is a value type. The value is copied.

But classes are reference types:

public class Person
{
    public string Name { get; set; } = "";
}

Person p1 = new Person { Name = "Ali" };
Person p2 = p1;

p2.Name = "Ahmed";

Console.WriteLine(p1.Name); // Ahmed
Console.WriteLine(p2.Name); // Ahmed

Both variables point to the same object in memory. In exam language: value types copy the value; reference types copy the reference.

Exceptions are for unexpected failure.

try
{
    Console.Write("Enter number: ");
    int number = int.Parse(Console.ReadLine()!);

    Console.WriteLine(100 / number);
}
catch (FormatException)
{
    Console.WriteLine("You did not enter a valid number.");
}
catch (DivideByZeroException)
{
    Console.WriteLine("Cannot divide by zero.");
}

My advice is not to catch everything blindly. Catch the failures you can genuinely handle.


Chapter 2: Building quality object-oriented code

Now we move from “writing statements” to “designing code.”

A class is a blueprint. An object is an instance.

public class LoanApplication
{
    public int Id { get; }
    public string ApplicantName { get; private set; }
    public decimal RequestedAmount { get; private set; }
    public string Status { get; private set; }

    public LoanApplication(int id, string applicantName, decimal requestedAmount)
    {
        if (string.IsNullOrWhiteSpace(applicantName))
            throw new ArgumentException("Applicant name is required.");

        if (requestedAmount <= 0)
            throw new ArgumentException("Requested amount must be greater than zero.");

        Id = id;
        ApplicantName = applicantName;
        RequestedAmount = requestedAmount;
        Status = "Submitted";
    }

    public void Approve()
    {
        if (Status != "Submitted")
            throw new InvalidOperationException("Only submitted applications can be approved.");

        Status = "Approved";
    }

    public void Reject()
    {
        if (Status != "Submitted")
            throw new InvalidOperationException("Only submitted applications can be rejected.");

        Status = "Rejected";
    }
}

This is more than syntax. This is object-oriented thinking.

The class protects itself. You cannot create a loan application with no name. You cannot approve an already rejected application. You do not expose Status freely to the whole world.

That is encapsulation.

The four pillars of OOP are:

Encapsulation: protect data and expose controlled behaviour. Inheritance: reuse and specialise behaviour from a base class. Polymorphism: treat different concrete types through a common abstraction. Abstraction: expose what matters, hide internal details.

Example:

public abstract class Notification
{
    public string Recipient { get; }

    protected Notification(string recipient)
    {
        Recipient = recipient;
    }

    public abstract void Send(string message);
}

public class EmailNotification : Notification
{
    public EmailNotification(string recipient) : base(recipient) { }

    public override void Send(string message)
    {
        Console.WriteLine($"Email to {Recipient}: {message}");
    }
}

public class SmsNotification : Notification
{
    public SmsNotification(string recipient) : base(recipient) { }

    public override void Send(string message)
    {
        Console.WriteLine($"SMS to {Recipient}: {message}");
    }
}

Usage:

List<Notification> notifications =
[
    new EmailNotification("user@example.com"),
    new SmsNotification("+441234567890")
];

foreach (var notification in notifications)
{
    notification.Send("Your application has been approved.");
}

That is polymorphism. The calling code does not care whether it is email or SMS. It knows each notification can Send.

Interfaces are contracts:

public interface IPaymentProcessor
{
    Task<bool> ProcessAsync(decimal amount);
}

public class StripePaymentProcessor : IPaymentProcessor
{
    public Task<bool> ProcessAsync(decimal amount)
    {
        Console.WriteLine($"Processing {amount:C} using Stripe...");
        return Task.FromResult(true);
    }
}

A class says, “I promise I can do this.”

SOLID becomes useful when it helps us move from code that merely works to code that can be changed safely.

Single Responsibility: one class should have one reason to change. Open/Closed: open for extension, closed for modification. Liskov Substitution: child classes should safely replace parent classes. Interface Segregation: do not force classes to implement methods they do not need. Dependency Inversion: depend on abstractions, not concrete classes.

Bad:

public class OrderService
{
    private StripePaymentProcessor _processor = new StripePaymentProcessor();
}

Better:

public class OrderService
{
    private readonly IPaymentProcessor _paymentProcessor;

    public OrderService(IPaymentProcessor paymentProcessor)
    {
        _paymentProcessor = paymentProcessor;
    }

    public async Task PlaceOrderAsync(decimal amount)
    {
        bool paid = await _paymentProcessor.ProcessAsync(amount);

        if (!paid)
            throw new InvalidOperationException("Payment failed.");

        Console.WriteLine("Order placed.");
    }
}

Now OrderService depends on an interface. That makes it testable, flexible, and clean.

Also revise modern C# features:

public record CustomerDto(int Id, string Name, string Email);

var customer = new CustomerDto(1, "Faz", "faz@example.com");

var (id, name, email) = customer;

Console.WriteLine($"{id}: {name} - {email}");

Records are excellent for immutable data transfer objects.

Generics let you write reusable type-safe code:

public class Result<T>
{
    public bool Success { get; }
    public T? Data { get; }
    public string? Error { get; }

    private Result(bool success, T? data, string? error)
    {
        Success = success;
        Data = data;
        Error = error;
    }

    public static Result<T> Ok(T data) => new(true, data, null);
    public static Result<T> Fail(string error) => new(false, default, error);
}

This is reusable for Result, Result, Result>, and so on.


Chapter 3: Delegates, events, and lambdas

Now we enter the “functions as values” world.

A delegate is a type-safe reference to a method.

public delegate bool LoanRule(LoanApplication application);

public static bool AmountMustBeReasonable(LoanApplication app)
{
    return app.RequestedAmount <= 500000;
}

Usage:

LoanRule rule = AmountMustBeReasonable;

bool allowed = rule(new LoanApplication(1, "Sara", 250000));
Console.WriteLine(allowed);

In modern C#, you often use Func and Action.

Func returns a value:

Func<int, int, int> add = (x, y) => x + y;
Console.WriteLine(add(10, 20)); // 30

Action returns nothing:

Action<string> log = message => Console.WriteLine($"LOG: {message}");
log("Application started");

A lambda is short syntax for an inline function.

var highValueLoans = loans.Where(loan => loan.RequestedAmount > 100000);

The part loan => loan.RequestedAmount > 100000 means:

“Given a loan, return true if its requested amount is greater than 100000.”

Events are the publisher-subscriber model.

public class FileDownloader
{
    public event Action<string>? FileDownloaded;

    public void Download(string fileName)
    {
        Console.WriteLine($"Downloading {fileName}...");
        FileDownloaded?.Invoke(fileName);
    }
}

var downloader = new FileDownloader();

downloader.FileDownloaded += fileName =>
{
    Console.WriteLine($"Notification: {fileName} has been downloaded.");
};

downloader.Download("report.pdf");

The downloader publishes an event. Other code subscribes.

One distinction worth remembering is that events are built on delegates, but restrict how outside code can interact. Outside code can subscribe and unsubscribe, but cannot directly raise the event.

Closures are another key idea.

int threshold = 100000;

Func<LoanApplication, bool> isLargeLoan =
    loan => loan.RequestedAmount > threshold;

The lambda captures threshold. That captured variable stays available to the lambda. Useful, but dangerous if you accidentally capture changing loop variables.


Chapter 4: Data structures and LINQ

Collections are how we hold multiple values.

Use List when you need an ordered growable collection:

var names = new List<string> { "Ali", "Sara", "John" };
names.Add("Mary");

Use Dictionary when you need fast lookup by key:

var userRoles = new Dictionary<string, string>
{
    ["faz@example.com"] = "Admin",
    ["user@example.com"] = "User"
};

Console.WriteLine(userRoles["faz@example.com"]);

Use HashSet when uniqueness matters:

var permissions = new HashSet<string>();

permissions.Add("Users.Read");
permissions.Add("Users.Read");
permissions.Add("Users.Write");

Console.WriteLine(permissions.Count); // 2

Use Queue for first-in, first-out:

var queue = new Queue<string>();
queue.Enqueue("Job1");
queue.Enqueue("Job2");

Console.WriteLine(queue.Dequeue()); // Job1

Use Stack for last-in, first-out:

var stack = new Stack<string>();
stack.Push("Page1");
stack.Push("Page2");

Console.WriteLine(stack.Pop()); // Page2

LINQ is one of the most important C# skills. It allows you to query collections clearly.

public record Product(int Id, string Name, string Category, decimal Price);

var products = new List<Product>
{
    new(1, "Laptop", "Tech", 1200),
    new(2, "Mouse", "Tech", 25),
    new(3, "Desk", "Office", 300),
    new(4, "Chair", "Office", 150)
};

var expensiveTechProducts = products
    .Where(p => p.Category == "Tech")
    .Where(p => p.Price > 100)
    .OrderByDescending(p => p.Price)
    .Select(p => new
    {
        p.Name,
        p.Price
    })
    .ToList();

foreach (var item in expensiveTechProducts)
{
    Console.WriteLine($"{item.Name}: {item.Price:C}");
}

Key LINQ operators:

Where filters. Select projects. OrderBy sorts. GroupBy groups. Any checks if at least one matches. All checks if all match. FirstOrDefault returns first match or default. ToList executes and materialises.

Deferred execution is one of the LINQ behaviours I always make sure a developer understands.

var query = products.Where(p => p.Price > 100);

// Query has not executed yet.

var result = query.ToList();

// Now it executes.

Until you enumerate it, LINQ is often just a query definition.

Group example:

var totalsByCategory = products
    .GroupBy(p => p.Category)
    .Select(group => new
    {
        Category = group.Key,
        Count = group.Count(),
        AveragePrice = group.Average(p => p.Price)
    });

foreach (var item in totalsByCategory)
{
    Console.WriteLine($"{item.Category}: {item.Count} items, Avg {item.AveragePrice:C}");
}

My rule of thumb: LINQ is expressive, but I do not hide expensive logic inside it. I want to know when a query runs and what work it performs.


Chapter 5: Concurrency, parallelism, and async code

This is where the terminology can easily become confusing.

Concurrency means dealing with multiple operations in overlapping time. Parallelism means literally doing multiple things at the same time on multiple cores. Async/await means not blocking a thread while waiting for something.

Example:

public async Task<string> GetCustomerAsync(int id)
{
    await Task.Delay(1000); // pretending to call database/API
    return $"Customer {id}";
}

Call it:

string customer = await GetCustomerAsync(10);
Console.WriteLine(customer);

await does not mean “start a new thread.” It means: pause this method until the awaited operation completes, and let the thread go do other work.

Task.WhenAll is useful when independent operations can run together.

Task<string> customerTask = GetCustomerAsync(1);
Task<string> orderTask = GetCustomerAsync(2);
Task<string> invoiceTask = GetCustomerAsync(3);

string[] results = await Task.WhenAll(customerTask, orderTask, invoiceTask);

foreach (var result in results)
{
    Console.WriteLine(result);
}

Cancellation:

public async Task ProcessLargeFileAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 100; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();

        await Task.Delay(100, cancellationToken);
        Console.WriteLine($"Processed chunk {i}");
    }
}

Exception handling in async:

try
{
    await ProcessLargeFileAsync(CancellationToken.None);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operation was cancelled.");
}
catch (Exception ex)
{
    Console.WriteLine($"Unexpected error: {ex.Message}");
}

Parallel example:

Parallel.ForEach(products, product =>
{
    Console.WriteLine($"Processing product {product.Name} on thread {Environment.CurrentManagedThreadId}");
});

The distinction I teach is to use parallelism deliberately for CPU-bound work and asynchronous APIs for I/O-bound work such as database calls, HTTP calls, file reads and message queues.

Do not use Task.Run around every async database call. That usually shows misunderstanding.


Chapter 6: Entity Framework with SQL Server

Entity Framework is an ORM: object-relational mapper. It maps C# classes to database tables.

public class AppDbContext : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Order> Orders => Set<Order>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Order> Orders { get; set; } = new();
}

public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
    public int CustomerId { get; set; }
    public Customer? Customer { get; set; }
}

Query:

var customers = await dbContext.Customers
    .Where(c => c.Name.Contains("Ali"))
    .ToListAsync();

Important distinction:

IQueryable means query can still be translated to SQL. IEnumerable means you are likely working in memory.

Bad:

var customers = dbContext.Customers.AsEnumerable()
    .Where(c => c.Name.Contains("Ali"))
    .ToList();

This may pull too much data into memory.

Better:

var customers = await dbContext.Customers
    .Where(c => c.Name.Contains("Ali"))
    .ToListAsync();

Read-only queries:

var summaries = await dbContext.Customers
    .AsNoTracking()
    .Select(c => new
    {
        c.Id,
        c.Name,
        OrderCount = c.Orders.Count
    })
    .ToListAsync();

Use AsNoTracking when you do not intend to update the entity. It reduces tracking overhead.

Avoid lazy-loading surprises. Prefer projection for read screens:

var customerOrders = await dbContext.Customers
    .Where(c => c.Id == customerId)
    .Select(c => new CustomerSummaryDto
    {
        CustomerId = c.Id,
        Name = c.Name,
        TotalOrders = c.Orders.Count,
        TotalSpent = c.Orders.Sum(o => o.Total)
    })
    .SingleAsync();

Repository pattern can help, but do not blindly wrap EF in generic repositories if it hides useful EF features. For enterprise code, separate domain models, DTOs, commands, queries, and persistence concerns clearly.


Chapter 7: Modern web applications with ASP.NET

ASP.NET applications have a pipeline.

Request comes in. Middleware processes it. Routing selects endpoint. Model binding builds inputs. Validation runs. Handler/controller executes. Response goes back.

Middleware example:

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("Request: {Method} {Path}",
            context.Request.Method,
            context.Request.Path);

        await _next(context);

        _logger.LogInformation("Response: {StatusCode}",
            context.Response.StatusCode);
    }
}

Register:

app.UseMiddleware<RequestLoggingMiddleware>();

Dependency injection lifetimes:

Singleton: one instance for whole application lifetime. Scoped: one instance per request. Transient: new instance every time requested.

One lifetime rule I emphasise is not to inject scoped services directly into singleton services.

Razor Pages are page-focused server-rendered web apps. A page has markup and a PageModel.

public class CreateTaskModel : PageModel
{
    [BindProperty]
    public string Title { get; set; } = "";

    public void OnGet()
    {
    }

    public IActionResult OnPost()
    {
        if (string.IsNullOrWhiteSpace(Title))
        {
            ModelState.AddModelError(nameof(Title), "Title is required.");
            return Page();
        }

        return RedirectToPage("Index");
    }
}

Important concepts: model binding, validation, page handlers, partial pages, tag helpers, and view components.


Chapter 8: Creating and using Web API clients

Most modern systems call other systems.

Use HttpClient, but understand it properly.

Bad: creating and disposing HttpClient everywhere.

Better in ASP.NET Core:

builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com/");
});

Client:

public class WeatherApiClient
{
    private readonly HttpClient _httpClient;

    public WeatherApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<string> GetWeatherAsync(string city)
    {
        var response = await _httpClient.GetAsync($"weather?city={city}");

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Authentication may use API keys, bearer tokens, basic auth, or OAuth2.

request.Headers.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

Idempotency matters.

GET should read. POST usually creates or triggers. PUT replaces. PATCH partially updates. DELETE deletes.

Idempotent means that calling the same operation multiple times produces the same final result. It is a small word with an important effect on API design.


Chapter 9: Creating API services

A Web API exposes functionality over HTTP.

Controller example:

[ApiController]
[Route("api/customers")]
public class CustomersController : ControllerBase
{
    private readonly ICustomerService _customerService;

    public CustomersController(ICustomerService customerService)
    {
        _customerService = customerService;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<CustomerDto>> GetById(int id)
    {
        var customer = await _customerService.GetByIdAsync(id);

        if (customer is null)
            return NotFound();

        return Ok(customer);
    }

    [HttpPost]
    public async Task<ActionResult<CustomerDto>> Create(CreateCustomerRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        var created = await _customerService.CreateAsync(request);

        return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
    }
}

Status codes matter.

200 OK: successful read/update. 201 Created: resource created. 400 Bad Request: invalid request. 401 Unauthorized: not authenticated. 403 Forbidden: authenticated but not allowed. 404 Not Found: resource missing. 500 Internal Server Error: unexpected server failure.

Swagger/OpenAPI documents your API and allows testing.

DTOs protect your domain model from direct exposure:

public record CustomerDto(int Id, string Name, string Email);
public record CreateCustomerRequest(string Name, string Email);

Security basics:

JWT is a token format commonly used for API authentication. OpenID Connect sits on top of OAuth2 for identity. Azure Active Directory, now commonly Microsoft Entra ID, can issue tokens. Your API validates tokens before allowing protected access.

Minimal API example:

app.MapGet("/api/time", () =>
{
    return Results.Ok(new
    {
        UtcNow = DateTime.UtcNow
    });
});

Microservices are separate deployable services around business capability. Do not call something a microservice just because it is a small API. A real microservice owns its responsibility, data boundary, deployment lifecycle, and integration contracts.

Azure Functions are serverless functions. They are useful for event-driven workloads, file processing, scheduled jobs, lightweight APIs, and background processing.


Chapter 10: The senior revision exercise — allocate an order safely

The first nine chapters remind you what the tools are. Senior revision asks a harder question: can you combine them without hiding correctness, performance or operational risk?

We will review a small feature in a warehouse application. A client requests allocation of stock to an order. The system must reject invalid quantities, prevent duplicate requests, avoid overselling under concurrency, call a pricing dependency within a deadline, persist the allocation and return an honest HTTP result.

Junior: This sounds larger than a syntax exercise.
>
Senior: Exactly. C# expertise is visible in the decisions around the syntax: which states are representable, who owns mutation, how cancellation flows, where allocations occur, and what the API promises when delivery is uncertain.
Begin with requirements and invariants:
  • quantity must be positive;
  • an order line identifies one product;
  • available stock cannot become negative;
  • the same request ID cannot allocate twice;
  • a stale stock version must not overwrite a newer one;
  • cancellation before commitment may abandon work;
  • loss of the HTTP response after commitment must not repeat the allocation.
Those statements give code review an objective. Without them, reviewers argue about style while missing the business rule.

Chapter 11: Make invalid states difficult to express

Primitive obsession passes strings, integers and decimals whose meaning exists only in variable names. Introduce small value types where they protect an important rule.

public readonly record struct ProductId(Guid Value)
{
    public static ProductId Create(Guid value) =>
        value == Guid.Empty
            ? throw new ArgumentException("A product ID is required.", nameof(value))
            : new(value);
}

public readonly record struct Quantity
{
    public int Value { get; }

    private Quantity(int value) => Value = value;

    public static Quantity Create(int value) =>
        value > 0
            ? new Quantity(value)
            : throw new ArgumentOutOfRangeException(
                nameof(value), "Quantity must be positive.");
}

readonly record struct gives value semantics and discourages mutation. It does not automatically make every design good. Too many wrappers can make serialisation, EF mapping and debugging noisy. Use them for concepts whose rules or accidental interchange matter. A warehouse bin ID and a product ID may both contain a Guid, but passing one in place of the other is a real defect.

Do not let default values quietly bypass invariants. A struct always has a default value, so decide whether default(ProductId) can reach important code. Validate at deserialisation/application boundaries or use a representation whose construction rules match the risk.

Nullability is a contract, not decoration

With nullable reference types enabled, string says callers should provide a value and string? says absence is expected. The compiler performs static analysis; it does not insert runtime validation.

public sealed record AllocateStockRequest(
    Guid RequestId,
    Guid ProductId,
    int Quantity,
    string? ClientReference);

The HTTP boundary still validates empty GUIDs, range, length and malformed input. Avoid the null-forgiving operator merely to silence a warning:

// Warning suppressed, bug preserved.
var length = request.ClientReference!.Length;

Use pattern matching to narrow truth:

var referenceLength = request.ClientReference is { Length: > 0 } reference
    ? reference.Length
    : 0;

Annotate public APIs accurately and fix warnings at their source. A project containing hundreds of casual ! operators has opted out of useful design feedback.

Junior: Should every domain concept be a record?
>
Senior: Choose semantics. Records are helpful for value-like data and immutable messages. An entity with identity, lifecycle and controlled mutation is usually better represented by a class that protects its invariants.

Chapter 12: Entity behaviour and encapsulation

The stock item owns the rule that availability cannot become negative:

public sealed class StockItem
{
    private StockItem() { } // Persistence constructor.

    public ProductId ProductId { get; private set; }
    public int OnHand { get; private set; }
    public int Allocated { get; private set; }
    public long Version { get; private set; }

    public int Available => OnHand - Allocated;

    public Allocation Allocate(Quantity quantity, Guid requestId, IClock clock)
    {
        if (quantity.Value > Available)
            throw new InsufficientStockException(ProductId, quantity, Available);

        Allocated += quantity.Value;
        Version++;

        return Allocation.Create(
            requestId, ProductId, quantity, Version, clock.UtcNow);
    }
}

The setters are not public because arbitrary code must not put the entity into an impossible state. The application service coordinates loading and persistence; it should not reproduce the arithmetic.

Inject time through a small abstraction or TimeProvider so tests do not depend on wall-clock timing. Avoid static mutable clocks and global service locators. The domain method receives what it needs explicitly.

Exceptions can represent exceptional domain rejection when the surrounding style handles them consistently, but an explicit result is often clearer for expected outcomes:

public abstract record AllocationAttempt
{
    public sealed record Success(Allocation Value) : AllocationAttempt;
    public sealed record Insufficient(int Requested, int Available) : AllocationAttempt;
}

Do not use null, false and exceptions interchangeably for the same operation. A caller should be able to see the meaningful outcomes in the signature and handle them exhaustively.

Encapsulation does not mean putting every line inside an entity. Pricing calls, database transactions and message publication are application/infrastructure concerns. The entity protects local business invariants; the application service sequences the use case.

Chapter 13: Equality, records and collection keys

Records generate value-oriented equality from their members. Classes use reference equality unless overridden. This matters in dictionaries, sets, tests and change tracking.

var a = new AllocateStockRequest(id, product, 2, "WEB-42");
var b = new AllocateStockRequest(id, product, 2, "WEB-42");

Console.WriteLine(a == b); // True for this record's values.

Never mutate data participating in a hash code while it is used as a Dictionary key or HashSet member. The collection may no longer find it in the bucket chosen at insertion.

Choose the collection for operations:

  • List for ordered iteration and indexed access;
  • Dictionary for key lookup;
  • HashSet for uniqueness and membership;
  • Queue for first-in-first-out work;
  • Stack for last-in-first-out traversal;
  • concurrent collections only when shared concurrent access is part of the design.
A dictionary improves average lookup but adds memory and requires a stable key. Replacing every list with a dictionary is not optimisation. Measure the data size and access pattern.

Expose the narrowest useful type. Returning IEnumerable can hide whether work is deferred; returning IReadOnlyList communicates materialised ordered data. It does not make mutable elements immutable, and a cast may expose the underlying collection. Contract intent and enforcement are related but not identical.

Chapter 14: LINQ — read the execution model

LINQ syntax looks uniform across IEnumerable and IQueryable, but execution is different. Enumerable operators execute .NET delegates in memory. Queryable operators build expression trees that a provider attempts to translate.

IQueryable<StockItem> query = db.StockItems
    .Where(x => requestedProductIds.Contains(x.ProductId));

var summaries = await query
    .Select(x => new StockSummary(
        x.ProductId,
        x.OnHand,
        x.Allocated))
    .ToListAsync(cancellationToken);

Until materialisation, the query is a description. ToListAsync executes it. Inspect generated SQL and query plans for important paths.

A dangerous boundary is hidden materialisation:

// Pulls every row before filtering.
var items = (await repository.GetAllAsync())
    .Where(x => x.Available > 0)
    .Take(20);

Prefer a repository/query method that accepts the business filter or a specification translated by the provider. Do not expose IQueryable across architectural layers casually; it leaks persistence capabilities and lets callers construct unreviewed queries.

Deferred execution surprises

var available = items.Where(x => x.Available > 0);

foreach (var item in available) { /* first enumeration */ }
foreach (var item in available) { /* query may run again */ }

Materialise once when you need a stable snapshot or multiple iterations. Keep deferred execution when streaming or query composition is intentional. The senior answer is neither “always call ToList” nor “never allocate a list.” It is to know when execution occurs and what consistency is required.

Be cautious with First, Single, and their OrDefault variants. Single asserts exactly one result and throws for zero or many; First permits many. Select the method that expresses the invariant. Do not catch InvalidOperationException later to infer which condition occurred.

Chapter 15: Async is resource coordination

An allocation use case awaits database and network I/O. async does not create a background thread for that wait. It allows the current thread to return while the operation completes.

public async Task<AllocationResult> AllocateAsync(
    AllocateStock command,
    CancellationToken cancellationToken)
{
    var quoteTask = pricing.GetQuoteAsync(
        command.ProductId, cancellationToken);

    var stock = await stockRepository.GetForUpdateAsync(
        command.ProductId, cancellationToken);

    var quote = await quoteTask;
    // Apply rules and commit...
}

Starting independent operations before awaiting can reduce latency, but it also changes resource use and failure behaviour. Do not parallelise two operations through the same EF Core DbContext; it does not support concurrent operations. Do not start unbounded tasks for thousands of products.

Propagate cancellation through APIs. A cancellation token is a request, not a forced thread abort. Code must observe it, and external side effects may already have happened.

Use a point-of-no-cancellation concept. Before the transaction commits, cancellation may safely abandon the use case. After commit, throwing cancellation to the HTTP caller can make a completed allocation look unsuccessful. Continue required reliable publication or return/reconcile the committed result according to the architecture.

Junior: Should every method be async?
>
Senior: Methods should be async when they coordinate asynchronous work. Do not wrap CPU work in Task.Run inside ASP.NET Core merely to look asynchronous. That consumes another thread and can reduce throughput.
Avoid .Result, .Wait() and blocking waits. They can deadlock in some environments and always tie up a thread. In server applications, blocking under load causes thread-pool starvation and cascading latency.

Return Task, not async void, except for event signatures that require it. A task makes completion and exceptions observable. If work must outlive an HTTP request, put it onto a durable background queue rather than starting a fire-and-forget task that process shutdown can lose.

Task failure and coordination

When awaiting multiple tasks, decide whether one failure should cancel siblings, whether partial results are useful, and how exceptions are reported. Task.WhenAll is not an error-handling policy.

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    cancellationToken);
linkedCts.CancelAfter(TimeSpan.FromSeconds(2));

try
{
    var results = await Task.WhenAll(
        warehouses.Select(w => w.GetAvailabilityAsync(
            productId, linkedCts.Token)));
}
catch (OperationCanceledException) when (linkedCts.IsCancellationRequested)
{
    // Distinguish caller cancellation from our deadline if the UX needs it.
}

Bound concurrency with Parallel.ForEachAsync, a semaphore, channel or worker pool when calling many dependencies. Limits protect sockets, databases and the remote service.

Chapter 16: Concurrency is about shared truth

Two requests can both read Available = 1 and allocate the last unit. A lock in one web process cannot protect against another instance or an external writer.

Use database concurrency appropriate to the invariant. Optimistic concurrency detects that the row changed:

modelBuilder.Entity<StockItem>()
    .Property(x => x.Version)
    .IsConcurrencyToken();

The update includes the original version. If no row matches, EF Core raises a concurrency exception. Translate it to a meaningful result, reload state and decide whether automatic retry is safe. Re-running an allocation may choose a different outcome; never hide that business change inside a generic retry.

For a highly contended counter, an atomic database statement, suitable isolation level or purpose-built reservation model may work better. Test using the production database engine because in-memory substitutes do not reproduce locking and isolation.

Thread safety still matters for in-process shared state. A singleton service is shared across requests and must not hold an ordinary mutable dictionary without coordination. Prefer immutability or concurrent primitives, and minimise shared mutation. ConcurrentDictionary makes individual operations thread-safe; a multi-step “check then update” sequence may still need an atomic method such as GetOrAdd or external coordination.

lock protects synchronous critical sections. Do not await inside a lock; use SemaphoreSlim for asynchronous coordination where an in-process gate is genuinely appropriate. Always release it in finally:

await gate.WaitAsync(cancellationToken);
try
{
    await RefreshCacheAsync(cancellationToken);
}
finally
{
    gate.Release();
}

Remember the scope: this gate protects one process, not the fleet.

Chapter 17: Resource ownership and deterministic cleanup

IDisposable represents deterministic cleanup, not necessarily unmanaged memory directly. Streams, database transactions, timers and cancellation sources can own resources that should be released promptly.

await using var transaction =
    await db.Database.BeginTransactionAsync(cancellationToken);

// Make related persistence changes.

await transaction.CommitAsync(cancellationToken);

using compiles to try/finally semantics. await using supports asynchronous cleanup through IAsyncDisposable.

Do not dispose objects owned by dependency injection. The container disposes instances it creates at the end of their lifetime. Conversely, if your code creates a stream or cancellation source, make ownership clear and dispose it.

Finalisers are a last-resort safety net for types directly owning unmanaged resources. Prefer SafeHandle and the established dispose pattern rather than inventing one. Most application types need no finaliser.

Watch closures and event subscriptions. A long-lived publisher holding a delegate can keep its subscriber alive. Unsubscribe or use a lifecycle-aware design. Lambdas capture variables, sometimes allocating a closure and extending object lifetime. This is normally fine; it becomes important in hot paths or long-lived registrations.

Chapter 18: Exceptions and result boundaries

Throw exceptions for situations a method cannot satisfy, not for ordinary branching in a hot loop. Preserve the original stack when rethrowing:

catch (SqlException ex)
{
    logger.LogError(ex, "Allocation persistence failed");
    throw; // Not: throw ex;
}

Catch at the level that can add context, translate to a stable abstraction or recover. Logging and rethrowing at every layer creates duplicate noise. A global ASP.NET Core exception handler can map unexpected exceptions to safe problem details while domain/application results map expected conflicts and validation.

Never expose exception messages as an API contract. They may reveal internals and change during refactoring. Use status codes and stable error codes.

Custom exceptions should carry useful structured context without sensitive data. They should not become one class per possible message. If callers routinely switch on an exception type for expected outcomes, consider a result type.

Exception filters can discriminate without catching broadly:

catch (OperationCanceledException)
    when (cancellationToken.IsCancellationRequested)
{
    return AllocationResult.CancelledByCaller();
}

Do not turn OperationCanceledException into a 500. Cancellation is a separate outcome. Also do not assume every cancellation means the original caller disconnected; a linked deadline token may have expired.

Chapter 19: EF Core unit of work without lifetime mistakes

DbContext represents a short-lived unit of work. It tracks entity changes and is not thread-safe. In a typical HTTP request, scoped lifetime is useful, but long-running workers or interactive circuits often create a context per operation through IDbContextFactory.

For read paths, project only needed columns and consider AsNoTracking:

var result = await db.StockItems
    .AsNoTracking()
    .Where(x => x.ProductId == productId)
    .Select(x => new StockAvailabilityDto(
        x.ProductId.Value,
        x.OnHand - x.Allocated,
        x.Version))
    .SingleOrDefaultAsync(cancellationToken);

Tracking is valuable when loading an aggregate to update it. Do not apply AsNoTracking automatically everywhere, then attach a graph and mark every property modified.

Avoid N+1 queries by shaping queries and inspecting SQL. Include is not always the answer: large joined graphs can create cartesian growth. Projection, split queries or separate bounded queries may be clearer. Measure with realistic cardinality.

Transactions protect local state. If an allocation and an outbox event must commit together, store both in one database transaction. An HTTP call or message broker publish is not part of that local transaction. Use an outbox rather than holding database locks while waiting on the network.

Migration design must allow rolling deployment. Add compatible schema first, deploy code that supports both forms, backfill observably, switch usage and remove old schema later.

Chapter 20: Design the HTTP boundary honestly

Our endpoint translates transport input into valid application concepts:

app.MapPost("/api/allocations", async (
    AllocateStockRequest request,
    IStockAllocator allocator,
    CancellationToken cancellationToken) =>
{
    var command = AllocateStock.TryCreate(request);
    if (!command.IsSuccess)
        return Results.ValidationProblem(command.Errors);

    var result = await allocator.AllocateAsync(
        command.Value, cancellationToken);

    return result switch
    {
        AllocationResult.Created created => Results.Created(
            $"/api/allocations/{created.Id}", created.Value),
        AllocationResult.Duplicate existing => Results.Ok(existing.Value),
        AllocationResult.Insufficient insufficient => Results.Conflict(
            new { code = "insufficient_stock", insufficient.Available }),
        AllocationResult.Stale => Results.Conflict(
            new { code = "stock_version_conflict" }),
        _ => Results.Problem(statusCode: 500)
    };
});

The sample abbreviates custom result APIs, but the mapping is explicit. 201 Created describes a new resource. A duplicate idempotent request can return the original result. A stock conflict is not malformed JSON and should not be reported as 400 merely because that is convenient.

Authenticate, then authorise the actual warehouse or tenant resource. Hiding the endpoint in Swagger or the button in a SPA is not security. Validate request size, ranges and identifiers at the boundary, then let the domain protect its invariant again.

Use idempotency for uncertain retries. Store request ID, request fingerprint and result atomically with the allocation. If the same key arrives with a different body, reject it; silently returning the earlier result would conceal a client bug.

Version contracts with compatibility discipline. DTOs are public promises once consumers depend on them. Do not expose EF entities, lazy-loading proxies or internal enum assumptions.

Chapter 21: Delegates, lambdas and events in production code

Delegates represent executable behaviour with a signature. Func and Action are convenient; named delegates can better express domain intent.

LINQ lambdas may become SQL expression trees under IQueryable, while the same syntax becomes compiled delegates under IEnumerable. A helper method valid in memory may be untranslatable by EF Core. Keep the boundary visible.

Events are multicast notifications inside a process. They do not provide durability, replay, cross-process delivery or transactional guarantees. Calling a C# event an “integration event” does not make it a message bus.

When raising an event, copy the delegate reference or use null-conditional invocation:

public event EventHandler<StockChangedEventArgs>? StockChanged;

private void OnStockChanged(StockChangedEventArgs args) =>
    StockChanged?.Invoke(this, args);

One subscriber throwing can prevent later subscribers from running. If handlers require isolation or asynchronous durability, use a different mechanism with explicit semantics.

Avoid async void event handlers where you control the signature. When framework events require them, catch and route exceptions deliberately, then delegate to a Task method that is testable.

Chapter 22: Performance revision without folklore

Correctness and clarity come first, then measurement. Establish a benchmark or trace tied to the affected journey. Common C# performance costs include allocations, boxing, repeated enumeration, reflection, excessive logging, contention and inefficient I/O.

Value types can reduce heap allocation when small and used appropriately, but copying a large struct repeatedly is expensive. Boxing occurs when a value type is converted to object or an interface representation. Generics often avoid boxing, but confirm with a profiler.

Use Span and pooled buffers only at well-measured boundaries. Spans are stack-only views with lifetime restrictions; they are powerful for parsing and slicing without allocation, not a replacement for ordinary strings in business code.

String concatenation in a small expression is readable and often optimised. Repeated concatenation in a loop can allocate many intermediate strings; use StringBuilder, streaming or structured formatting where measurement justifies it.

Logging arguments may still incur work even if a log level is disabled, depending on how they are produced. Prefer structured templates and source-generated logging for very hot paths after measuring. Never optimise by removing the telemetry required to operate a financial action.

For allocation throughput, inspect database wait time, query plans, connection-pool saturation and concurrency conflicts before micro-optimising record construction. The slowest line of C# may merely be awaiting the real bottleneck.

Chapter 23: Testing at the correct boundary

Test the stock invariant as a plain unit:

[Fact]
public void Allocate_RejectsQuantityAboveAvailability()
{
    var item = StockItemBuilder.WithAvailability(3).Build();

    var act = () => item.Allocate(
        Quantity.Create(4), Guid.NewGuid(), new FakeClock());

    act.Should().Throw<InsufficientStockException>();
}

An application integration test should prove duplicate request handling and optimistic concurrency using the actual relational provider. An HTTP test should prove validation, policy enforcement and result mapping through the real ASP.NET Core pipeline. A small browser or consumer test proves the public journey.

Do not mock DbSet and conclude that LINQ translation works. Do not assert private implementation calls when the outcome is what matters. Use a fake clock and deterministic IDs where those are inputs; do not abstract every .NET method to satisfy a mocking framework.

Test failure paths:

  • pricing exceeds its deadline;
  • caller cancels before persistence;
  • database commit succeeds but response delivery fails;
  • two allocators use the same stock version;
  • the same idempotency key arrives twice;
  • the same key arrives with a different request;
  • outbox publication is retried;
  • unauthorised tenant requests a product.
Mutation or property-based testing can reveal gaps in invariant tests, but apply tools where their cost is justified. A readable example suite remains valuable documentation.

Chapter 24: Diagnose three incidents

Incident one: latency rises while CPU stays low

Requests become slow, CPU is modest and thread count climbs. Inspect traces, thread-pool counters, blocked stacks and outbound dependencies. Look for .Result, .Wait(), synchronous database calls, long locks or socket exhaustion. Adding servers may briefly hide thread starvation but does not remove blocking.

Reproduce with load, replace sync-over-async, bound dependency time and verify cancellation. Compare latency percentiles rather than averages.

Incident two: memory grows after every refresh

A singleton cache subscribes to events from transient objects, closures capture large graphs, and entries have no eviction. Collect a managed heap dump, compare object counts and inspect GC roots. A garbage collector cannot reclaim reachable objects.

Fix ownership and retention: unsubscribe, dispose registrations, bound caches and avoid capturing more than necessary. Calling GC.Collect is not the production fix.

Incident three: stock goes negative only under load

Unit tests read then update sequentially. Two app instances race in production. Trace the competing requests, inspect isolation and generated SQL, and reproduce with concurrent integration tests. Add optimistic concurrency or an atomic storage operation and translate the conflict. An in-process lock cannot coordinate all writers.

After every incident, document the failure mechanism, not just the faulty line. Add a test or guard at the layer that could have prevented recurrence.

Chapter 25: A senior code-review method

Review in passes so naming comments do not hide correctness.

Pass one: behaviour and contracts

What outcome is promised? Which inputs are invalid? Are status codes, nullability and exceptions honest? Can the operation be repeated safely?

Pass two: state and concurrency

Who owns mutation? What happens with two requests? Is a collection or singleton shared? Does the database enforce the invariant?

Pass three: execution and resources

When does LINQ execute? How many queries and network calls occur? Are contexts, streams and registrations disposed? Is concurrency bounded?

Pass four: failure and operations

Where are deadlines and cancellation? What if commitment succeeds but delivery fails? Can telemetry correlate the request without exposing sensitive data?

Pass five: maintainability

Are names expressed in domain language? Are responsibilities cohesive? Is duplication real knowledge duplication or harmless similarity? Can tests change with behaviour rather than implementation?

Write review comments with consequence and suggestion:

“This ToListAsync materialises every stock row before filtering, so memory and query time grow with the catalogue. Can we move the product and availability predicates before materialisation and inspect the generated SQL?”
That teaches more than “bad LINQ.” Mark blocking correctness/security issues separately from optional preferences.

Chapter 26: Mentoring questions and practical exercises

Explain these without reciting definitions:

  1. Why can a non-nullable property still be null at runtime?
  2. When do record equality semantics help or harm?
  3. What changes when a LINQ pipeline moves from IQueryable to IEnumerable?
  4. Why does await improve I/O scalability without making CPU work faster?
  5. Where should cancellation stop being treated as rollback?
  6. Why can ConcurrentDictionary still allow a business race?
  7. When does EF Core tracking help?
  8. Why is retry unsafe for an ordinary POST?
  9. What evidence distinguishes a memory leak from normal GC growth?
  10. Which invariant belongs in C#, which belongs in the database, and why might it need both?

Exercise one: strengthen the model

Implement ProductId, Quantity, StockItem and explicit allocation outcomes. Write tests for default values, equality, insufficient stock and state transitions. Explain every public setter you keep.

Exercise two: build the persistence slice

Map optimistic concurrency and an idempotency record. Run two real database contexts against the same stock version. Prove one succeeds, one receives a conflict and stock never becomes negative.

Exercise three: expose the endpoint

Return validation problems, created resources, duplicates and conflicts distinctly. Add resource authorisation and a cancellation-aware deadline for pricing. Test through WebApplicationFactory or the equivalent real host.

Exercise four: profile rather than guess

Generate representative load. Capture traces, runtime counters and database evidence. Make one measured improvement, write down its before/after result and explain its trade-off. Reject an optimisation if it adds complexity without meaningful gain.

Exercise five: teach it back

Review a deliberately flawed version containing .Result, shared DbContext, exposed entity DTOs, unbounded Task.WhenAll, a mutable dictionary key and unsafe retry. Explain the production consequence of each flaw and propose the smallest reliable correction.

Chapter 27: Generics, variance and reusable constraints

Generics let one algorithm retain compile-time type information. They avoid many casts and often avoid boxing value types. Their deeper benefit is expressing what an operation requires.

public interface IEntity<TId> where TId : notnull
{
    TId Id { get; }
}

public interface IReadRepository<TEntity, in TId>
    where TEntity : class, IEntity<TId>
    where TId : notnull
{
    Task<TEntity?> FindAsync(TId id, CancellationToken cancellationToken);
}

Constraints communicate capabilities such as reference type, value type, non-null key, base class, interface or parameterless construction. Do not add a generic parameter merely to make an API look flexible. If every caller uses one type and the abstraction has no meaningful variation, a concrete API may be clearer.

Variance describes safe assignment relationships for interface/delegate type parameters. Covariance (out) permits a producer of a more derived type where a producer of a base type is expected. Contravariance (in) permits a consumer of a base type where a consumer of a derived type is expected.

IEnumerable<string> names = ["A", "B"];
IEnumerable<object> objects = names; // Covariance: values come out.

Action<object> inspect = value => Console.WriteLine(value);
Action<string> inspectText = inspect; // Contravariance: values go in.

Variance applies to reference types in variant interfaces and delegates, not arbitrary classes. If you must draw arrows for five minutes to understand a public generic abstraction, question whether its flexibility helps the domain.

Avoid a generic repository that erases useful queries

A universal repository with GetAll, Add, Update and Delete often duplicates DbSet while hiding projection, concurrency and aggregate rules. The allocation feature needs a focused operation such as GetForUpdateAsync(ProductId) and perhaps a query projection. Those names capture intent.

Generics are ideal for infrastructure that truly repeats—result envelopes, pipeline behaviours or strongly typed identifiers—but domain boundaries should remain visible. Reuse mechanics; do not generalise away business meaning.

Reflection and generic runtime behaviour

The runtime creates specialised generic code/data according to type and implementation details. Do not base performance claims on folklore such as “all generics are free.” Measure code size, JIT behaviour and allocation for the actual hot path.

Reflection can inspect closed generic types and construct them dynamically, but it moves errors from compile time to runtime and can complicate trimming or ahead-of-time compilation. Prefer source generation or explicit registration where discovery is known at build time and deployment constraints require it.

Junior: Should I build a generic event bus for all in-process communication?
>
Senior: Only after defining delivery, ordering, failure and lifetime semantics. Publish is a convenient method signature; it is not an architecture.

Chapter 28: Memory, allocation and garbage collection

.NET manages memory, but managed does not mean costless or unlimited. Objects typically allocate on the managed heap. Garbage collection finds reachable objects, compacts generations in many configurations and reclaims unreachable memory. Large objects and pinned memory have additional consequences.

Most short-lived allocations are efficient. Problems arise from excessive allocation rates, objects retained longer than intended, large buffers, fragmentation, finalisation pressure or pauses that affect latency objectives.

Understand reachability

An object remains alive while reachable from roots such as static fields, active stacks, handles and live threads. A cache without eviction, a static event publisher or a long-lived closure can retain an entire graph.

public sealed class StockListener : IDisposable
{
    private readonly StockFeed _feed;

    public StockListener(StockFeed feed)
    {
        _feed = feed;
        _feed.Changed += OnChanged;
    }

    private void OnChanged(object? sender, StockChangedEventArgs e)
    {
        // Update a local projection.
    }

    public void Dispose() => _feed.Changed -= OnChanged;
}

If _feed lives for the process and listeners are created per operation, failure to unsubscribe retains listeners. The garbage collector is behaving correctly; ownership is wrong.

Stack and heap are useful models, not simplistic rules

Value types can be stored inline in many locations—inside an object, array, stack frame or register. Reference-type objects normally live on the managed heap, while the reference itself can live elsewhere. Avoid interview answers that claim “structs are always on the stack.” The relevant questions are copying, lifetime, boxing, layout and allocation.

Large mutable structs are risky because assignment copies their fields and mutation may affect only a copy. Keep value types small, immutable and semantically value-like. Use in, ref and ref readonly only when profiling shows copy cost and the API remains understandable.

Pools require ownership discipline

ArrayPool can reduce repeated large-buffer allocation:

byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    var read = await stream.ReadAsync(
        buffer.AsMemory(0, buffer.Length), cancellationToken);
    await ProcessAsync(buffer.AsMemory(0, read), cancellationToken);
}
finally
{
    ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}

The rented array may be larger than requested and may contain previous data. Never inspect beyond the valid length. Clear sensitive material before return; clearArray: true has a performance cost that security may require. Do not retain a span or memory view after returning its backing buffer.

Pool only after measurement. A leaked pooled buffer or use-after-return is harder to diagnose than ordinary allocation.

Diagnose with evidence

Use runtime counters to observe allocation rate, heap size, generation collections and pause time. Use a trace or allocation profiler to find types and call stacks. Use heap dumps and root paths for retention. Compare under repeatable load.

High memory is not automatically a leak; the runtime may retain committed segments for reuse. A leak pattern is unbounded retained live data for the same workload. Conversely, stable heap size can coexist with harmful allocation churn and GC CPU.

For our feature, avoid loading document payloads into allocation objects, avoid logging serialised requests, project database data narrowly and bound caches. Optimise the dominant measured cost rather than replacing readable records pre-emptively.

Chapter 29: Dependency injection and lifetime correctness

Dependency injection separates construction from use and makes dependencies explicit. It does not automatically create clean architecture. A constructor with fifteen services reveals a responsibility problem even though the container can resolve it.

ASP.NET Core commonly uses transient, scoped and singleton lifetimes:

  • transient creates instances as resolved according to container behaviour;
  • scoped commonly means one instance per HTTP request scope;
  • singleton means one instance for the application container.
The word “scoped” does not universally mean HTTP request. A background worker creates scopes explicitly; an interactive server circuit can have a longer scope; tests may create their own. Reason about the actual scope owner.

Captive dependencies

A singleton must not capture a scoped DbContext. The scoped object would effectively live as long as the singleton or the container will reject the configuration when scope validation detects it.

public sealed class AllocationWorker(
    IServiceScopeFactory scopeFactory,
    ILogger<AllocationWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var scope = scopeFactory.CreateAsyncScope();
            var processor = scope.ServiceProvider
                .GetRequiredService<IAllocationProcessor>();

            await processor.ProcessNextAsync(stoppingToken);
        }
    }
}

The worker is singleton-like, but each unit of work gets a scope and short-lived dependencies. In a real worker, receiving work should block efficiently rather than busy-loop, and exceptions/backoff need an operational policy.

Prefer constructor injection. Method parameters are suitable for contextual dependencies such as cancellation or authenticated actor data. Avoid resolving arbitrary services from IServiceProvider throughout business code; that hides dependencies and makes lifetime mistakes easier.

Factories and keyed choices

Use a factory when construction requires runtime context or fresh operation lifetime. Use strategy registration when several implementations represent a deliberate policy. Do not build a switch on environment name inside the domain.

public interface IPricingStrategy
{
    string Channel { get; }
    Task<Money> QuoteAsync(ProductId product, Quantity quantity, CancellationToken ct);
}

The application can choose a strategy from validated channel data. Ensure the selection is bounded; never use reflection over an arbitrary client-provided type name.

Singleton services must be thread-safe. Statelessness is easiest. If they cache data, define consistency, eviction, memory limits, refresh coordination and failure behaviour. A plain dictionary plus a timer is not a complete cache design.

Junior: Does injecting an interface make code testable?
>
Senior: It can create a seam, but testability comes from coherent responsibility and observable behaviour. An interface in front of every class adds ceremony without necessarily improving either.

Chapter 30: Serialisation, contracts and version tolerance

JSON serialisation crosses a trust and compatibility boundary. A public DTO should contain the contract, not the domain entity.

public sealed record AllocationResponse(
    Guid AllocationId,
    Guid ProductId,
    int Quantity,
    string Status,
    DateTimeOffset CreatedAt,
    long StockVersion);

Be explicit about naming policy, enum representation, date/time and number semantics. DateTimeOffset preserves an offset and instant; a bare DateTime with an unspecified kind is easily misinterpreted. Money needs currency and agreed precision, not an unlabelled double.

Unknown fields should usually be tolerated by readers so providers can add data compatibly. Removing fields, changing their type or changing semantic meaning can break consumers even when deserialisation succeeds. Contract tests and telemetry about client versions help manage evolution.

Enums are particularly risky. A consumer compiled before a new enum value may fail or take an unsafe default branch. Consider string values plus an Unknown handling policy, or model states that can evolve. Never map an unknown lending decision to Approved.

Source generation and trimming

Reflection-based serialisation is convenient. Source-generated metadata can improve startup/throughput and is useful in trimming or native ahead-of-time scenarios where dynamic discovery is constrained.

[JsonSerializable(typeof(AllocateStockRequest))]
[JsonSerializable(typeof(AllocationResponse))]
internal partial class AllocationJsonContext : JsonSerializerContext;

Use it because measurements or deployment requirements justify it, not because generated code is inherently more senior. Keep contexts near contract ownership and include tests for wire output.

Deserialisation is not validation

Successfully producing an object proves only that JSON matched a shape sufficiently. Validate length, range, identifiers, allowed combinations and authorisation. Limit body size and nesting where appropriate. Do not enable unsafe polymorphic type handling for arbitrary client type names.

If the same contract travels through a message broker, include schema/version identity and stable message ID. Avoid serialising exception objects, EF proxies or runtime type names. Consumers should be able to replay old retained messages using supported schemas.

Time, culture and comparison

Use ordinal comparison for machine identifiers unless a protocol specifies otherwise. Use culture-aware comparison and formatting for human language. Parsing a decimal or date without an explicit culture can behave differently across servers.

Store timestamps as instants with clear UTC/offset semantics and convert for display at the edge. A date-only business concept, such as a due date, may deserve DateOnly; do not invent midnight UTC when no instant exists.

Chapter 31: Pattern matching and readable decision logic

Modern pattern matching can make exhaustive state handling visible:

static IResult ToHttpResult(AllocationResult result) => result switch
{
    AllocationResult.Created x => Results.Created(
        $"/api/allocations/{x.Id}", x.Value),
    AllocationResult.Duplicate x => Results.Ok(x.Value),
    AllocationResult.Insufficient x => Results.Conflict(new
    {
        code = "insufficient_stock",
        x.Available
    }),
    AllocationResult.Stale => Results.Conflict(new
    {
        code = "stock_version_conflict"
    }),
    _ => Results.Problem()
};

The discard arm protects runtime handling but can hide a newly added subtype during review. Where the type system and compiler support exhaustive reasoning, use it; otherwise add a test that every known outcome maps explicitly.

Property and relational patterns are excellent when they state a compact rule:

var band = request switch
{
    { Quantity: <= 0 } => ValidationBand.Invalid,
    { Quantity: > 1000 } => ValidationBand.ManualReview,
    { ClientReference.Length: > 50 } => ValidationBand.Invalid,
    _ => ValidationBand.Standard
};

Do not compress a complex business policy into an impressive switch expression. Named rules, intermediate facts and decision tables can be easier to audit. Pattern matching is a communication tool, not a code-golf competition.

Patterns do not eliminate null/runtime validation. Review evaluation order and property access, and keep side effects out of patterns and guards. A match should classify data, not secretly update it.

Chapter 32: Security questions inside ordinary C#

Security is present in everyday library choices. SQL parameters prevent code/data confusion; string concatenation does not. Razor encoding protects HTML contexts by default; manually marking user text as raw defeats it. File paths constructed from client filenames invite traversal. Regex without bounded input or timeout can consume resources.

For secrets, avoid immutable string copies where an API offers a safer representation, but do not claim memory can be perfectly scrubbed in a managed process. The primary controls are secret stores, least privilege, rotation, restricted logging and short exposure.

Compare security-sensitive tokens with APIs designed for the purpose. Use cryptographic random number generation for tokens, not Random. Use password hashing algorithms and identity libraries, never a fast general-purpose hash.

Validate outbound destinations when the server fetches URLs to prevent server-side request forgery. An HttpClient accepting an arbitrary request URL can reach metadata endpoints or internal services. Allow-list schemes/hosts and resolve network policy at multiple layers.

Protect allocation by authenticating the caller, authorising tenant and warehouse, validating the command, enforcing the database invariant and auditing the result. No single attribute replaces those layers.

Include threat cases in tests, but recognise that tests are examples rather than proof of absence. Use dependency scanning, static analysis, secure defaults, review and runtime monitoring as complementary evidence.

Chapter 33: Production checklist

Before approving the allocation feature, verify:

  • nullable warnings are enabled and resolved intentionally;
  • input concepts are validated at the boundary;
  • domain mutation protects stock invariants;
  • database concurrency protects multi-instance execution;
  • idempotency handles uncertain HTTP retries;
  • queries are translated, projected and measured;
  • DbContext is short-lived and never used concurrently;
  • asynchronous work is awaited and concurrency is bounded;
  • cancellation is propagated without misreporting committed work;
  • resources and subscriptions have explicit ownership;
  • expected outcomes are not hidden inside generic exceptions;
  • API status and error codes form a stable contract;
  • authorisation is enforced on the resource server-side;
  • telemetry carries safe identifiers and useful timings;
  • tests cover domain, persistence, host and critical failure behaviour;
  • migration, rollout and rollback support mixed versions.
This list is deliberately cross-cutting. Senior C# is not confined to language trivia because C# code participates in databases, networks, runtimes and human operations.

Chapter 34: Connect this guide to the wider collection

Use the C# 14 and .NET 10 Modern Development Mentoring Guide for version-specific language features. Use Multithreading, Async and Parallel C# and High-Performance C# and .NET for deeper runtime work. Continue to Pragmatic TDD, EF Core Best Practices, Anatomy of an ASP.NET Core Web Application and HTTP and the Web for the boundaries exercised here. The Microservices with .NET guide expands idempotency, outbox and distributed failure.

The cross-linking lesson is intentional: language knowledge is the foundation, not the whole building. A senior developer can move from a C# expression to its allocation behaviour, database translation, concurrency guarantee, wire contract and operational evidence.

Chapter 35: The five-minute explanation drill

A senior technical discussion often begins with a small fragment. Practise expanding it through the relevant layers without turning the answer into a lecture.

var result = await query.ToListAsync(ct);

Explain that var preserves the compile-time inferred type; it is not dynamic. If query is IQueryable, the provider translates the expression and ToListAsync materialises results. await asynchronously observes completion rather than dedicating a thread to the database wait. The cancellation token asks the provider to cancel, but cancellation timing depends on the provider and database. Then ask about projection, query plan, cardinality, tracking and repeated enumeration.

cache.GetOrAdd(key, _ => Load(key))

Explain that a concurrent collection protects its internal state, but the value factory may be invoked more than once under races even though one value wins. If Load has side effects or is expensive, use an appropriate lazy/task pattern and define failure/eviction. The key must have stable equality and hash behaviour. In a distributed deployment, each process still has its own cache unless an external cache is used.

return Results.Accepted(location, operation);

Explain that 202 Accepted means processing has not necessarily completed. The location should let the client inspect status. The operation requires durable acceptance, stable identity, idempotency and eventual terminal states. Define timeouts, failure visibility and retention. Do not return Accepted immediately before placing work only in volatile memory.

catch (Exception ex) { return null; }

Explain why it destroys the distinction between not found, cancellation, dependency failure and programmer defect. It also discards diagnostic context. Catch only what can be translated or recovered, preserve correlation and make absence explicit in the contract. Do not “fix” it by logging and still returning misleading success.

services.AddSingleton();

Ask whether the service is stateless and thread-safe, what it captures, and whether any dependency is scoped. A singleton holding DbContext, current-user information or mutable request data is unsafe. Lifetime is an ownership and concurrency decision, not a performance switch.

How to practise

For each fragment, answer in this order:

  1. What does the C# compiler/runtime do?
  2. What external work or state is involved?
  3. What correctness and concurrency assumptions exist?
  4. How can it fail?
  5. What evidence would verify it in production?
Keep the first answer concise, then deepen it when asked. Strong senior communication chooses the relevant risk instead of listing every fact remembered about the keyword.

Finally, take five lines from your current codebase and perform the drill with a colleague. If neither of you can say when a query executes, who owns a resource, whether repetition is safe or what a caller observes on failure, add that clarity to the code or contract. Revision is complete when it changes how you inspect real software.

Write the answers down and compare them with a trace, generated SQL, a concurrency test or a memory profile. Where observation contradicts your explanation, keep the evidence and correct the mental model. That habit is more valuable than confidently recalling an implementation detail that changed between runtime versions. Seniority is not certainty about everything; it is the discipline to state assumptions, choose the right diagnostic tool and update a decision when the system provides better evidence.

A focused two-day revision plan

Day one should be fundamentals and language.

Morning: variables, types, strings, conditions, loops, arrays, exceptions. Afternoon: OOP, classes, constructors, properties, value/reference types, interfaces, inheritance, polymorphism, abstraction, SOLID. Evening: delegates, events, lambdas, collections, LINQ and deferred execution.

Day two should be application development.

Morning: async/await, tasks, cancellation, exceptions, parallelism. Afternoon: EF, DbContext, DbSet, LINQ-to-SQL, tracking, AsNoTracking, repository, query/command patterns. Evening: ASP.NET pipeline, middleware, DI lifetimes, Razor Pages, Web API, HttpClient, status codes, Swagger, security, JWT, microservices, Azure Functions.

The mindset I want you to finish with:

Do not only memorise syntax. Understand behaviour.

When you see string.Replace, remember immutability. When you see a class passed to a method, remember reference type behaviour. When you see IQueryable, remember database query translation. When you see ToList, remember materialisation. When you see await, remember non-blocking wait. When you see Singleton, remember lifetime danger. When you see HttpClient, remember reuse and typed clients. When you see POST, PUT, and PATCH, remember HTTP meaning. When you see JWT, remember authentication token. When you see interface injection, remember dependency inversion.

Strong C# knowledge is not demonstrated by memorising every method. It is demonstrated by explaining what the code is doing, what the runtime is doing, what the object model is protecting, what the database call is executing and what the HTTP endpoint is promising.

That is the standard I use when mentoring developers, reviewing code and preparing for technical discussions.

Applied In

The thinking in this article has been applied throughout my enterprise portfolio, where architecture, workflows, permissions, notifications, reporting and modular design are all built around real business operations rather than isolated technical features.

View Continuous Learning →

Use this journal entry for recall practice

Compare your explanation with the questions and working answers in my Practice Room.

Practise C# and .NET interview questions →
Afzal Ahmed

Faz Ahmed

Senior Full Stack Engineer & Technical Lead

A hands-on engineer with 15+ years in commercial software. I publish what I am studying, revising and testing so visitors can see both established experience and learning still in progress.

How would you approach this problem? I'd love to hear your thoughts or continue the discussion.

Connect on LinkedIn →