.NET
Comprehensive notes for fresher developers joining the team.
What is .NET?
.NET is a free, open-source, cross-platform framework developed by Microsoft for building modern applications — web APIs, desktop apps, cloud services, and more. It supports multiple languages, but the primary language used is C# (C-Sharp).
.NET consists of:
- C# — the programming language
- ASP.NET Core — the web framework for building APIs and web applications
- CLR (Common Language Runtime) — the engine that runs your code
- BCL (Base Class Library) — built-in utilities like collections, I/O, and networking
Why Use .NET?
| Feature | Description |
|---|---|
| Cross-Platform | Runs on Windows, macOS, and Linux |
| High Performance | One of the fastest web frameworks available |
| Strongly Typed | C# catches type errors at compile time, reducing runtime bugs |
| Rich Ecosystem | NuGet packages, Microsoft support, extensive documentation |
| Unified Platform | One framework for web, desktop, mobile, cloud, and microservices |
Learning Flow
| Phase | Topics |
|---|---|
| Phase 1 – C# Core | OOPs, Type system, Memory management, CLR fundamentals |
| Phase 2 – .NET Fundamentals | Program.cs, Middleware, DI, Async/Await, SOLID, Design Patterns |
| Phase 3 – Collections & LINQ | Interfaces, LINQ, Lambda expressions |
| Phase 4 – Controllers & Routing | Controllers, Routing, Model binding, DTOs, Validation, Filters |
| Phase 5 – Middleware & Filters | Custom middleware, Built-in middleware, Filters vs Middleware |
| Phase 6 – API & REST | REST principles, HTTP, Web API, EF Core loading strategies |
| Phase 7 – Authentication & Authorization | JWT, Middleware, Authorize attribute |
| Phase 8 – Security | HTTPS, CORS, Secrets, SQL Injection |
| Phase 9 – Logging & Monitoring (Optional) | ILogger, Serilog, NLog |
| Phase 10 – Performance Optimization (Optional) | Caching, Async best practices |
Phase 1 – C# Core
1. OOPs in C# — Classes, Objects, Inheritance, Polymorphism, Encapsulation, Abstraction
Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — instances of classes. C# is a fully object-oriented language and OOP is the backbone of how .NET applications are structured.
Classes and Objects
A class is a blueprint. An object is an instance of that blueprint.
// Class definition
public class Car
{
public string Brand { get; set; }
public int Year { get; set; }
public void StartEngine()
{
Console.WriteLine($"{Brand} engine started.");
}
}
// Creating objects
Car myCar = new Car();
myCar.Brand = "Toyota";
myCar.Year = 2022;
myCar.StartEngine(); // Output: Toyota engine started.
Constructors — special methods that run when an object is created:
public class Car
{
public string Brand { get; set; }
public int Year { get; set; }
// Constructor
public Car(string brand, int year)
{
Brand = brand;
Year = year;
}
}
Car myCar = new Car("Toyota", 2022);
Inheritance — a child class inherits properties and methods from a parent class, promoting code reuse.
public class Animal
{
public string Name { get; set; }
public void Breathe()
{
Console.WriteLine("Breathing...");
}
}
public class Dog : Animal // Dog inherits from Animal
{
public void Bark()
{
Console.WriteLine("Woof!");
}
}
Dog dog = new Dog();
dog.Name = "Rex";
dog.Breathe(); // Inherited from Animal
dog.Bark(); // Defined in Dog
Use the base keyword to call the parent class constructor or method:
public class Animal
{
public string Name { get; set; }
public Animal(string name)
{
Name = name;
}
}
public class Dog : Animal
{
public Dog(string name) : base(name) { }
}
Polymorphism — the ability of different classes to be treated as the same base type, while each class implements the behaviour differently.
- Compile-time (Method Overloading): same method name, different parameters.
- Runtime (Method Overriding): child class provides a different implementation of a parent method using
virtualandoverride.
public class Shape
{
public virtual double Area()
{
return 0;
}
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area()
{
return Math.PI * Radius * Radius;
}
}
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double Area()
{
return Width * Height;
}
}
// Polymorphic usage
Shape shape1 = new Circle { Radius = 5 };
Shape shape2 = new Rectangle { Width = 4, Height = 6 };
Console.WriteLine(shape1.Area()); // 78.54
Console.WriteLine(shape2.Area()); // 24
Encapsulation — hiding internal details and exposing only what is necessary through access modifiers and properties.
| Modifier | Accessible From |
|---|---|
public | Anywhere |
private | Inside the same class only |
protected | Same class and derived classes |
internal | Same assembly/project |
public class BankAccount
{
private decimal _balance; // private — hidden from outside
public decimal Balance
{
get { return _balance; }
private set { _balance = value; } // only this class can set it
}
public void Deposit(decimal amount)
{
if (amount > 0)
_balance += amount;
}
}
Abstraction — hiding complex implementation details and showing only the essential interface. Achieved using abstract classes and interfaces.
// Abstract class — cannot be instantiated directly
public abstract class Vehicle
{
public string Brand { get; set; }
public abstract void Drive(); // must be implemented by subclasses
public void Refuel()
{
Console.WriteLine("Refuelling...");
}
}
public class Car : Vehicle
{
public override void Drive()
{
Console.WriteLine("Car is driving.");
}
}
// Interface — pure contract, no implementation
public interface IPayable
{
void ProcessPayment(decimal amount);
}
public class Order : IPayable
{
public void ProcessPayment(decimal amount)
{
Console.WriteLine($"Payment of {amount:C} processed.");
}
}
Abstract Class vs Interface:
| Feature | Abstract Class | Interface |
|---|---|---|
| Can have implementation | ✅ Yes | ❌ No (default methods are an exception in C# 8+) |
| Multiple inheritance | ❌ No | ✅ Yes (a class can implement many interfaces) |
| Constructors | ✅ Yes | ❌ No |
| Access modifiers | ✅ Yes | ❌ All public by default |
| Use when | Sharing common base behaviour | Defining a contract |
2. IS / AS Operators — Type Checking and Safe Casting
is — checks if an object is of a specific type. Returns true or false.
object obj = "Hello";
if (obj is string)
{
Console.WriteLine("It is a string"); // ✅ prints this
}
// Pattern matching with 'is' (C# 7+)
if (obj is string text)
{
Console.WriteLine(text.ToUpper()); // HELLO
}
as — attempts to cast an object to a type. Returns null if the cast fails (instead of throwing an exception).
object obj = "Hello";
string text = obj as string; // ✅ "Hello"
int? number = obj as int?; // null — obj is not an int
if (text != null)
{
Console.WriteLine(text.Length); // 5
}
Comparison:
| Operator | Returns | Throws on failure |
|---|---|---|
is | bool | Never |
as | Instance or null | Never |
(Type) cast | Instance | ✅ InvalidCastException |
Rule: Use
aswhen a failed cast is acceptable (nullable). Use(Type)cast only when you are certain of the type. Useisfor type checks with pattern matching.
3. Sealed, Partial, Static Classes and Methods
sealed class — prevents other classes from inheriting from it.
public sealed class FinalClass
{
public void DoSomething() { }
}
// ❌ This will cause a compile error
public class DerivedClass : FinalClass { }
Use sealed to prevent unintended extension of a class (e.g., security-sensitive classes, or when you want to signal that the class is complete).
sealed method — prevents a derived class from overriding a method further. Must be used on an override method.
public class Base
{
public virtual void Display() { }
}
public class Middle : Base
{
public sealed override void Display() { } // no further overriding
}
public class Child : Middle
{
// ❌ Cannot override Display() here
}
partial class — splits a class definition across multiple files. All parts are merged by the compiler.
// File: Person.cs
public partial class Person
{
public string Name { get; set; }
}
// File: Person.Methods.cs
public partial class Person
{
public void Greet()
{
Console.WriteLine($"Hello, {Name}");
}
}
Useful in large classes, auto-generated code (like Entity Framework models), and separating designer code from logic code.
static class — cannot be instantiated. All members must also be static. Used for utility/helper methods.
public static class MathHelper
{
public static int Add(int a, int b) => a + b;
public static int Multiply(int a, int b) => a * b;
}
// Usage — no 'new' keyword needed
int result = MathHelper.Add(3, 4); // 7
static method — belongs to the class itself, not an instance.
public class Counter
{
public static int Count { get; private set; } = 0;
public Counter()
{
Count++;
}
}
new Counter();
new Counter();
Console.WriteLine(Counter.Count); // 2
4. Boxing & Unboxing
C# has two categories of types: value types (like int, bool, struct) and reference types (like class, string). Boxing and unboxing bridge the gap between them.
Boxing — converting a value type to a reference type (object). The value is wrapped in a heap-allocated object.
int number = 42; // value type — stored on the stack
object boxed = number; // boxing — copied to heap as an object
Unboxing — extracting the value type back from the object. Requires an explicit cast.
object boxed = 42;
int unboxed = (int)boxed; // unboxing — must cast to the correct type
Why does it matter?
Boxing and unboxing have a performance cost — they involve heap allocation and type checking. Avoid them in performance-critical loops.
// ❌ Bad — boxes on every iteration
ArrayList list = new ArrayList();
for (int i = 0; i < 1000; i++)
list.Add(i); // each int is boxed
// ✅ Good — use generic collections instead
List<int> list = new List<int>();
for (int i = 0; i < 1000; i++)
list.Add(i); // no boxing
5. Strings — String vs StringBuilder, String Methods, Immutability
String Immutability
In C#, string is immutable — once created, its value cannot be changed. Every operation that appears to modify a string actually creates a new string in memory.
string name = "Hello";
name = name + " World"; // creates a brand new string, old one becomes garbage
This is why repeated string concatenation in a loop is inefficient.
String vs StringBuilder
Use StringBuilder when you need to build or modify strings in a loop, because it mutates the same buffer instead of creating new strings.
// ❌ Bad — creates a new string on each iteration (slow for large loops)
string result = "";
for (int i = 0; i < 1000; i++)
result += i;
// ✅ Good — StringBuilder modifies in place (efficient)
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
sb.Append(i);
string result = sb.ToString();
| Feature | string | StringBuilder |
|---|---|---|
| Immutable | ✅ Yes | ❌ No (mutable) |
| Memory | New object per change | Single buffer |
| Performance | Poor for many changes | Excellent for many changes |
| Use when | Few or no modifications | Many modifications or loops |
Common String Methods
string s = " Hello, World! ";
s.Length // 18 — number of characters
s.Trim() // "Hello, World!" — removes leading/trailing whitespace
s.ToUpper() // " HELLO, WORLD! "
s.ToLower() // " hello, world! "
s.Contains("World") // true
s.StartsWith(" He") // true
s.EndsWith("! ") // true
s.Replace("World", "C#") // " Hello, C#! "
s.Split(',') // [" Hello", " World! "]
s.Substring(2, 5) // "Hello" — start index 2, length 5
s.IndexOf("World") // 9 — position of first match
s.Trim().ToUpper() // chaining: "HELLO, WORLD!"
// String interpolation (preferred modern syntax)
string name = "Alice";
string greeting = $"Hello, {name}!"; // "Hello, Alice!"
// String.Format (older style)
string formatted = string.Format("Hello, {0}!", name);
// Verbatim string — @ prefix ignores escape characters
string path = @"C:\Users\Documents\file.txt";
// Check for null or empty
string.IsNullOrEmpty(s) // true if null or ""
string.IsNullOrWhiteSpace(s) // true if null, "", or whitespace only
6. Using Statement — Resource Management & IDisposable
Some objects hold unmanaged resources (file handles, database connections, network sockets) that must be explicitly released when done. The IDisposable interface provides the Dispose() method for this purpose.
The using statement ensures Dispose() is called automatically — even if an exception occurs.
// Without using — risky if an exception is thrown before Dispose()
StreamReader reader = new StreamReader("file.txt");
string content = reader.ReadToEnd();
reader.Dispose(); // might not be reached
// ✅ With using — Dispose() is always called
using (StreamReader reader = new StreamReader("file.txt"))
{
string content = reader.ReadToEnd();
} // Dispose() called automatically here
// ✅ Modern using declaration (C# 8+) — cleaner syntax
using StreamReader reader = new StreamReader("file.txt");
string content = reader.ReadToEnd();
// Dispose() called when reader goes out of scope
Implementing IDisposable in your own class:
public class DatabaseConnection : IDisposable
{
private bool _disposed = false;
public void Open() { /* open connection */ }
public void Dispose()
{
if (!_disposed)
{
// release resources
_disposed = true;
}
}
}
using var conn = new DatabaseConnection();
conn.Open();
// conn.Dispose() called automatically at end of scope
7. Finalize & Garbage Collection
Garbage Collection (GC)
The .NET Garbage Collector automatically manages memory for managed objects. You don't need to manually free memory — the GC periodically identifies objects with no references and reclaims their memory.
The GC works in generations:
- Generation 0 — short-lived objects (most collections happen here)
- Generation 1 — medium-lived objects (buffer between Gen 0 and Gen 2)
- Generation 2 — long-lived objects (full GC, expensive)
// You rarely need to call GC directly, but you can:
GC.Collect(); // forces a collection (generally avoid this)
GC.SuppressFinalize(obj); // tell GC not to call finalizer (use in Dispose pattern)
Finalizer (Destructor)
A finalizer (~ClassName) is called by the GC before an object is collected. It is used as a safety net for releasing unmanaged resources. It is not deterministic — you don't control when it runs.
public class ResourceHolder
{
~ResourceHolder()
{
// cleanup unmanaged resources
// this runs at some point during GC — not immediately
}
}
Best Practice — Dispose Pattern:
Combine IDisposable with a finalizer to handle both deterministic (manual) and non-deterministic (GC) cleanup:
public class ResourceHolder : IDisposable
{
private bool _disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // no need for finalizer to run
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// free managed resources
}
// free unmanaged resources
_disposed = true;
}
}
~ResourceHolder()
{
Dispose(false); // safety net
}
}
8. Continue & Break — Control Flow in Loops
break — exits the loop entirely.
for (int i = 0; i < 10; i++)
{
if (i == 5)
break; // stops the loop when i = 5
Console.WriteLine(i); // prints 0, 1, 2, 3, 4
}
continue — skips the current iteration and moves to the next one.
for (int i = 0; i < 10; i++)
{
if (i % 2 == 0)
continue; // skip even numbers
Console.WriteLine(i); // prints 1, 3, 5, 7, 9
}
In a foreach loop:
string[] names = { "Alice", "Bob", "", "Charlie" };
foreach (string name in names)
{
if (string.IsNullOrEmpty(name))
continue; // skip empty names
Console.WriteLine(name); // Alice, Bob, Charlie
}
In a while loop:
int count = 0;
while (true)
{
count++;
if (count >= 5)
break; // exit infinite loop when count reaches 5
}
9. CLR — Common Language Runtime, JIT Compilation, Managed Code
What is the CLR?
The Common Language Runtime (CLR) is the virtual machine component of .NET. It is the execution engine that manages running your C# code, providing services like:
- Memory management via the Garbage Collector
- Type safety — ensures code doesn't access wrong memory
- Exception handling — structured try/catch across language boundaries
- Thread management
- Security
Compilation Flow:
C# Source Code (.cs)
↓
C# Compiler (csc)
↓
IL Code (MSIL) in .dll / .exe
↓
CLR loads the assembly
↓
JIT Compiler (Just-In-Time)
↓
Native Machine Code
↓
Runs
JIT (Just-In-Time) Compilation
C# code is first compiled to Intermediate Language (IL) — a CPU-independent bytecode. When your app runs, the CLR's JIT compiler converts IL to native machine code on demand — just before each method is first called. The result is cached so subsequent calls run native code directly.
This means:
- Your app starts slightly slower (JIT overhead on first run)
- But runs fast after warmup
- The same IL runs on any OS/CPU where the CLR is installed
Managed vs Unmanaged Code:
| Managed Code | Unmanaged Code | |
|---|---|---|
| Runtime | Runs under CLR supervision | Runs directly on OS/CPU |
| Memory | GC handles it | Developer handles it manually |
| Safety | Type-safe, bounds-checked | Can cause buffer overflows |
| Language | C#, VB.NET, F# | C, C++ |
10. Value Types vs Reference Types
This is one of the most important concepts in C# — understanding how variables are stored and copied.
Value Types
Stored directly on the stack. When you assign one to another, a copy is made.
Examples: int, double, bool, char, struct, enum
int a = 10;
int b = a; // b is a separate copy
b = 20;
Console.WriteLine(a); // 10 — unchanged
Console.WriteLine(b); // 20
Reference Types
Store a reference (pointer) to data on the heap. When you assign one to another, both variables point to the same object.
Examples: class, string, array, interface, delegate
int[] arr1 = { 1, 2, 3 };
int[] arr2 = arr1; // arr2 points to the SAME array
arr2[0] = 99;
Console.WriteLine(arr1[0]); // 99 — arr1 was also affected!
Comparison:
| Feature | Value Type | Reference Type |
|---|---|---|
| Storage | Stack | Heap |
| Assignment | Creates a copy | Copies the reference (both point to same data) |
| Default value | Zero/false/null struct | null |
| Examples | int, bool, struct | class, string, array |
| Nullable by default | ❌ | ✅ |
null with value types:
Value types cannot be null by default. Use nullable types (int?) when needed:
int? age = null; // nullable int
if (age.HasValue)
Console.WriteLine(age.Value);
else
Console.WriteLine("Age not set");
Phase 2 – .NET Fundamentals
1. Program.cs — Entry Point, Host Builder, App Configuration
In modern .NET (6+), Program.cs is the single entry point of your application. It uses top-level statements, eliminating the need for a boilerplate Main method.
// Program.cs — minimal Web API setup
var builder = WebApplication.CreateBuilder(args);
// Register services with the DI container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline (middleware)
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Two-phase structure:
builderphase — register services and configuration before the app starts.appphase — configure the request pipeline (middleware) and run the app.
Configuration — read settings from appsettings.json:
// appsettings.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=...;Database=mydb;"
},
"JwtSettings": {
"SecretKey": "my-secret-key"
}
}
// Access configuration in Program.cs or via DI
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var secretKey = builder.Configuration["JwtSettings:SecretKey"];
2. Middleware Pipeline — Request/Response Pipeline, Custom Middleware
What is Middleware?
Middleware is software that handles an HTTP request and response. Each piece of middleware can:
- Process the incoming request
- Pass it to the next middleware in the pipeline (calling
next()) - Process the outgoing response
Think of it as a chain of handlers — each one handles part of the request/response lifecycle.
HTTP Request
↓
[Middleware 1] → (can short-circuit or pass to next)
↓
[Middleware 2] → (can short-circuit or pass to next)
↓
[Middleware 3] → (can short-circuit or pass to next)
↓
Controller/Endpoint
↑
[Middleware 3] ← (processes response on way back)
↑
[Middleware 2] ←
↑
[Middleware 1] ←
↑
HTTP Response
Registering middleware — order matters:
app.UseHttpsRedirection(); // 1. Redirect HTTP to HTTPS
app.UseStaticFiles(); // 2. Serve static files
app.UseRouting(); // 3. Match request to a route
app.UseAuthentication(); // 4. Identify the user
app.UseAuthorization(); // 5. Check if user has permission
app.MapControllers(); // 6. Execute controller action
Custom Middleware (covered in detail in Phase 5).
3. Dependency Injection — IoC Container, Constructor Injection
What is Dependency Injection (DI)?
DI is a design pattern where a class receives its dependencies from outside rather than creating them itself. In .NET, an IoC (Inversion of Control) container manages object creation and wires dependencies together automatically.
Without DI (tightly coupled):
public class OrderService
{
private readonly EmailService _emailService;
public OrderService()
{
_emailService = new EmailService(); // ❌ tightly coupled — hard to test or swap
}
}
With DI (loosely coupled):
public interface IEmailService
{
void SendEmail(string to, string subject);
}
public class EmailService : IEmailService
{
public void SendEmail(string to, string subject)
{
// send email
}
}
public class OrderService
{
private readonly IEmailService _emailService;
// Constructor injection — the container provides the dependency
public OrderService(IEmailService emailService)
{
_emailService = emailService;
}
}
Registering services in Program.cs:
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<OrderService>();
Now when OrderService is needed anywhere, the container automatically creates an EmailService and injects it. You can swap EmailService for a mock in tests without changing OrderService.
4. Service Lifetime — Singleton, Scoped, Transient
When registering services with the DI container, you specify how long the service instance lives:
| Lifetime | Created | Destroyed | Use Case |
|---|---|---|---|
| Transient | Every time it is requested | After each use | Lightweight, stateless services |
| Scoped | Once per HTTP request | At end of the request | Database context, per-request state |
| Singleton | Once for the entire app lifetime | When app shuts down | Caching, configuration, logging |
builder.Services.AddTransient<IMyService, MyService>(); // new instance every time
builder.Services.AddScoped<IMyService, MyService>(); // one per HTTP request
builder.Services.AddSingleton<IMyService, MyService>(); // one for entire app
Choosing the right lifetime:
// ✅ Scoped — DbContext should be scoped (one per request)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// ✅ Singleton — shared cache or configuration
builder.Services.AddSingleton<ICacheService, CacheService>();
// ✅ Transient — email sender, stateless utilities
builder.Services.AddTransient<IEmailSender, EmailSender>();
Warning: Never inject a Scoped service into a Singleton. The Scoped instance would be captured and live for the entire app lifetime, causing stale data bugs. The DI container will throw an error in development.
5. Serialization — JSON Serialization
Serialization converts a C# object to a string (e.g., JSON) for storage or transmission. Deserialization reverses the process.
.NET includes System.Text.Json (built-in, high-performance) and supports Newtonsoft.Json (popular third-party library with more features).
Using System.Text.Json:
using System.Text.Json;
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// Serialize — object → JSON string
var product = new Product { Id = 1, Name = "Laptop", Price = 999.99m };
string json = JsonSerializer.Serialize(product);
// {"Id":1,"Name":"Laptop","Price":999.99}
// Deserialize — JSON string → object
string jsonInput = """{"Id":2,"Name":"Phone","Price":499.99}""";
var parsed = JsonSerializer.Deserialize<Product>(jsonInput);
Console.WriteLine(parsed.Name); // Phone
JsonSerializerOptions — customising behaviour:
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // "name" instead of "Name"
WriteIndented = true, // pretty-print
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
string json = JsonSerializer.Serialize(product, options);
Data annotations on properties:
public class Product
{
[JsonPropertyName("product_id")] // custom JSON key
public int Id { get; set; }
[JsonIgnore] // exclude from serialization
public string InternalCode { get; set; }
}
In ASP.NET Core, JSON serialization/deserialization of request and response bodies happens automatically when you use [ApiController] with [HttpPost] / [HttpGet].
6. Async / Await — Asynchronous Programming, Task, Task<T>
Why Async?
Synchronous code blocks a thread while waiting for I/O (database queries, HTTP calls, file reads). In a web server this is wasteful — a blocked thread cannot serve other requests. Async/await lets the thread be freed while waiting, improving throughput and scalability.
Task and Task<T>
Task— represents an asynchronous operation that completes without a return value.Task<T>— represents an asynchronous operation that returns a value of typeT.
// Synchronous — blocks the thread
string content = File.ReadAllText("file.txt");
// Asynchronous — thread is freed while file is read
string content = await File.ReadAllTextAsync("file.txt");
Async method rules:
- Must be marked with
async - Should return
Task,Task<T>, orvoid(avoidasync voidexcept for event handlers) - Use
awaitto suspend until the awaited Task completes
public async Task<string> GetDataAsync(int id)
{
// Simulating a database call
await Task.Delay(100); // non-blocking delay
return $"Data for id {id}";
}
// Calling async method
string result = await GetDataAsync(42);
Console.WriteLine(result);
Real-world example — async controller action:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(int id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
return NotFound();
return Ok(product);
}
Key rules:
- If a method uses
await, it must beasync. asyncpropagates up — when you make a method async, its callers should also be async.- Use
awaitrather than.Resultor.Wait()— those block the thread and can cause deadlocks.
7. SOLID Principles
SOLID is a set of five design principles that make software more maintainable, testable, and extensible.
S — Single Responsibility Principle (SRP)
A class should have only one reason to change — it should do one thing only.
// ❌ Violates SRP — handles both order logic and email sending
public class OrderService
{
public void PlaceOrder(Order order) { /* ... */ }
public void SendConfirmationEmail(Order order) { /* ... */ }
}
// ✅ Follows SRP — separate responsibilities
public class OrderService
{
public void PlaceOrder(Order order) { /* ... */ }
}
public class EmailService
{
public void SendConfirmationEmail(Order order) { /* ... */ }
}
O — Open/Closed Principle (OCP)
Classes should be open for extension, closed for modification — add new behaviour by extending, not by changing existing code.
// ✅ Add new payment methods without modifying existing code
public interface IPaymentProcessor
{
void ProcessPayment(decimal amount);
}
public class CreditCardProcessor : IPaymentProcessor { /* ... */ }
public class PayPalProcessor : IPaymentProcessor { /* ... */ }
public class CryptoProcessor : IPaymentProcessor { /* ... */ } // new — no existing code changed
L — Liskov Substitution Principle (LSP)
A derived class should be substitutable for its base class without breaking behaviour.
// ✅ Bird and Penguin are substitutable where IBird is used
public interface IBird
{
void Eat();
}
public interface IFlyingBird : IBird
{
void Fly();
}
public class Eagle : IFlyingBird
{
public void Eat() { }
public void Fly() { }
}
public class Penguin : IBird // Penguin only implements IBird, not IFlyingBird
{
public void Eat() { }
}
I — Interface Segregation Principle (ISP)
Clients should not be forced to implement interfaces they don't use. Split large interfaces into smaller, focused ones.
// ❌ Violates ISP — forces all implementors to define Print and Scan
public interface IMultiFunction
{
void Print();
void Scan();
void Fax();
}
// ✅ Follows ISP — separate interfaces
public interface IPrinter { void Print(); }
public interface IScanner { void Scan(); }
public interface IFax { void Fax(); }
public class SimplePrinter : IPrinter
{
public void Print() { /* ... */ }
}
D — Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces).
// ❌ Violates DIP — high-level class depends directly on low-level class
public class ReportService
{
private SqlDatabase _database = new SqlDatabase();
}
// ✅ Follows DIP — depends on abstraction
public interface IDatabase { void SaveReport(Report r); }
public class ReportService
{
private readonly IDatabase _database;
public ReportService(IDatabase database) { _database = database; }
}
8. Design Patterns — Repository, Factory, Singleton
Repository Pattern
Abstracts data access behind an interface, keeping the business logic free of database concerns.
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product> GetByIdAsync(int id)
=> await _context.Products.FindAsync(id);
public async Task<IEnumerable<Product>> GetAllAsync()
=> await _context.Products.ToListAsync();
// ... other methods
}
// Registration
builder.Services.AddScoped<IProductRepository, ProductRepository>();
Factory Pattern
Encapsulates object creation logic. Useful when the exact type to create depends on some condition.
public interface INotification
{
void Send(string message);
}
public class EmailNotification : INotification
{
public void Send(string message) => Console.WriteLine($"Email: {message}");
}
public class SmsNotification : INotification
{
public void Send(string message) => Console.WriteLine($"SMS: {message}");
}
public static class NotificationFactory
{
public static INotification Create(string type) => type switch
{
"email" => new EmailNotification(),
"sms" => new SmsNotification(),
_ => throw new ArgumentException($"Unknown type: {type}")
};
}
// Usage
var notification = NotificationFactory.Create("email");
notification.Send("Order confirmed!");
Singleton Pattern
Ensures only one instance of a class exists throughout the application. In .NET, prefer registering services as Singleton with the DI container rather than implementing the pattern manually.
// ✅ Preferred — use DI container
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
// Manual implementation (if DI is not available)
public class AppConfig
{
private static AppConfig _instance;
private static readonly object _lock = new object();
private AppConfig() { }
public static AppConfig Instance
{
get
{
lock (_lock)
{
_instance ??= new AppConfig();
return _instance;
}
}
}
}
9. DSA in C# — Arrays, Lists, Stack, Queue, Dictionary Basics
Array — fixed-size, zero-indexed collection of the same type.
int[] numbers = new int[5]; // size 5, all zeros
int[] primes = { 2, 3, 5, 7 }; // initialiser syntax
primes[0] // 2
primes.Length // 4
Array.Sort(primes); // sorts in-place
List<T> — dynamic-size array. Most commonly used collection.
var names = new List<string>();
names.Add("Alice");
names.Add("Bob");
names.Remove("Bob");
names.Contains("Alice"); // true
names.Count; // 1
names[0]; // "Alice"
Stack<T> — Last-In First-Out (LIFO).
var stack = new Stack<int>();
stack.Push(1);
stack.Push(2);
stack.Push(3);
stack.Pop(); // 3 — removes and returns top
stack.Peek(); // 2 — returns top without removing
stack.Count; // 2
Queue<T> — First-In First-Out (FIFO).
var queue = new Queue<string>();
queue.Enqueue("First");
queue.Enqueue("Second");
queue.Enqueue("Third");
queue.Dequeue(); // "First" — removes and returns front
queue.Peek(); // "Second" — returns front without removing
queue.Count; // 2
Dictionary<TKey, TValue> — key-value pairs, O(1) average lookup.
var scores = new Dictionary<string, int>();
scores["Alice"] = 95;
scores["Bob"] = 87;
scores["Alice"]; // 95
scores.ContainsKey("Bob"); // true
scores.Count; // 2
foreach (var kvp in scores)
Console.WriteLine($"{kvp.Key}: {kvp.Value}");
// Safe access with TryGetValue
if (scores.TryGetValue("Charlie", out int score))
Console.WriteLine(score);
else
Console.WriteLine("Not found");
Phase 3 – Collections & LINQ
1. IEnumerable — Deferred Execution, Iteration
IEnumerable<T> is the base interface for all collections in .NET. It provides only one capability: forward-only iteration via GetEnumerator(). It is the interface used by foreach.
Key characteristic — deferred execution:
LINQ queries on IEnumerable<T> are not executed when you define them. They execute when you actually iterate (with foreach, .ToList(), etc.).
IEnumerable<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
// This query is NOT executed yet
var evenNumbers = numbers.Where(n => n % 2 == 0);
// Executed here — iterating materialises the query
foreach (var n in evenNumbers)
Console.WriteLine(n); // 2, 4
2. ICollection — Add, Remove, Count
ICollection<T> extends IEnumerable<T> and adds:
Count— number of itemsAdd(T item)— add an itemRemove(T item)— remove an itemClear()— remove all itemsContains(T item)— check membership
List<T> and HashSet<T> implement ICollection<T>.
ICollection<string> names = new List<string>();
names.Add("Alice");
names.Add("Bob");
names.Remove("Bob");
Console.WriteLine(names.Count); // 1
3. IList — Index-Based Access
IList<T> extends ICollection<T> and adds index-based access:
this[int index]— get or set by indexIndexOf(T item)— find positionInsert(int index, T item)— insert at positionRemoveAt(int index)— remove by position
IList<string> fruits = new List<string> { "Apple", "Banana", "Cherry" };
Console.WriteLine(fruits[1]); // Banana
fruits.Insert(1, "Avocado");
fruits.RemoveAt(0); // removes "Apple"
4. IDictionary — Key-Value Pairs
IDictionary<TKey, TValue> maps unique keys to values:
this[TKey key]— get or set by keyAdd(TKey, TValue)— add a pairRemove(TKey)— remove by keyContainsKey(TKey)— check key existenceKeys/Values— collections of all keys or values
IDictionary<string, int> ages = new Dictionary<string, int>();
ages["Alice"] = 30;
ages["Bob"] = 25;
ages.ContainsKey("Alice"); // true
ages.Remove("Bob");
foreach (string key in ages.Keys)
Console.WriteLine($"{key}: {ages[key]}");
5. IQueryable — LINQ to Database, Expression Trees
IQueryable<T> extends IEnumerable<T> and is designed for querying external data sources (like a SQL database via Entity Framework). Unlike IEnumerable<T>, which executes in memory, IQueryable<T> translates the query to the native query language (SQL) and executes it at the data source.
// IEnumerable — loads ALL products into memory, then filters in C#
IEnumerable<Product> products = _context.Products; // ❌ loads everything
var cheap = products.Where(p => p.Price < 100); // filters in memory
// IQueryable — builds a SQL WHERE clause, filters in the database
IQueryable<Product> query = _context.Products; // ✅ no data loaded yet
var cheap = query.Where(p => p.Price < 100); // adds SQL WHERE
var result = await cheap.ToListAsync(); // executes: SELECT ... WHERE Price < 100
Use IQueryable<T> when working with ORMs like Entity Framework Core to avoid pulling entire tables into memory.
6. LINQ — Where, Select, GroupBy, OrderBy, Query & Method Syntax
LINQ (Language Integrated Query) lets you query collections using a uniform syntax, whether the data is in memory, a database, XML, or elsewhere.
Method Syntax (most common):
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Where — filter
var evens = numbers.Where(n => n % 2 == 0);
// 2, 4, 6, 8, 10
// Select — transform/project
var squares = numbers.Select(n => n * n);
// 1, 4, 9, 16, 25, 36, 49, 64, 81, 100
// OrderBy / OrderByDescending
var sorted = numbers.OrderByDescending(n => n);
// 10, 9, 8, ...
// GroupBy
var people = new List<(string Name, string City)>
{
("Alice", "London"), ("Bob", "Paris"), ("Charlie", "London")
};
var byCity = people.GroupBy(p => p.City);
foreach (var group in byCity)
{
Console.WriteLine($"{group.Key}: {string.Join(", ", group.Select(p => p.Name))}");
}
// London: Alice, Charlie
// Paris: Bob
// First, FirstOrDefault, Single, SingleOrDefault
var first = numbers.FirstOrDefault(n => n > 5); // 6 — or default if not found
// Any, All, Count
bool hasLarge = numbers.Any(n => n > 8); // true
bool allPositive = numbers.All(n => n > 0); // true
int countEven = numbers.Count(n => n % 2 == 0); // 5
// Sum, Min, Max, Average
double avg = numbers.Average(); // 5.5
int total = numbers.Sum(); // 55
// ToList, ToArray — materialize
List<int> list = numbers.Where(n => n > 5).ToList();
Query Syntax (SQL-like, less common):
var result =
from n in numbers
where n % 2 == 0
orderby n descending
select n * n;
7. Lambda Expressions — Func<>, Action<>, Expression Lambdas
A lambda expression is an anonymous function — a function without a name, defined inline.
// Named method
int Add(int a, int b) => a + b;
// Equivalent lambda
Func<int, int, int> add = (a, b) => a + b;
int result = add(3, 4); // 7
Func<T> delegates — represent a method that returns a value:
Func<int, bool> isEven = n => n % 2 == 0;
Func<string, string> toUpper = s => s.ToUpper();
Func<int, int, int> multiply = (a, b) => a * b;
isEven(4); // true
toUpper("hello"); // "HELLO"
multiply(3, 5); // 15
Action<T> delegates — represent a method that returns nothing:
Action<string> print = message => Console.WriteLine(message);
Action<int, int> printSum = (a, b) => Console.WriteLine(a + b);
print("Hello"); // Hello
printSum(3, 4); // 7
Lambdas in LINQ:
var names = new List<string> { "Alice", "Bob", "Charlie" };
var longNames = names.Where(name => name.Length > 3)
.Select(name => name.ToUpper())
.ToList();
// ["ALICE", "CHARLIE"]
Multi-line lambda:
Func<int, int> factorial = n =>
{
int result = 1;
for (int i = 2; i <= n; i++)
result *= i;
return result;
};
factorial(5); // 120
Phase 4 – Controllers & Routing
1. Controllers — API Controller vs MVC Controller
API Controller — returns data (JSON/XML). Used for building REST APIs consumed by SPAs or mobile apps.
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
var products = new[] { new { Id = 1, Name = "Laptop" } };
return Ok(products); // returns 200 with JSON
}
}
MVC Controller — returns Views (HTML). Used for server-rendered web applications.
public class HomeController : Controller // note: Controller, not ControllerBase
{
public IActionResult Index()
{
return View(); // returns an HTML view
}
}
Comparison:
| Feature | ControllerBase (API) | Controller (MVC) |
|---|---|---|
| Inherits from | ControllerBase | Controller (extends ControllerBase) |
| Returns | Data (JSON) | Views (HTML) or Data |
[ApiController] | ✅ Typically used | Not required |
| ViewBag / ViewData | ❌ Not available | ✅ Available |
| Use for | REST APIs | Web apps with server-rendered HTML |
2. Routing — Attribute Routing, Conventional Routing, Route Constraints
Attribute Routing — routes are defined directly on controllers and actions using attributes. Preferred for Web APIs.
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet] // GET /api/products
public IActionResult GetAll() { }
[HttpGet("{id}")] // GET /api/products/5
public IActionResult GetById(int id) { }
[HttpPost] // POST /api/products
public IActionResult Create([FromBody] ProductDto dto) { }
[HttpPut("{id}")] // PUT /api/products/5
public IActionResult Update(int id, [FromBody] ProductDto dto) { }
[HttpDelete("{id}")] // DELETE /api/products/5
public IActionResult Delete(int id) { }
}
Route Constraints — restrict what values a route segment can match:
[HttpGet("{id:int}")] // id must be an integer
[HttpGet("{name:alpha}")] // name must be letters only
[HttpGet("{id:int:min(1)}")] // id must be int >= 1
[HttpGet("{slug:regex(^[a-z]+$)}")] // slug matches regex
Conventional Routing — used in MVC apps, defined in Program.cs:
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
// e.g. /Products/Details/5 → ProductsController.Details(5)
3. Model Binding — FromBody, FromQuery, FromRoute, FromHeader
Model binding maps incoming HTTP request data to action method parameters.
// [FromRoute] — from URL segment: GET /api/products/5
[HttpGet("{id}")]
public IActionResult GetById([FromRoute] int id) { }
// [FromQuery] — from query string: GET /api/products?page=2&size=10
[HttpGet]
public IActionResult GetAll([FromQuery] int page = 1, [FromQuery] int size = 10) { }
// [FromBody] — from JSON request body: POST /api/products
[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto) { }
// [FromHeader] — from HTTP header
[HttpGet]
public IActionResult GetWithToken([FromHeader(Name = "X-Api-Key")] string apiKey) { }
// [FromForm] — from HTML form data
[HttpPost("upload")]
public IActionResult Upload([FromForm] IFormFile file) { }
With [ApiController], [FromBody] is inferred for complex types in POST/PUT actions, and [FromQuery] is inferred for simple types. You can still be explicit for clarity.
4. DTO — Purpose, Mapping, AutoMapper Basics
DTO (Data Transfer Object) is a plain object used to carry data between layers. It:
- Hides internal domain model properties (e.g., passwords, audit fields)
- Controls exactly what data is sent in API requests/responses
- Decouples the API contract from the database schema
// Domain model — internal
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string PasswordHash { get; set; } // ❌ should never be exposed
public DateTime CreatedAt { get; set; }
}
// DTO — what the API exposes
public class UserResponseDto
{
public int Id { get; set; }
public string Username { get; set; }
}
// Request DTO — what the client sends
public class CreateUserDto
{
public string Username { get; set; }
public string Password { get; set; }
}
Manual mapping:
var dto = new UserResponseDto
{
Id = user.Id,
Username = user.Username
};
AutoMapper — automatic mapping:
// Install: dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
// Configure mapping profile
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<User, UserResponseDto>();
CreateMap<CreateUserDto, User>();
}
}
// Register in Program.cs
builder.Services.AddAutoMapper(typeof(MappingProfile));
// Use in controller
public class UsersController : ControllerBase
{
private readonly IMapper _mapper;
public UsersController(IMapper mapper)
{
_mapper = mapper;
}
[HttpGet("{id}")]
public IActionResult Get(int id)
{
var user = _userRepository.GetById(id);
var dto = _mapper.Map<UserResponseDto>(user);
return Ok(dto);
}
}
5. Model Validation — Data Annotations, FluentValidation Basics
Data Annotations — add validation rules directly to model properties using attributes.
public class CreateProductDto
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; }
[Range(0.01, 9999.99)]
public decimal Price { get; set; }
[EmailAddress]
public string ContactEmail { get; set; }
[RegularExpression(@"^\d{4}-\d{2}-\d{2}$", ErrorMessage = "Use YYYY-MM-DD format")]
public string LaunchDate { get; set; }
}
With [ApiController], validation is automatic — if the model is invalid, it returns a 400 Bad Request with validation errors before the action runs.
Manual validation check (without [ApiController]):
[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
// proceed
}
FluentValidation — keeps validation rules in a separate class, making them more readable and testable.
// Install: dotnet add package FluentValidation.AspNetCore
public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MinimumLength(2)
.MaximumLength(100);
RuleFor(x => x.Price)
.GreaterThan(0)
.LessThan(10000);
}
}
// Register in Program.cs
builder.Services.AddFluentValidation(fv =>
fv.RegisterValidatorsFromAssemblyContaining<CreateProductValidator>());
6. Action Filters — IActionFilter, OnActionExecuting, OnActionExecuted
Action Filters run code before or after an action method executes. They are applied as attributes on controllers or individual actions.
public class LogActionFilter : IActionFilter
{
private readonly ILogger<LogActionFilter> _logger;
public LogActionFilter(ILogger<LogActionFilter> logger)
{
_logger = logger;
}
// Runs BEFORE the action
public void OnActionExecuting(ActionExecutingContext context)
{
_logger.LogInformation($"Action starting: {context.ActionDescriptor.DisplayName}");
}
// Runs AFTER the action
public void OnActionExecuted(ActionExecutedContext context)
{
_logger.LogInformation($"Action finished: {context.ActionDescriptor.DisplayName}");
}
}
Registering and applying filters:
// Apply globally (all controllers and actions)
builder.Services.AddControllers(options =>
options.Filters.Add<LogActionFilter>());
// Apply to a specific controller or action
[ServiceFilter(typeof(LogActionFilter))]
public class ProductsController : ControllerBase { }
Short-circuiting in filters:
public void OnActionExecuting(ActionExecutingContext context)
{
if (!context.HttpContext.User.Identity.IsAuthenticated)
{
context.Result = new UnauthorizedResult(); // stops the action from running
}
}
7. MVC Pattern — Model, View, Controller Flow
MVC (Model-View-Controller) is an architectural pattern that separates an application into three components:
- Model — represents data and business logic
- View — the UI template (Razor
.cshtmlfile for web apps) - Controller — handles user input, coordinates model and view
Request flow in ASP.NET Core MVC:
Browser Request: GET /Products/Details/5
↓
Router → matches to ProductsController.Details(5)
↓
Controller.Details(5)
→ fetches Product from Model/Repository
→ returns View("Details", product)
↓
View (Details.cshtml)
→ renders HTML using Model data
↓
HTML Response sent back to browser
// Model
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
// Controller
public class ProductsController : Controller
{
[HttpGet("{id}")]
public IActionResult Details(int id)
{
var product = _repo.GetById(id); // fetch model
return View(product); // pass model to view
}
}
// View — Details.cshtml
@model Product
<h1>@Model.Name</h1>
<p>Price: @Model.Price.ToString("C")</p>
In API development (no HTML views), the Controller returns JSON directly — the MVC pattern still applies but the "View" is the serialised JSON response.
Phase 5 – Middleware & Filters
1. Custom Middleware — Writing and Registering Middleware
Custom middleware lets you inject reusable request/response logic into the pipeline.
Class-based middleware:
public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
await _next(context); // call the next middleware
stopwatch.Stop();
_logger.LogInformation(
$"{context.Request.Method} {context.Request.Path} — {stopwatch.ElapsedMilliseconds}ms");
}
}
// Extension method for clean registration
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
=> app.UseMiddleware<RequestTimingMiddleware>();
}
Registering in Program.cs:
app.UseRequestTiming(); // uses the extension method
// or
app.UseMiddleware<RequestTimingMiddleware>();
Inline middleware (simple cases):
app.Use(async (context, next) =>
{
Console.WriteLine($"Before: {context.Request.Path}");
await next();
Console.WriteLine($"After: {context.Response.StatusCode}");
});
Short-circuit middleware (skip the rest of the pipeline):
app.Use(async (context, next) =>
{
if (context.Request.Path == "/health")
{
await context.Response.WriteAsync("Healthy"); // respond immediately
return; // don't call next()
}
await next();
});
2. Built-in Middleware
ASP.NET Core includes many built-in middleware components:
| Middleware | Method | Purpose |
|---|---|---|
| HTTPS Redirection | UseHttpsRedirection() | Redirect HTTP to HTTPS |
| Static Files | UseStaticFiles() | Serve files from wwwroot |
| Routing | UseRouting() | Match request to endpoint |
| CORS | UseCors() | Cross-origin request handling |
| Authentication | UseAuthentication() | Identify the user (who are you?) |
| Authorization | UseAuthorization() | Check user permissions (are you allowed?) |
| Exception Handling | UseExceptionHandler() | Global error handling |
| Response Compression | UseResponseCompression() | Compress responses (gzip/brotli) |
| Rate Limiting | UseRateLimiter() | Limit requests per time window |
Exception handling middleware:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var error = context.Features.Get<IExceptionHandlerFeature>();
await context.Response.WriteAsJsonAsync(new
{
error = "An unexpected error occurred.",
detail = error?.Error.Message
});
});
});
3. Action Filters vs Middleware — When to Use Which
| Aspect | Middleware | Action Filters |
|---|---|---|
| Scope | Entire HTTP pipeline | Only for MVC/API actions |
| Access to | HttpContext only | HttpContext + Action arguments, Result |
| DI support | Constructor injection | Constructor injection |
| Short-circuit | By not calling next() | By setting context.Result |
| Runs when | Every request | Only when a matching controller action runs |
| Use for | Auth, logging, CORS, exceptions, rate limiting | Validation, action-specific logging, response transformation |
Rule of thumb:
- Use Middleware for cross-cutting concerns that apply to all or most requests (authentication, logging, CORS).
- Use Action Filters when you need access to the controller/action context or want to apply logic to specific actions only.
Phase 6 – API & REST
1. REST Principles — HTTP Verbs, Resource-Based URLs
REST (Representational State Transfer) is an architectural style for designing APIs around resources (nouns, not verbs).
Core Principles:
- Stateless — each request contains all necessary information; no session state on the server.
- Resource-based — URLs represent resources, not actions.
- Uniform interface — use standard HTTP methods consistently.
- Client-Server — client and server are independent.
Resource-based URL design:
| Bad (verb-based) | Good (resource-based) |
|---|---|
/getProducts | GET /products |
/createProduct | POST /products |
/deleteProduct?id=5 | DELETE /products/5 |
/updateProduct/5 | PUT /products/5 |
2. HTTP Basics — Methods and Status Codes
HTTP Methods:
| Method | Purpose | Has Body | Idempotent |
|---|---|---|---|
| GET | Read a resource | No | ✅ Yes |
| POST | Create a resource | Yes | ❌ No |
| PUT | Replace a resource entirely | Yes | ✅ Yes |
| PATCH | Partially update a resource | Yes | ❌ No |
| DELETE | Delete a resource | No | ✅ Yes |
Common Status Codes:
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE (nothing to return) |
| 400 | Bad Request | Invalid input / validation error |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but not authorised |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, concurrency conflict |
| 500 | Internal Server Error | Unexpected server-side error |
In ASP.NET Core controllers:
return Ok(data); // 200
return Created(location, data); // 201
return NoContent(); // 204
return BadRequest(errors); // 400
return Unauthorized(); // 401
return Forbid(); // 403
return NotFound(); // 404
return Conflict(); // 409
return StatusCode(500); // 500
3. Building Web API — Minimal API vs Controller-Based
Controller-Based API — traditional approach, uses controller classes.
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repo;
public ProductsController(IProductRepository repo) => _repo = repo;
[HttpGet]
public async Task<IActionResult> GetAll()
=> Ok(await _repo.GetAllAsync());
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await _repo.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductDto dto)
{
var product = await _repo.AddAsync(dto);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
Minimal API — lightweight approach, defines endpoints directly in Program.cs. Best for small APIs or microservices.
var app = builder.Build();
app.MapGet("/api/products", async (IProductRepository repo) =>
Results.Ok(await repo.GetAllAsync()));
app.MapGet("/api/products/{id:int}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
app.MapPost("/api/products", async (CreateProductDto dto, IProductRepository repo) =>
{
var product = await repo.AddAsync(dto);
return Results.Created($"/api/products/{product.Id}", product);
});
Comparison:
| Feature | Controller-Based | Minimal API |
|---|---|---|
| Code organisation | Organised in classes | All in Program.cs (can be split) |
| Filters / Middleware | Full support | Limited filter support |
| Best for | Large, structured APIs | Small APIs, microservices |
| Boilerplate | More | Less |
4. Lazy Loading vs Eager Loading — EF Core Navigation Properties
Entity Framework Core loads related entities (navigation properties) in two main ways.
Eager Loading — loads related data in the same query using .Include(). Recommended when you know you'll need the related data.
// One query with a SQL JOIN — loads Product and its Category together
var products = await _context.Products
.Include(p => p.Category)
.Include(p => p.Reviews)
.ToListAsync();
Console.WriteLine(products[0].Category.Name); // ✅ no extra query
Lazy Loading — loads related data on-demand when the property is first accessed. Requires a proxy package and virtual navigation properties.
// Install: dotnet add package Microsoft.EntityFrameworkCore.Proxies
// In DbContext setup: options.UseLazyLoadingProxies()
public class Product
{
public int Id { get; set; }
public virtual Category Category { get; set; } // virtual = lazy loading
}
var product = await _context.Products.FindAsync(1);
// Category is not loaded yet
var name = product.Category.Name; // triggers a NEW SQL query here
Explicit Loading — load related data on-demand manually (no proxy needed):
var product = await _context.Products.FindAsync(1);
await _context.Entry(product)
.Reference(p => p.Category)
.LoadAsync();
await _context.Entry(product)
.Collection(p => p.Reviews)
.LoadAsync();
Comparison:
| Strategy | When data loads | SQL queries | Use when |
|---|---|---|---|
| Eager Loading | With main query (JOIN) | One query | You know you'll need related data |
| Lazy Loading | When property is accessed | N+1 queries (can be a problem) | Related data is rarely needed |
| Explicit Loading | When you call Load() | Controlled additional query | Need selective loading at runtime |
N+1 Problem: Lazy loading in a loop causes one query per item — e.g., 100 products = 100 extra category queries. Always prefer eager loading in lists.
Phase 7 – Authentication & Authorization
1. Authentication vs Authorization
| Concept | Question | Example |
|---|---|---|
| Authentication | Who are you? | Verifying the JWT token and identifying the user |
| Authorization | What are you allowed to do? | Checking if the user has the "Admin" role |
Authentication must happen before authorisation. A user must be identified before you can check their permissions.
2. JWT — Structure, Claims, Token Generation & Validation
JWT (JSON Web Token) is a compact, self-contained token used to securely transmit user identity. It consists of three Base64-encoded parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiQWRtaW4iLCJleHAiOjE3MjAwMDAwMDB9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header — algorithm and token type.
Payload — claims (user data): sub (subject), name, role, exp (expiry).
Signature — HMAC of header + payload, signed with a secret key. Verifies the token hasn't been tampered with.
Generating a JWT in ASP.NET Core:
// Install: dotnet add package System.IdentityModel.Tokens.Jwt
// Install: dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
public class TokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config) => _config = config;
public string GenerateToken(User user)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.Role, user.Role)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_config["JwtSettings:SecretKey"]));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["JwtSettings:Issuer"],
audience: _config["JwtSettings:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Configuring JWT validation:
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["JwtSettings:Issuer"],
ValidAudience = builder.Configuration["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["JwtSettings:SecretKey"]))
};
});
The client sends the token in the Authorization header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
3. Middleware for Auth — UseAuthentication, UseAuthorization
These two middleware components must be added to the pipeline in the correct order:
app.UseRouting();
app.UseAuthentication(); // 1. Identify who the user is (reads JWT, sets ClaimsPrincipal)
app.UseAuthorization(); // 2. Check if the identified user is allowed
app.MapControllers();
UseAuthentication reads the JWT from the Authorization header, validates it, and populates HttpContext.User with the user's claims.
UseAuthorization checks the [Authorize] attributes on controllers/actions and enforces access control.
4. [Authorize] Attribute — Role-Based and Policy-Based Authorization
Require authentication:
[Authorize] // user must be authenticated
public class ProductsController : ControllerBase { }
[AllowAnonymous] // override [Authorize] for a specific action
public IActionResult GetPublicProducts() { }
Role-based authorization:
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id) { }
[Authorize(Roles = "Admin,Manager")] // multiple roles (OR)
public IActionResult Update(int id) { }
Policy-based authorization (more flexible):
// Define the policy in Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditProducts", policy =>
policy.RequireRole("Admin")
.RequireClaim("department", "sales", "product-team"));
});
// Apply the policy
[Authorize(Policy = "CanEditProducts")]
[HttpPut("{id}")]
public IActionResult Update(int id) { }
Phase 8 – Security
1. HTTPS & SSL Basics
HTTPS (HyperText Transfer Protocol Secure) encrypts all communication between the client and server using TLS (Transport Layer Security) — commonly still called SSL.
In ASP.NET Core:
// Force HTTPS redirect in Program.cs
app.UseHttpsRedirection();
// Enforce HTTPS in development via launchSettings.json
// Or use HSTS (HTTP Strict Transport Security) in production
app.UseHsts(); // tells browsers to only ever use HTTPS for this domain
In production, your web server (IIS, Nginx, Azure App Service) handles the TLS certificate. In development, use the .NET developer certificate:
dotnet dev-certs https --trust
2. CORS — Cross-Origin Resource Sharing
Browsers enforce the Same-Origin Policy — a webpage at https://myapp.com cannot call an API at https://api.myapp.com by default. CORS is an HTTP mechanism that allows servers to selectively permit cross-origin requests.
// Program.cs
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.WithOrigins("https://myapp.com", "http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod());
});
app.UseCors("AllowFrontend"); // register before UseRouting
AllowAnyOrigin() with AllowCredentials() is not allowed — credentials require specific origins:
// ❌ Cannot combine AllowAnyOrigin with AllowCredentials
policy.AllowAnyOrigin().AllowCredentials();
// ✅ Use specific origins when credentials are needed
policy.WithOrigins("https://myapp.com").AllowCredentials();
3. Secrets Management — User Secrets, Environment Variables
Never hardcode secrets (connection strings, API keys, JWT secrets) in source code or appsettings.json committed to source control.
User Secrets (development) — stored outside the project directory, not committed to git:
# Enable user secrets for the project
dotnet user-secrets init
# Set a secret
dotnet user-secrets set "JwtSettings:SecretKey" "my-dev-secret-key"
// Access just like appsettings — configuration automatically merges them
var secretKey = builder.Configuration["JwtSettings:SecretKey"];
Environment Variables (production) — set at the OS/container/cloud level:
// Environment variable names use __ for hierarchy on Windows
// JwtSettings__SecretKey = "prod-secret-key"
var secretKey = builder.Configuration["JwtSettings:SecretKey"];
// automatically resolves from env var in production
In Azure — use Azure Key Vault or App Service Application Settings. In Docker, use environment variables or mounted secrets.
4. Common Vulnerabilities — SQL Injection
SQL Injection occurs when untrusted user input is concatenated into a SQL query, allowing attackers to modify the query.
// ❌ Vulnerable — direct string concatenation
string userInput = "'; DROP TABLE Users; --";
string sql = $"SELECT * FROM Users WHERE Username = '{userInput}'";
// Resulting SQL: SELECT * FROM Users WHERE Username = ''; DROP TABLE Users; --'
Prevention with parameterised queries:
// ✅ Safe — parameters are escaped automatically
using var command = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username", connection);
command.Parameters.AddWithValue("@username", userInput);
Using Entity Framework Core (automatically parameterised):
// ✅ Safe — EF Core always uses parameterised queries
var user = await _context.Users
.Where(u => u.Username == username)
.FirstOrDefaultAsync();
If you must use raw SQL in EF Core, use parameters:
// ✅ Safe — FormattableString is parameterised
var user = await _context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Username = {username}")
.FirstOrDefaultAsync();
// ❌ Unsafe — FromSqlRaw with string concatenation
var user = await _context.Users
.FromSqlRaw($"SELECT * FROM Users WHERE Username = '{username}'")
.FirstOrDefaultAsync();
Phase 9 – Logging & Monitoring (Optional)
1. Built-in Logging — ILogger, Log Levels
ASP.NET Core includes a built-in logging framework via ILogger<T>. Inject it anywhere through DI.
Log levels (lowest to highest severity):
| Level | Method | Use for |
|---|---|---|
| Trace | LogTrace | Very detailed, step-by-step diagnostics |
| Debug | LogDebug | Developer diagnostics |
| Information | LogInformation | Normal application flow events |
| Warning | LogWarning | Unexpected but recoverable situations |
| Error | LogError | Errors that affect the current operation |
| Critical | LogCritical | Fatal failures requiring immediate attention |
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger)
{
_logger = logger;
}
public async Task PlaceOrder(Order order)
{
_logger.LogInformation("Placing order {OrderId} for user {UserId}",
order.Id, order.UserId);
try
{
// process order
_logger.LogInformation("Order {OrderId} placed successfully", order.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to place order {OrderId}", order.Id);
throw;
}
}
}
Configure log levels in appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"MyApp.Services": "Debug"
}
}
}
2. Serilog / NLog — Structured Logging Basics
The built-in logger writes plain text. Serilog and NLog provide structured logging — logging with key-value properties that can be queried and filtered in log management tools (Seq, Elasticsearch, Application Insights).
Setting up Serilog:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
.Enrich.FromLogContext()
.CreateLogger();
builder.Host.UseSerilog();
Structured log messages:
// The {OrderId} placeholder becomes a structured property, not just text
_logger.LogInformation("Order {OrderId} placed by {UserId}", order.Id, order.UserId);
This allows log queries like "show me all logs where OrderId = 42" in Seq or Kibana.
Phase 10 – Performance Optimization (Optional)
1. Caching — In-Memory Cache, Distributed Cache (Redis Overview)
Why cache? Avoid re-computing or re-fetching expensive data (database queries, API calls) on every request.
In-Memory Cache — stores data in the web server's memory. Fast, but not shared across multiple server instances.
// Register in Program.cs
builder.Services.AddMemoryCache();
// Use in a service
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly IProductRepository _repo;
public ProductService(IMemoryCache cache, IProductRepository repo)
{
_cache = cache;
_repo = repo;
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
const string cacheKey = "all_products";
if (!_cache.TryGetValue(cacheKey, out IEnumerable<Product> products))
{
products = await _repo.GetAllAsync();
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
SlidingExpiration = TimeSpan.FromMinutes(2)
};
_cache.Set(cacheKey, products, options);
}
return products;
}
}
Distributed Cache (Redis) — stores data in a shared external cache. Ideal when running multiple server instances (load-balanced environments).
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
// Register in Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
});
// Use via IDistributedCache
public class ProductService
{
private readonly IDistributedCache _cache;
public async Task<string> GetProductJsonAsync(int id)
{
string cacheKey = $"product:{id}";
string cached = await _cache.GetStringAsync(cacheKey);
if (cached is null)
{
var product = await _repo.GetByIdAsync(id);
cached = JsonSerializer.Serialize(product);
await _cache.SetStringAsync(cacheKey, cached, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
}
return cached;
}
}
2. Async Best Practices — Avoiding Deadlocks, ConfigureAwait
Avoid .Result and .Wait() — they block the current thread and can cause deadlocks in environments with a synchronisation context (ASP.NET Framework, WPF):
// ❌ Can deadlock in certain environments
var result = GetDataAsync().Result; // blocking
GetDataAsync().Wait(); // blocking
// ✅ Always await instead
var result = await GetDataAsync();
ConfigureAwait(false) — when writing library code, tells the awaiter not to resume on the original synchronisation context. This improves performance and avoids deadlocks in library code:
// Library code — use ConfigureAwait(false)
public async Task<string> FetchDataAsync()
{
var response = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return response;
}
// ASP.NET Core application code — ConfigureAwait(false) is not strictly necessary
// (ASP.NET Core has no synchronisation context), but it can still help with performance
Avoid async void — exceptions thrown in async void methods cannot be caught by the caller and will crash the process:
// ❌ Avoid async void (except for event handlers)
public async void LoadData()
{
await FetchAsync(); // unhandled exceptions here = app crash
}
// ✅ Use async Task
public async Task LoadData()
{
await FetchAsync(); // caller can await and catch exceptions
}
Run independent tasks in parallel with Task.WhenAll:
// ❌ Sequential — each awaits before the next starts
var orders = await _orderRepo.GetAllAsync();
var products = await _productRepo.GetAllAsync();
var users = await _userRepo.GetAllAsync();
// ✅ Parallel — all three start simultaneously
var ordersTask = _orderRepo.GetAllAsync();
var productsTask = _productRepo.GetAllAsync();
var usersTask = _userRepo.GetAllAsync();
await Task.WhenAll(ordersTask, productsTask, usersTask);
var orders = await ordersTask;
var products = await productsTask;
var users = await usersTask;
Quick Reference
Common HTTP Status Codes
| Code | Meaning | When to Return |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH success |
| 201 Created | Resource created | POST success |
| 204 No Content | Success, no body | DELETE success |
| 400 Bad Request | Invalid input | Validation errors |
| 401 Unauthorized | Not authenticated | Missing/invalid token |
| 403 Forbidden | Not authorised | Authenticated but no permission |
| 404 Not Found | Resource missing | ID not found in database |
| 500 Internal Server Error | Server crashed | Unhandled exception |
C# Collection Interfaces
| Interface | Adds Over Previous | Typical Implementation |
|---|---|---|
IEnumerable<T> | Iteration only | Any collection |
ICollection<T> | Add, Remove, Count | List<T>, HashSet<T> |
IList<T> | Index access | List<T> |
IDictionary<K,V> | Key-value access | Dictionary<K,V> |
IQueryable<T> | DB query translation | EF Core DbSet |
Service Lifetimes
| Lifetime | Instance Created | Use for |
|---|---|---|
Transient | Every injection | Stateless, lightweight services |
Scoped | Once per request | DbContext, per-request services |
Singleton | Once, app lifetime | Cache, configuration, shared state |