.NET Internals β How It All Works Under the Hood
Learning Context: Part of Phase 1 (Foundation β .NET Internals) in the Senior .NET Engineer Learning Path 2026.
Covers these Phase 1 checklist items:
- β¬ .NET Internals β Middleware pipeline, DI container lifetimes,
async/awaitat IL level- β¬ Dependency Injection β Scoped vs Transient vs Singleton pitfalls, keyed services
Prerequisites: None. This is the starting point.
π§Έ Baby Explanation: The Three-Layer Cake
Think of a .NET application like a three-layer cake π:
βββββββββββββββββββββββββββββββββββ
β π° Your Code (Controllers, β β YOU write this
β Services, Middleware) β
βββββββββββββββββββββββββββββββββββ€
β π° ASP.NET Core (Web Host, β β Microsoft built this
β Kestrel, Routing) β
βββββββββββββββββββββββββββββββββββ€
β π° .NET Runtime (CLR, GC, β β This runs EVERYTHING
β JIT Compiler) β
βββββββββββββββββββββββββββββββββββ
The .NET Runtime is the bottom layer β itβs the engine. It:
- Compiles your C# code into machine code (JIT)
- Manages memory (Garbage Collection)
- Handles threads and async operations
You donβt need to know every detail, but understanding the engine helps you write better code.
Part 1: Dependency Injection β The Waiter Analogy π½οΈ
What is DI?
Imagine youβre at a restaurant. You donβt walk into the kitchen and cook your own food β a waiter brings it to you.
// β WITHOUT DI: You cook your own food
public class OrderService
{
private readonly SqlDbContext _db;
public OrderService()
{
_db = new SqlDbContext("connection-string-here"); // You create it
}
}// β
WITH DI: The waiter brings it to you
public class OrderService
{
private readonly SqlDbContext _db;
public OrderService(SqlDbContext db) // "Waiter, I need a SqlDbContext"
{
_db = db; // The waiter (DI container) hands it to you
}
}The DI Container is the βwaiterβ β it creates objects and hands them to whoever needs them.
The DI Container β How It Works
When your app starts, you register services:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// "Hey waiter, when someone asks for IOrderService, give them OrderService"
builder.Services.AddScoped<IOrderService, OrderService>();
// "Hey waiter, here's a ready-made config object"
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);Then when a request comes in, the container:
1. Web request arrives β "I need an OrdersController"
2. Container checks: "OrdersController needs IOrderService"
3. Container checks: "IOrderService β OrderService β needs SqlDbContext"
4. Container creates: SqlDbContext β OrderService β OrdersController
5. Hands OrdersController to ASP.NET Core
This is called constructor injection β dependencies are passed through the constructor.
The Three Lifetimes
This is the most important DI concept. Each βlifetimeβ tells the container how long to keep an object alive.
| Lifetime | Registration | How Long It Lives | Real-World Analogy |
|---|---|---|---|
| Transient | AddTransient<T>() | New instance every time itβs requested | Disposable coffee cup β β new one each time |
| Scoped | AddScoped<T>() | Same instance per HTTP request | Restaurant order π§Ύ β same order throughout your meal |
| Singleton | AddSingleton<T>() | Same instance for the entire app lifetime | Restaurant building π’ β thereβs only one |
π― Visual Example: Three Lifetimes in Action
// Registration
builder.Services.AddTransient<ITransientService, MyService>(); // New every time
builder.Services.AddScoped<IScopedService, MyService>(); // New per request
builder.Services.AddSingleton<ISingletonService, MyService>(); // One foreverWhen TWO requests arrive at the same time:
Request A: Request B:
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β OrdersController β β ProductsController β
β β ITransientService β (new) β β ITransientService β (new)
β β IScopedService β (new) β β IScopedService β (new)
β β ISingletonService β (same) β β ISingletonService β (same) β SHARED!
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
- Transient: Request A and Request B get DIFFERENT instances β
- Scoped: Request A and Request B get DIFFERENT instances β
- Singleton: Request A and Request B share the SAME instance β οΈ
β οΈ The Most Common DI Bug: Captive Dependency
// β BUG: Injecting a Scoped service into a Singleton
builder.Services.AddSingleton<IMyCache, MyCache>();
builder.Services.AddScoped<SqlDbContext>();
public class MyCache
{
private readonly SqlDbContext _db; // β οΈ Scoped injected into Singleton!
public MyCache(SqlDbContext db)
{
_db = db; // This DbContext will live FOREVER and never get disposed
}
}Why this breaks:
SqlDbContextis Scoped β meant to be disposed after each requestMyCacheis Singleton β lives forever- The DbContext inside MyCache never gets disposed β connection leaks, stale data
The fix:
// β
Inject IServiceScopeFactory instead
public class MyCache
{
private readonly IServiceScopeFactory _scopeFactory;
public MyCache(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public void DoWork()
{
using var scope = _scopeFactory.CreateScope(); // Create a mini-scope
var db = scope.ServiceProvider.GetRequiredService<SqlDbContext>();
// Use db... it will be disposed when the scope ends
}
}Lifetime Selection Guide
| If your service⦠| Use |
|---|---|
| Is stateless, lightweight, no shared state | Transient |
| Needs to share state within a request (like DbContext) | Scoped |
| Holds shared state, is expensive to create, or lives forever (cache, config) | Singleton |
| Depends on a Scoped service but MUST be Singleton | Use IServiceScopeFactory |
Part 2: async/await β How It Actually Works
π§Έ Baby Explanation: The Microwave Analogy
// β SYNC: You stand in front of the microwave waiting
public void MakeBreakfast()
{
var eggs = FryEggs(); // 5 minutes β you just stand there
var toast = ToastBread(); // 3 minutes β still standing there
var coffee = BrewCoffee(); // 2 minutes β you're tired of standing
// Total: 10 minutes
}// β
ASYNC: You start one task, then do another while waiting
public async Task MakeBreakfastAsync()
{
var eggsTask = FryEggsAsync(); // Start frying, it'll tell you when done
var toastTask = ToastBreadAsync(); // Start toasting while eggs fry
var coffeeTask = BrewCoffeeAsync(); // Start brewing while everything cooks
await Task.WhenAll(eggsTask, toastTask, coffeeTask);
// Total: ~5 minutes (the longest task)
}Key Insight: async/await doesnβt make things faster β it makes them not block each other.
What Actually Happens Under the Hood
When you write:
public async Task<string> GetDataAsync()
{
var result = await _httpClient.GetStringAsync("https://api.example.com");
return result;
}The compiler transforms this into a state machine:
Step 1: Call GetStringAsync β return an incomplete Task
Step 2: Register a "callback" β "when the HTTP response arrives, resume my code"
Step 3: Return control to the caller (don't block the thread!)
...
[Thread is free to handle other requests]
...
Step 4: HTTP response arrives β the callback fires
Step 5: Resume the state machine right after 'await'
Step 6: Return the result string
π¬ The Three Async Pitfalls
Pitfall 1: async void π₯π₯π₯
// β NEVER DO THIS (unless it's an event handler)
public async void ProcessData()
{
await Task.Delay(1000);
throw new Exception("Boom!"); // This exception is SWALLOWED β you'll never see it
}Rule: Always return Task or Task<T>. The only exception is event handlers (like button clicks in WPF).
Pitfall 2: .Result / .Wait() = Deadlock
// β Can deadlock in ASP.NET Core (especially with SynchronizationContext)
public string GetData()
{
return GetDataAsync().Result; // Blocks the thread waiting
}Rule: βAsync all the way upβ β if you start async, donβt mix sync calls.
Pitfall 3: Forgetting ConfigureAwait(false) in Libraries
// β
In library code
var result = await _httpClient.GetStringAsync(url).ConfigureAwait(false);This tells .NET: βDonβt try to return to the original context.β Important in libraries but less critical in ASP.NET Core (which has no SynchronizationContext).
Async Best Practices Summary
| Do β | Donβt β |
|---|---|
Return Task / Task<T> | Return void (except event handlers) |
Use await with using (C# 8+) | Use .Result or .Wait() |
Name async methods with Async suffix | Mix sync and async in the same call chain |
Use Task.WhenAll for parallel work | Use async void in try/catch (it wonβt catch) |
Use ConfigureAwait(false) in libraries | Call async from constructor (use factory pattern) |
Part 3: The IHost Lifecycle
π§Έ Baby Explanation: The School Day
A .NET application has a lifecycle just like a school day:
π« 8:00 AM β School opens (Host starts)
β
π 8:05 β Teachers prepare (ConfigureServices β register DI)
β
π 8:15 β Classes begin (Build the app β middleware pipeline ready)
β
π 8:30-3:00 β Students learn (Requests are processed)
β
π 3:00 PM β Bell rings (Host is stopping)
β
π§Ή 3:05 β Clean up (Dispose services, flush logs, close connections)
β
π 3:15 β School closed (Host stopped)
In Code:
var builder = WebApplication.CreateBuilder(args);
// π "Teachers prepare" β Register services
builder.Services.AddControllers();
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
// π "Classes begin" β Configure middleware pipeline
app.UseMiddleware<RequestTimingMiddleware>();
app.MapControllers();
// π "School day" β Start listening for requests
app.Run(); // This blocks until the app is stoppedLifecycle Hooks β What Runs When
// Runs when the host is STARTING (before any requests)
builder.Services.AddHostedService<DatabaseMigrationService>();
// In your service:
public class DatabaseMigrationService : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
// Run migrations, seed data, warm up caches
}
public async Task StopAsync(CancellationToken cancellationToken)
{
// Graceful shutdown β finish in-flight work, close connections
}
}π§ͺ Build Challenge: Tracing Scoped vs Transient
Your POS API likely has a DbContext registered as Scoped. Letβs prove you understand lifetimes.
Build a diagnostic endpoint that demonstrates the difference:
-
Create three small services:
IGuidPrinterService(interface withstring GetGuid())TransientGuidService : IGuidPrinterServiceβ registered as TransientScopedGuidService : IGuidPrinterServiceβ registered as Scoped
-
Each generates a new
Guidin its constructor and returns it viaGetGuid() -
In a controller, inject TWO instances of each service
-
Create an endpoint
GET /api/diagnostics/guidsthat returns:{ "transient1": "abc-123", "transient2": "def-456", // SHOULD be different from transient1 "scoped1": "ghi-789", "scoped2": "ghi-789" // SHOULD be same as scoped1 (same request!) } -
Hit the endpoint twice β verify Scoped values CHANGE between requests but are the same WITHIN a request
Rules:
- β Donβt ask Jeri for code
- β Use this document to understand the lifetimes
- β
Push to
pos-apirepo - β Show Jeri the results
Related:
- Senior .NET Engineer Learning Path 2026 β Phase 1: Foundation
- ASP.NET Core Middleware Pipeline Explained β Next topic after this one