Dependency Injection, Configuration and Logging

Modern .NET applications (console tools, workers, ASP.NET Core services, desktop apps) are built on the same three pieces from Microsoft.Extensions.*, tied together by a host:

Concept

Modern .NET applications (console tools, workers, ASP.NET Core services, desktop apps) are built on the same three pieces from Microsoft.Extensions.*, tied together by a host:

Dependency inversion (the "D" in SOLID, Module 55): high-level code depends on abstractions (IEcuReader), not on concrete classes (CanEcuReader). Dependency injection supplies those abstractions from outside — usually through the constructor.

IServiceCollection: AddSingleton, AddScoped, AddTransient

IServiceProvider: GetRequiredService<T>() (throws if missing), GetService<T>() (returns null)

provider.CreateScope() — ASP.NET Core creates one per HTTP request One for the whole application

Caches, clocks, configuration, HttpClient factories — must be thread-safe One per scope (request)

DbContext, unit of work, per-request session New on every resolution Lightweight stateless services

The container disposes the IDisposable services it created when their scope (or the root provider) is disposed. A captive dependency — a singleton holding a scoped service — is a classic bug; ValidateScopes (on by default in Development) detects resolving scoped services from the root.

IConfiguration merges key/value pairs from providers, and later providers override earlier ones. The default host order is: appsettings.json → appsettings.{Environment}.json → user secrets (Development only) → environment variables → command line.

Keys are hierarchical with : (Bench:Name); environment variables use __ (Bench__Name) because : is not portable.

The environment (DOTNET_ENVIRONMENT / ASPNETCORE_ENVIRONMENT = Development, Staging, Production) selects the environment-specific file.

Secrets never go into appsettings.json or source control: use dotnet user-secrets in development and a vault (Azure Key Vault, AWS Secrets Manager) or environment variables in production.

The options pattern binds a section to a class: services.AddOptions<BenchOptions>().Bind(section).ValidateDataAnnotations().ValidateOnStart(), then inject IOptions<T> (fixed), IOptionsSnapshot<T> (per scope, reloads) or IOptionsMonitor<T> (live change notifications).

ILogger<T> writes log events that providers (Console, Debug, EventLog, EventSource, OpenTelemetry, Serilog, NLog...) deliver to their destinations.

Trace / Debug Detailed diagnostics, off in production Normal flow: "flash started" Unexpected but handled An operation failed The application cannot continue

Structured logging: logger.LogInformation("Flashing {File} with {Blocks} blocks", file, blocks) keeps File and Blocks as named fields that log stores can query. Do not use string interpolation in log calls: it loses the fields and formats the string even when the level is disabled.

Scopes (BeginScope) attach context (ECU, request ID) to every event inside them.

Event IDs identify event types; pass exceptions as the first argument, not in the message.

Filters set minimum levels per category ("Microsoft": "Warning").

Performance: [LoggerMessage] source-generated methods avoid boxing and parsing templates on every call.

flowchart TB
    APP["Application / Host"] --> DI["Dependency injection container"]
    DI --> SVC["Services (your classes)"]
    CFG["Configuration: JSON, env vars, command line, secrets"] --> OPT["Options classes"]
    OPT --> SVC
    LOG["Logging: ILogger, providers, filters"] --> SVC
    APP --> CFG
    APP --> LOG

Why It Matters

Every ASP.NET Core application, worker service and serious tool uses these three together. Understanding lifetimes prevents subtle bugs (a DbContext shared across threads), layered configuration makes the same build run on a developer PC, a HIL bench and the cloud, and structured logs are what you search at 3 a.m. when a test bench fails.

Syntax

services.AddSingleton<IClock, SystemClock>();
services.AddScoped<DiagnosticSession>();
services.AddOptions<BenchOptions>().Bind(config.GetSection("Bench")).ValidateDataAnnotations().ValidateOnStart();
public class ReportService(IEcuReader reader, IOptions<BenchOptions> options, ILogger<ReportService> logger) { ... }
logger.LogInformation("Flashing {File}", file);

Example

