.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/await at 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.

LifetimeRegistrationHow Long It LivesReal-World Analogy
TransientAddTransient<T>()New instance every time it’s requestedDisposable coffee cup β˜• β€” new one each time
ScopedAddScoped<T>()Same instance per HTTP requestRestaurant order 🧾 β€” same order throughout your meal
SingletonAddSingleton<T>()Same instance for the entire app lifetimeRestaurant 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 forever

When 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:

  • SqlDbContext is Scoped β†’ meant to be disposed after each request
  • MyCache is 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 stateTransient
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 SingletonUse 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 suffixMix sync and async in the same call chain
Use Task.WhenAll for parallel workUse async void in try/catch (it won’t catch)
Use ConfigureAwait(false) in librariesCall 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 stopped

Lifecycle 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:

  1. Create three small services:

    • IGuidPrinterService (interface with string GetGuid())
    • TransientGuidService : IGuidPrinterService β€” registered as Transient
    • ScopedGuidService : IGuidPrinterService β€” registered as Scoped
  2. Each generates a new Guid in its constructor and returns it via GetGuid()

  3. In a controller, inject TWO instances of each service

  4. Create an endpoint GET /api/diagnostics/guids that 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!)
    }
  5. 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-api repo
  • βœ… Show Jeri the results

Related: