A log line isn't a sentence for a human — it's a record for a search engine.
Something breaks in production at 3am. You open the logs, and you're staring at ten thousand lines that look like "Order 4471 failed for customer sam@example.com". Now try to answer: "how many orders failed for this specific customer in the last hour, across every log line, regardless of the exact wording?" With plain interpolated strings, you can't — the customer's email and order ID are baked irretrievably into free-form text. Your only tool is a fuzzy text search.
Structured logging exists to fix exactly this. Instead of burning your data into a sentence, you log a message template with named placeholders, and pass the actual values as separate parameters. The logging system keeps the values as real, queryable data alongside the human-readable message — so you can search, filter, and aggregate by OrderId or CustomerEmail as if they were columns in a database, because in a real logging backend, they effectively are.
In this lesson, you'll learn how ILogger<T> works, what structured logging with message templates actually means (and why it's not string interpolation), log levels, and why this distinction matters enormously once your app is running in production.
ILogger<T> is the standard .NET interface for writing log messages, automatically supplied through DI to any class that asks for it. The <T> tells the logging system which class is doing the logging, so every message is automatically tagged with its source (called a "category").
.NET's logging system, part of Microsoft.Extensions.Logging, is built into the generic host and automatically available via DI. It's built around three ideas: categories (which component logged this — usually the class's full name via ILogger<T>), log levels (how important/severe is this message), and structured message templates (named placeholders that keep logged values as real, extractable data, not just embedded text).
logger.LogInformation($"Order {orderId} placed")orderId as structured datalogger.LogInformation("Order {OrderId} placed", orderId)OrderId directlyA single developer running an app locally can eyeball a handful of Console.WriteLine calls just fine. A production service handling thousands of requests per minute across dozens of machines generates millions of log lines per day. At that scale, "reading the logs" isn't reading at all — it's querying, filtering, and aggregating, in a log aggregation platform (Seq, Application Insights, Elasticsearch, Datadog, and similar tools). Those platforms work by indexing structured values — if your values are welded into a sentence via string interpolation, there's nothing for them to index.
logger.LogInformation("Order {OrderId} placed by {CustomerEmail}", orderId, email) keeps OrderId and CustomerEmail as distinctly named, separately captured values. The logging system renders a human-readable line for the console, and hands structured log backends a payload like { "OrderId": 4471, "CustomerEmail": "sam@example.com" } that they can index and query directly — "show me every log entry where OrderId = 4471," instantly, across the entire fleet.
logger.LogInformation("Order {OrderId} placed by {CustomerEmail}", 4471, "sam@example.com");info: OrderService[0]
Order 4471 placed by sam@example.com{ "Message": "Order 4471 placed by sam@example.com",
"OrderId": 4471, "CustomerEmail": "sam@example.com",
"Category": "OrderService", "Level": "Information" }One call to LogInformation produces both — a readable line and a queryable record — because the template and the arguments are kept separate all the way through the pipeline, not merged into a string before logging happens.
public class OrderService(ILogger<OrderService> logger)
AddLogging() yourself for the default console logger.<OrderService> sets the log category to "OrderService" — that's what shows up next to each message, letting you filter by source component.logger.LogWarning("Payment {PaymentId} failed after {Attempts} attempts", paymentId, attempts);
LogWarning) sets the level; {PaymentId} and {Attempts} are named placeholders matched, in order, to the arguments that follow.public class OrderService(ILogger<OrderService> logger)
{
public void PlaceOrder(int orderId, string customerEmail)
{
logger.LogInformation("Placing order {OrderId} for {CustomerEmail}", orderId, customerEmail);
try
{
// ... processing ...
logger.LogInformation("Order {OrderId} placed successfully", orderId);
}
catch (Exception ex)
{
// Pass the exception as the first argument — it's captured with a full stack trace,
// separately from the message template.
logger.LogError(ex, "Order {OrderId} failed to process", orderId);
}
}
}
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddTransient<OrderService>();
using var host = builder.Build();
host.Services.GetRequiredService<OrderService>().PlaceOrder(4471, "sam@example.com");
Console output looks like:
info: OrderService[0]
Placing order 4471 for sam@example.com
info: OrderService[0]
Order 4471 placed successfully
Note the order matters: placeholder names in the template must line up positionally with the arguments passed after it — {OrderId} matches the first argument, {CustomerEmail} the second.
A payment worker logs at different levels depending on outcome, and uses BeginScope to attach a correlation ID to every log line produced while processing one specific payment — so when something goes wrong, every related log entry can be found together, even across multiple log calls:
public class PaymentWorker(IPaymentGateway gateway, ILogger<PaymentWorker> logger)
{
public async Task ProcessAsync(Payment payment)
{
using (logger.BeginScope("PaymentId:{PaymentId}", payment.Id))
{
logger.LogDebug("Starting payment processing");
var result = await gateway.ChargeAsync(payment);
if (result.Succeeded)
{
logger.LogInformation("Payment succeeded for {Amount:C}", payment.Amount);
}
else if (result.IsRetryable)
{
logger.LogWarning("Payment failed but is retryable: {Reason}", result.Reason);
}
else
{
logger.LogError("Payment permanently failed: {Reason}", result.Reason);
}
}
}
}
Every log line inside the using (logger.BeginScope(...)) block automatically carries PaymentId, even the ones that don't explicitly mention it in their own template — so a log query for a single payment ID returns the complete story: start, gateway call outcome, and result, all correlated.
String-interpolated logging is like scribbling a note on a napkin: "Order 4471 for sam@example.com failed." Readable in isolation, but to find every napkin mentioning order 4471, you have to read every single napkin, word by word.
Structured logging is like filling in a spreadsheet row with columns: OrderId | CustomerEmail | Status. Now finding every row where OrderId = 4471 is a filter, not a search — instant, exact, and it works no matter how the surrounding wording changes.
LogInformation(string message, params object?[] args) — the template and its values travel through the logging pipeline as separate pieces, not pre-combined.IsEnabled(logLevel) for the category before doing any formatting work — a LogDebug call that's filtered out at the configured minimum level costs almost nothing, because the message is never rendered at all.This is the single most common mistake in this lesson: writing logger.LogInformation($"Order {orderId} placed") instead of logger.LogInformation("Order {OrderId} placed", orderId). They can render identical console output, which is exactly why the mistake is so easy to miss — but the first one has already collapsed orderId into an opaque string before logging even sees it, so no structured backend can ever recover it as a distinct, queryable value. The second keeps OrderId as a real, named, separately captured piece of data all the way through the pipeline.
Setting the minimum log level to Warning in production doesn't just make LogDebug calls invisible — the framework typically skips the expensive work of even formatting those messages, because the "is this level enabled" check happens first.
logger.LogInformation($"User {userId} logged in") — destroys structure, as explained above.
logger.LogInformation("User {UserId} logged in", userId) — keeps UserId queryable.
logger.LogInformation("User logged in with password {Password}", password) — passwords, credit card numbers, and other secrets end up permanently stored in log systems, often with weaker access controls than your primary database.
Never log secrets or full sensitive payloads. Log identifiers (user ID, masked card number) instead.
Logging routine, expected events (like every successful request) at LogError, or logging genuinely critical failures at LogInformation where they'll be filtered out or ignored in production.
Match severity to reality: Trace/Debug for detailed diagnostic noise, Information for normal operational events, Warning for recoverable/unexpected-but-not-broken situations, Error for failures that need attention, Critical for failures threatening the whole application.
ILogger<T> instead of Console.WriteLine in any application beyond a throwaway script — it's filterable by level, routable to multiple destinations, and structured.Console.WriteLine can still be fine for a quick, disposable debugging print you'll delete in five minutes — but production code should always go through ILogger<T>.logger.LogInformation("Order {OrderId}", id) — never $"Order {id}".ILogger<T> is injected automatically — no manual registration required.ILogger<T> is the standard, DI-injected way to log in .NET; <T> sets the category so you can filter by source component."Order {OrderId} placed", orderId) — the template and values travel separately through the pipeline, so real logging backends can index each value.$"Order {orderId}") collapses the value into opaque text before logging ever sees it — it can look identical on the console but is useless to structured log queries.BeginScope attaches shared structured context (like a correlation ID) to every log line within its block.You've seen why structured logging beats string interpolation. Let's confirm you can spot the difference in practice.
1. What's the key difference between logger.LogInformation($"Order {orderId} placed") and logger.LogInformation("Order {OrderId} placed", orderId)?
Correct: B
Why B is correct: String interpolation resolves $"..." into a plain string immediately, before LogInformation is even called — the logging system never sees orderId as a distinct value. The template overload passes the template and value separately, so structured backends can capture and index OrderId.
Why A is incorrect: They can render identical console text, which is exactly why this mistake is easy to make — but the underlying data captured is completely different.
Why C is incorrect: Interpolation isn't meaningfully faster, and correctness/queryability matters far more than a negligible difference here.
Why D is incorrect: The template-based approach works with every provider — console, structured backends, everything — it's actually the interpolated version that loses information regardless of provider.
Reinforcement: Always pass values as separate arguments matched to named placeholders, never pre-formatted into the string.
2. Your production log level is set to Warning. What happens to logger.LogDebug("Cache lookup for {Key}", key) calls?
Correct: B
Why B is correct: The logging system checks whether the category/level combination is enabled before doing any real formatting work — a disabled-level call is cheap and produces no log output anywhere.
Why A is incorrect: Disabled log levels are silently skipped, not exceptions — logging should never crash your app.
Why C is incorrect: The level you call is the level recorded (when enabled); there's no automatic promotion to a different level.
Why D is incorrect: When a level is filtered out, it's filtered out everywhere — no provider receives it.
Reinforcement: Level filtering happens early, which is why liberal use of Debug/Trace logging in code is cheap in production once the minimum level excludes it.
3. Why would a team specifically choose a structured logging backend (like Seq or an OpenTelemetry-based system) over just writing log lines to a text file?
Correct: B
Why B is correct: This is exactly the motivating problem from the hook — at production scale, you need to query logs precisely by specific field values, not eyeball or grep through raw text. Structured backends make this possible because the logging system hands them named values, not just rendered sentences.
Why A is incorrect: There's no such length restriction on text files; this isn't the real distinction.
Why C is incorrect: Write speed isn't the deciding factor — queryability of the resulting data is.
Why D is incorrect: Logging systems record what happened; they don't fix code.
Reinforcement: Structured logging's whole value proposition is enabling precise queries at scale — this only works if you use message templates correctly in your code.
4. A developer logs logger.LogInformation("Every request handled") for every single successful HTTP request in a high-traffic production API. What's the most likely problem?
Correct: B
Why B is correct: Logging every single routine, expected event at Information level in a high-traffic system generates enormous log volume, making genuinely important Information-level events harder to find and increasing storage/ingestion costs. This kind of per-request detail often belongs at Debug/Trace level, or should be sampled/aggregated instead.
Why A is incorrect: There's no such rate limit built into the logging framework.
Why C is incorrect: This is a design/operational concern, not a syntax error.
Why D is incorrect: The "correct" level depends entirely on context and volume — matching severity to actual significance is the whole point of having levels.
Reinforcement: Choosing log levels is a judgment call about signal-to-noise ratio at your app's actual traffic scale, not a fixed rule per event type.
You now understand structured logging with ILogger<T> — and why message templates, not string interpolation, are the professional standard.
dotnetmadeeasy.com — Learn C# and .NET, the right way.