These programs use the ASP.NET Core SDK (#:sdk Microsoft.NET.Sdk.Web) only because it brings all Microsoft.Extensions.* libraries with it; a console project would add the Microsoft.Extensions.Hosting package instead.

45.1 Service lifetimes, constructor injection and scope validation

45.2 Layered configuration and the options pattern Run with the command-line argument --Bench:CanBitrate=250000.

45.3 Host, structured logging, scopes, event IDs and a logging provider

#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.Extensions.Options;

// ---- Service lifetimes ----
var services = new ServiceCollection();
services.AddSingleton<IClock, SystemClock>();
services.AddScoped<DiagnosticSession>();          // one per scope (per HTTP request in ASP.NET Core)
services.AddTransient<FrameBuilder>();            // new instance every time
services.AddTransient<IEcuReader, SimulatedEcuReader>();
services.AddTransient<ReportService>();

using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
using (IServiceScope scope1 = provider.CreateScope())
using (IServiceScope scope2 = provider.CreateScope())
{
    var s1a = scope1.ServiceProvider.GetRequiredService<DiagnosticSession>();
    var s1b = scope1.ServiceProvider.GetRequiredService<DiagnosticSession>();
    var s2 = scope2.ServiceProvider.GetRequiredService<DiagnosticSession>();
    var f1 = scope1.ServiceProvider.GetRequiredService<FrameBuilder>();
    var f2 = scope1.ServiceProvider.GetRequiredService<FrameBuilder>();
    var c1 = scope1.ServiceProvider.GetRequiredService<IClock>();
    var c2 = scope2.ServiceProvider.GetReq
…
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

// Files that would normally sit next to the app
string dir = Path.Combine(Path.GetTempPath(), "eh_config_demo");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "appsettings.json"), """
    {
      "Bench": { "Name": "HIL-01", "CanBitrate": 500000, "Channels": [ "CAN1", "CAN2" ], "TimeoutMs": 2000 },
      "Logging": { "LogLevel": { "Default": "Information" } }
    }
    """);
File.WriteAllText(Path.Combine(dir, "appsettings.Development.json"), """
    { "Bench": { "TimeoutMs": 10000 } }
    """);
Environment.SetEnvironmentVariable("EH_Bench__Name", "HIL-02");          // '__' = section separator

// Providers are layered: later sources override earlier ones
string environment = "Development";
IConfiguration config = new ConfigurationBuilder()
    .SetBasePath(dir)
    .AddJsonFile("appsettings.json", optional: false)
    .AddJsonFile($"appsettings.{environment}.json", optional: true)
    .AddEnvironmentVariables(prefix: "EH_")
    .AddCommandLine(args)                                                // e.g. --Bench:CanBitrate
…
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

// A generic host wires configuration + DI + logging together (the same host ASP.NET Core uses)
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new ConsoleListProvider());          // a tiny synchronous provider, shown below
builder.Logging.SetMinimumLevel(LogLevel.Debug);
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);        // category filter: hide framework noise
builder.Services.AddSingleton<FlashService>();
using IHost host = builder.Build();

var flasher = host.Services.GetRequiredService<FlashService>();
flasher.Flash("VCU", "vcu_2.4.1.hex", blocks: 3);
flasher.Flash("BMS", "", blocks: 0);

