.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.
π§ Wait, Is DI a Pattern or a Container?
Most people confuse these, so letβs be crystal clear:
DI is a design pattern β a technique where objects receive their dependencies rather than creating them. Thatβs it. You could do it with zero tools:
// Pure DI β no container, no framework, just the pattern
var db = new SqlDbContext("connection-string");
var orderService = new OrderService(db); // Manual injection
var controller = new OrdersController(orderService);This works fine for small apps. Itβs called Pure DI or Poor Manβs DI.
| Layer | What | Example |
|---|---|---|
| β The Pattern π§© | Dependency Injection β passing dependencies in through the constructor | MyService(SqlDbContext db) |
| β‘ The Container π¦ | A tool that automates the wiring so you donβt have to write new everywhere | services.AddScoped<IService, Service>() |
| β’ The Implementation ποΈ | .NETβs specific built-in container | Microsoft.Extensions.DependencyInjection |
π‘ Key insight: You can use the pattern without the container. But the container makes life easier once you have 50+ services.
The Container is basically a smart factory β at registration time itβs a registry (βwhen asked for X, give Yβ), and at resolution time itβs a builder (figure out what X needs, create everything, hand it over).
Think of the container as a vending machine:
- Registration = stocking it (βCoke is in row A5β)
- Resolution = pressing the button and getting your drink (plus any cup, lid, straw it needs)
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 |
π§ͺ Build Challenge: Tracing Scoped vs Transient
Prove that you understand DI lifetimes by building a diagnostic endpoint that shows Transient and Scoped behavior.
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 your learning path repo
- β Show Jeri the results
π§ͺ Build Challenge: The Captive Dependency Bug
Now that you proved Transient vs Scoped, letβs explore the most common DI bug in production β and why modern .NET protects you from it.
β οΈ What Youβll Discover First
.NET 6+ actively blocks this bug. When you try to register BrokenSingleton, the framework throws:
Cannot consume scoped service 'ScopedGuidService' from singleton 'BrokenSingleton'
This error IS the proof. Microsoft saw this bug destroy production apps so often that they baked in a validation. So the challenge has 3 parts:
- Hit the error β try to register BrokenSingleton, see it crash
- Force the leak (optional) β see the captive behavior with a workaround
- Fix it β use
IServiceScopeFactoryproperly
Part 1: The Setup (Expected to Fail)
Create a Singleton service that depends on a Scoped service β the bug:
// β οΈ THIS IS THE BUG β Singleton holding a Scoped
public class BrokenSingleton
{
private readonly string _singletonGuid; // Set once, forever
private readonly ScopedGuidService _scopedService; // β οΈ Captive!
public BrokenSingleton(ScopedGuidService scopedService)
{
_singletonGuid = Guid.NewGuid().ToString();
_scopedService = scopedService; // This Scoped would be CAPTIVE
}
public string GetSingletonGuid() => _singletonGuid;
public string GetCapturedScopedGuid() => _scopedService.GetGuid();
}Register it:
builder.Services.AddSingleton<BrokenSingleton>(); // β οΈ Will throw!Expected result: App crashes on startup with the captive dependency error. This is .NET protecting you.
Part 2: Force the Bug (for learning purposes)
To actually see the captive behavior, bypass the validation by manually creating an undisposed scope:
builder.Services.AddSingleton<BrokenSingleton>(sp =>
{
var scopeFactory = sp.GetRequiredService<IServiceScopeFactory>();
var scope = scopeFactory.CreateScope(); // No 'using' β never disposed (leak!)
var scoped = scope.ServiceProvider.GetRequiredService<ScopedGuidService>();
return new BrokenSingleton(scoped);
});Then create the endpoint GET /api/diagnostics/captive and hit it twice:
{
"singletonGuid": "abc-123",
"capturedScopedGuid": "def-456",
"requestScopedGuid1": "ghi-789",
"requestScopedGuid2": "ghi-789"
}| Refresh | singletonGuid | capturedScopedGuid | requestScopedGuid1 | requestScopedGuid2 |
|---|---|---|---|---|
| Request 1 | abc | def | ghi | ghi |
| Request 2 | abc β
(same) | def β
(STILL SAME!) | jkl β (changed) | jkl β (changed) |
The bug: capturedScopedGuid never changes β itβs captive. Request 2 should have gotten a fresh Scoped, but instead it got the stale one from Request 1. If this were a DbContext, youβd have stale data, connection leaks, and wrong results for every user after the first request.
Part 3: The Fix β Create a New Service
Create a new service that does it right. Donβt modify BrokenSingleton β that class exists to show the bug, and you should keep it as a reference for what NOT to do.
Replace direct injection with IServiceScopeFactory:
public class SingletonWithFreshScope
{
private readonly string _singletonGuid;
private readonly IServiceScopeFactory _scopeFactory;
public SingletonWithFreshScope(IServiceScopeFactory scopeFactory)
{
_singletonGuid = Guid.NewGuid().ToString();
_scopeFactory = scopeFactory;
}
public string GetSingletonGuid() => _singletonGuid;
public string GetFreshScopedGuid()
{
using var scope = _scopeFactory.CreateScope();
var scoped = scope.ServiceProvider.GetRequiredService<ScopedGuidService>();
return scoped.GetGuid();
}
}Register it:
builder.Services.AddSingleton<SingletonWithFreshScope>(); // β
IServiceScopeFactory is Singleton-safe!Now hit the endpoint twice:
| Refresh | singletonGuid | freshScopedGuid |
|---|---|---|
| Request 1 | abc | def |
| Request 2 | abc β
| xyz β Changed! β
|
Why this proves the fix: Every call to GetFreshScopedGuid() returns a DIFFERENT Guid β a new Scoped instance is created, used, and disposed. No captive, no leak. π₯
Key insight: IServiceScopeFactory is itself a Singleton β itβs the ONLY safe way for Singletons to create Scoped instances.
Rules:
- β Donβt ask Jeri for code
- β Hit the error first, force the leak second, fix it third
- β Push to your learning path repo
- β Show Jeri your results
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) |
π§ͺ Build Challenge: Tracing Async Execution
Prove that async/await doesnβt make things faster β it makes them not block each other.
The Setup
Create a service with a method that simulates an async I/O operation:
public class AsyncTraceService
{
private readonly ILogger<AsyncTraceService> _logger;
public AsyncTraceService(ILogger<AsyncTraceService> logger)
{
_logger = logger;
}
public async Task<string> SimulateIoCallAsync(string name, int delayMs)
{
var threadId = Environment.CurrentManagedThreadId;
_logger.LogInformation("[{Name}] START on Thread {Thread}", name, threadId);
await Task.Delay(delayMs); // Simulate HTTP call / DB query / file read
var threadIdAfter = Environment.CurrentManagedThreadId;
_logger.LogInformation("[{Name}] END on Thread {Thread} ({After}ms)", name, threadIdAfter, delayMs);
return $"{name} done in {delayMs}ms";
}
}Register it:
builder.Services.AddScoped<AsyncTraceService>();The Test: Sequential vs Parallel
Create an endpoint GET /api/diagnostics/async that calls SimulateIoCallAsync three times (200ms, 100ms, 300ms):
Test A β Sequential (await each one):
var r1 = await service.SimulateIoCallAsync("Call A", 200);
var r2 = await service.SimulateIoCallAsync("Call B", 100); // Waits for A first!
var r3 = await service.SimulateIoCallAsync("Call C", 300); // Waits for B first!
// Total: ~600ms β°Test B β Parallel (start all, await together):
var t1 = service.SimulateIoCallAsync("Call A", 200);
var t2 = service.SimulateIoCallAsync("Call B", 100);
var t3 = service.SimulateIoCallAsync("Call C", 300);
await Task.WhenAll(t1, t2, t3);
// Total: ~300ms β° (just the longest!)Return both results with elapsed time.
The Expected Result
| Test | Approach | Total Time | Thread behavior |
|---|---|---|---|
| A | Sequential (await each) | ~600ms | await frees thread per call; next call resumes on any available thread |
| B | Parallel (Task.WhenAll) | ~300ms | All START on same thread, END threads may differ |
β οΈ Key insight: Thread switching applies to BOTH tests! Every
awaitfrees the thread. After the delay, the continuation resumes on any available thread pool thread β not necessarily the original one. ASP.NET Core has noSynchronizationContext, so threads can (and sometimes do) switch in both Test A and Test B.
Test A (Sequential) β Thread switching within each call
Since Test A chains await one after another, each await Task.Delay() inside SimulateIoCallAsync frees the thread. The next callβs START thread = the previous callβs END thread:
// Test A: Sequential, all on same thread (most common)
[Call A] START on Thread 14
[Call A] END on Thread 14 β Same, thread pool was free
[Call B] START on Thread 14 β Chains after Call A
[Call B] END on Thread 14
[Call C] START on Thread 14
[Call C] END on Thread 14
// Test A: Sequential, thread switches mid-call (less common)
// Each await can resume on a different thread
[Call A] START on Thread 6
[Call A] END on Thread 9 β Different! Thread freed during await
[Call B] START on Thread 9 β Chains after Call A's END
[Call B] END on Thread 9
[Call C] START on Thread 9
[Call C] END on Thread 6 β Different again!
Test B (Parallel) β All start together, END threads may differ
Since all 3 calls start before any await completes, they all START on the same request thread:
// Test B: Most common β all END on the same thread
[Call A] START on Thread 14
[Call B] START on Thread 14
[Call C] START on Thread 14
[Call B] END on Thread 14 β Thread pool was free when B resumed
[Call A] END on Thread 14
[Call C] END on Thread 14
// Test B: Less common β one switches, rest stay
[Call A] START on Thread 6
[Call B] START on Thread 6
[Call C] START on Thread 6
[Call B] END on Thread 9 β Different! (B's delay finished first, thread 6 was busy)
[Call A] END on Thread 6 β Thread 6 free again
[Call C] END on Thread 6
// Test B: Least common β all switch (thread pool pressure)
[Call A] START on Thread 4
[Call B] START on Thread 4
[Call C] START on Thread 4
[Call B] END on Thread 6
[Call A] END on Thread 10
[Call C] END on Thread 8
π Summary: Test A vs Test B Thread Behavior
| Aspect | Test A (Sequential) | Test B (Parallel) |
|---|---|---|
| START pattern | Chained β each call starts on previous callβs last thread | All 3 start on the same request thread |
| Can thread switch within a call? | β
Yes β await frees thread in every call | β Yes β same reason |
| Why it matters | Even a simple await chain doesnβt block a thread | Proves multiple tasks truly run concurrently |
| Real lesson | async/await is about thread efficiency, not parallelism |
π¨ Bonus: Prove the Deadlock
Add a THIRD endpoint GET /api/diagnostics/async-deadlock that calls .Result instead of await:
// β BUG: .Result blocks the thread
public IActionResult GetDataSync()
{
var result = service.SimulateIoCallAsync("Deadlock", 1000).Result;
return Ok(new { result });
}Expected: Request hangs for ~1 second while the thread is blocked doing nothing. Compare with the async version that frees the thread.
π What .Result Actually Does vs What People Think
What most people think: .Result keeps the thread busy working on the task from START to END β one thread, one job, fine.
[Deadlock] START on Thread 18
... [Thread 18 is "working" for 1 second] ...
[Deadlock] END on Thread 18 β Same thread, looks okay
What actually happens: .Result wastes the thread. The original thread (18) starts the async method, then hits .Result and stops β it canβt do anything, not even run the rest of the method.
[Deadlock] START on Thread 18 β Starts the async method
[Deadlock] hits `await Task.Delay` β Thread 18 does `.Result` β π« BLOCKED
... [Thread 18 does NOTHING for 1 second β can't serve other requests] ...
... [A DIFFERENT thread must rescue the task] ...
[Deadlock] END on Thread 22 β Different thread! Thread 18 was wasted
The proof from real logs:
[Call B] END on Thread 20 β Test B running in parallel
[Deadlock] START on Thread 18 β Deadlock starts
[Call A] END on Thread 22 β Test B continues
[Deadlock] END on Thread 22 β Deadlock's END on Thread 22! NOT 18!
[Call C] END on Thread 18 β Call C uses Thread 18 (finally free after .Result unblocked)
Notice: Thread 18 is free to do Call Cβs END only after .Result unblocked. But for the entire 1 second delay, Thread 18 was completely wasted β it could have handled 10+ requests in that time if await was used instead.
β±οΈ The Real Cost: Timing Comparison
| Approach | Total Time | Thread Utilization |
|---|---|---|
async / await β
| ~300ms (max of 3 tasks) | Thread freed to handle other requests |
.Result β | ~1000ms (blocked) | Thread wasted for 1 second doing nothing |
Put a Stopwatch on both endpoints and see for yourself β the .Result version always takes longer because it wastes a whole thread.
π§ The Senior Engineer Takeaway
.Result isnβt just slower β itβs wasteful. A blocked thread:
- β Canβt serve other incoming requests
- β Canβt even finish its own task (someone else has to)
- β Hurts scalability (each blocked thread consumes memory/overhead)
- β The continuation MUST resume on a different thread because the original is stuck
This is exactly why the rule is: βasync all the way upβ β never mix sync and async.
Rules:
- β Donβt ask Jeri for code
- β Build the service, prove sequential vs parallel timing
- β Witness thread switching in the logs
- β Push to your learning path repo
- β Show Jeri your timing results
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 the IHost Lifecycle
Prove that a .NET application has a predictable lifecycle β and you can hook into it.
Build a service that traces every stage of the appβs life:
The Setup
Create a LifecycleTrackerService that implements IHostedService:
public class LifecycleTrackerService : IHostedService
{
private readonly ILogger<LifecycleTrackerService> _logger;
public LifecycleTrackerService(ILogger<LifecycleTrackerService> logger)
{
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("π« [IHostedService] School opens β Host is starting...");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("π [IHostedService] School closes β Host is stopping...");
return Task.CompletedTask;
}
}Register it:
// β
Factory pattern β one instance for BOTH host management and DI injection
builder.Services.AddSingleton<LifecycleTrackerService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<LifecycleTrackerService>());π‘ Why the factory pattern?
AddHostedService<T>()registers the type internally, but your controller also needs to injectLifecycleTrackerServicefor the/lifecycleendpoint. If you write bothAddHostedService<LifecycleTrackerService>()ANDAddSingleton<LifecycleTrackerService>(), you get two separate instances β one managed by the host, one resolved by the controller. The factory pattern ensures one instance, shared by both.
Level 2: Hook into Application Lifetime
Add a SECOND service that uses app.Lifetime events. Register it in Program.cs:
// In Program.cs, AFTER builder.Build()
var app = builder.Build();
app.Lifetime.ApplicationStarted.Register(() =>
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("π [Lifetime] App is ready to serve requests!");
});
app.Lifetime.ApplicationStopping.Register(() =>
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("π [Lifetime] App is stopping (graceful shutdown)...");
});
app.Lifetime.ApplicationStopped.Register(() =>
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("π§Ή [Lifetime] Cleanup complete. Goodbye!");
});Add a simple diagnostic endpoint:
[HttpGet("lifecycle")]
public IActionResult GetLifecycle()
{
var uptime = (DateTime.UtcNow - _startTime);
return Ok(new { status = "running", uptime = uptime.ToString(@"hh\:mm\:ss"), since = _startTime });
}The Expected Output
Run the app and watch the logs:
π« [IHostedService] School opens β Host is starting... β IHostedService.StartAsync
π [Lifetime] App is ready to serve requests! β ApplicationStarted
(info: Microsoft.Hosting.Lifetime) Now listening on: http://localhost:5000
π¨ GET /api/diagnostics/lifecycle β response: "running" β Your first request
π¨ GET /api/diagnostics/lifecycle β response: "running" β More requests...
π [Lifetime] App is stopping (graceful shutdown)... β Ctrl+C β ApplicationStopping
π« [IHostedService] School closes β Host is stopping... β IHostedService.StopAsync
π§Ή [Lifetime] Cleanup complete. Goodbye! β ApplicationStopped
π¬ Prove the Order
The key insight: IHostedService.StartAsync runs BEFORE ApplicationStarted. This matters when you need to run migrations or warm up caches before accepting requests.
| Event | Order | What itβs for |
|---|---|---|
IHostedService.StartAsync | 1st π₯ | Pre-startup tasks (migrations, seed data, warm caches) |
ApplicationStarted | 2nd π₯ | Signal that the app is ready for traffic |
| Requests served | 3rd π₯ | Normal operation |
ApplicationStopping | 4th | Signal that shutdown is beginning (finish in-flight requests) |
IHostedService.StopAsync | 5th | Cleanup (flush logs, close connections) |
ApplicationStopped | 6th | Everything is done |
π§ The Senior Engineer Takeaway
Knowing the lifecycle order lets you answer interview questions like:
- βWhere would you put database migrations in a .NET app?β β
IHostedService.StartAsync(before any request arrives!) - βHow do you ensure graceful shutdown?β β
ApplicationStopping+IHostedService.StopAsync
Rules:
- β Donβt ask Jeri for code
- β See all 6 lifecycle events in order
- β Run the app, hit the endpoint, then Ctrl+C
- β Push to your learning path repo
- β Show Jeri your output
Related:
- Senior .NET Engineer Learning Path 2026 β Phase 1: Foundation
- ASP.NET Core Middleware Pipeline Explained β Next topic after this one