sealed partial class FlashService(ILogger<FlashService> logger)
{
    public void Flash(string ecu, string file, int blocks)
    {
        // Scopes attach context (ECU name) to every log line inside them
        using IDisposable? scope = logger.BeginScope("ECU {Ecu}", ecu);

        // Structured logging: a message *template* with named placeholders, NOT string interpolation
        logger.LogInformation("Flashing {File} with {Blocks} bl
…

Expected Result

Scoped sessions were disposed when their scopes ended, in reverse order of creation.

The Trace message is missing because the minimum level is Debug. The fields: lines are the structured properties a log store would index; {OriginalFormat} (the template) is also carried but not printed here.

singleton same across scopes: True
scoped same within a scope: True, same across scopes: False
transient same: False
report: VCU 2.4.1 read at 08:00
  session 2 disposed
  session 1 disposed
scopes disposed (DiagnosticSession.Dispose ran for each scope above)
root resolve of scoped service: Cannot resolve scoped service 'DiagnosticSession' from root provider.
Bench:Name        = HIL-02   (environment variable wins over JSON)
Bench:TimeoutMs   = 10000   (appsettings.Development.json wins over appsettings.json)
Bench:CanBitrate  = 250000   (command line wins over everything)
Bench:Channels:1  = CAN2
GetValue<int>     = 10 s
missing key       = '' (null), with default: 42
BenchService: HIL-02 at 250 kbit/s on CAN1+CAN2, timeout 10000 ms
validation: DataAnnotation validation failed for 'BenchOptions' members: 'Name' with the error: 'The Name field is required.'.
validation: DataAnnotation validation failed for 'BenchOptions' members: 'CanBitrate' with the error: 'The field CanBitrate must be between 10000 and 1000000.'.
Information FlashService[0] [ECU VCU] Flashing vcu_2.4.1.hex with 3 blocks
            fields: File=vcu_2.4.1.hex, Blocks=3
Debug       FlashService[0] [ECU VCU] Block 1/3 written
            fields: Block=1, Total=3
Debug       FlashService[0] [ECU VCU] Block 2/3 written
            fields: Block=2, Total=3
Debug       FlashService[0] [ECU VCU] Block 3/3 written
            fields: Block=3, Total=3
Information FlashService[2001] [ECU VCU] Flash complete: 3 blocks
            fields: Count=3
Information FlashService[0] [ECU BMS] Flashing  with 0 blocks
            fields: File=, Blocks=0
Error       FlashService[5001] [ECU BMS] Flashing BMS failed
            fields: Ecu=BMS
            exception: InvalidDataException: image contains no blocks

Common Mistakes

Injecting a scoped service (e.g. DbContext) into a singleton — a captive dependency.

Calling new on services that should be injected, or using the service locator (provider.GetService) everywhere.

Storing secrets in appsettings.json committed to Git.

Using : in environment variable names (use __).

String interpolation in log messages; logging passwords, tokens or personal data.

Forgetting ValidateOnStart, so invalid configuration is discovered at the first request instead of at start-up.

Best Practices

Constructor injection with interfaces; keep constructors cheap.

Choose lifetimes deliberately; enable ValidateScopes and ValidateOnBuild.

Strongly typed, validated options per feature section.

Structured logging with message templates, event IDs and scopes; [LoggerMessage] in hot paths.

Configure log levels per environment in configuration, not in code.

Interview Relevance

Very high: "singleton vs scoped vs transient?", "what is a captive dependency?", "how does configuration precedence work?", "IOptions vs IOptionsSnapshot vs IOptionsMonitor?", "what is structured logging?", "why not use string interpolation in logs?".

Practice Questions

Register two implementations of IEcuReader and inject IEnumerable<IEcuReader>.

Use IOptionsMonitor<BenchOptions> with reloadOnChange: true and print changes when the JSON file is edited.

Add a [LoggerMessage] method for "DTC {Code} detected on {Ecu}" with event ID 3001.

Build a worker service (BackgroundService) that logs a heartbeat every second until cancelled.

Quick Quiz

Q1. (Multiple choice) Which lifetime does ASP.NET Core use for DbContext by default? A) Singleton B) Scoped C) Transient D) Static Answer: B.

Q2. (Predict the output) Bench:Name is HIL-01 in appsettings.json and the environment variable EH_Bench__Name=HIL-02 is set (prefix EH_). What is config["Bench:Name"]?

Answer: HIL-02 — environment variables override JSON files (verified).

Q3. (True/False) logger.LogInformation($"Flashing {file}") produces the same structured field as logger.LogInformation("Flashing {File}", file).

Answer: False. The interpolated version logs only a finished string; the template version keeps File as a queryable field.

Q4. (Find the bug) provider.GetRequiredService<DiagnosticSession>() on the root provider, where DiagnosticSession is scoped.

Answer: With scope validation it throws Cannot resolve scoped service 'DiagnosticSession' from root provider. (verified); create a scope first.

Coding Challenge

Register an IGreeter implementation that takes IOptions<GreeterOptions> (with a Prefix read from in-memory configuration) and an ILogger<Greeter>; resolve it and print a greeting.

#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.Extensions.Options;

IConfiguration config = new ConfigurationBuilder()
    .AddInMemoryCollection(new Dictionary<string, string?> { ["Greeter:Prefix"] = "Hello from the bench" })
    .Build();

var services = new ServiceCollection();
services.AddLogging();                                    // ILogger<T> with no providers: logs go nowhere
services.Configure<GreeterOptions>(config.GetSection("Greeter"));
services.AddSingleton<IGreeter, Greeter>();
using var provider = services.BuildServiceProvider();
Console.WriteLine(provider.GetRequiredService<IGreeter>().Greet("VCU"));

public class GreeterOptions { public string Prefix { get; set; } = "Hi"; }
public interface IGreeter { string Greet(string name); }
public class Greeter(IOptions<GreeterOptions> options, ILogger<Greeter> logger) : IGreeter
{
    public string Greet(string name)
    {
        logger.LogDebug("Greeting {Name}", name);
        return $"{options.Value.Prefix}, {name}!";
    }
}