← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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").

The Technical Definition

.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).

String Interpolation

Structured Message Template

Why Does It Exist?

The Problem — Free-Text Logs Don't Scale

A 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.

The Solution — Templates With Named Placeholders

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.

Big Picture

ONE LOG CALL, TWO OUTPUTS
Your code
logger.LogInformation("Order {OrderId} placed by {CustomerEmail}", 4471, "sam@example.com");
Rendered for a human (console)
info: OrderService[0]
      Order 4471 placed by sam@example.com
Captured for a machine (structured backend)
{ "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.

How It Works

USING ILogger<T>
1. INJECT ILogger<T> — NO REGISTRATION NEEDED
public class OrderService(ILogger<OrderService> logger)
2. LOG WITH A LEVEL AND A TEMPLATE
logger.LogWarning("Payment {PaymentId} failed after {Attempts} attempts", paymentId, attempts);
3. THE FRAMEWORK ROUTES IT TO REGISTERED PROVIDERS

Simple Example

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.

Real-World Example

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.

Analogy

A Handwritten Note vs. a Spreadsheet Row

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.

Under the Hood

HOW THE FRAMEWORK KEEPS TEMPLATE AND VALUES SEPARATE
1. THE OVERLOAD TAKES A TEMPLATE STRING + PARAMS ARRAY
2. IT'S WRAPPED IN A LogEntry AND HANDED TO EACH PROVIDER
3. LEVEL FILTERING HAPPENS BEFORE ANY OF THIS WORK

Common Confusion

"It looks like string.Format — so it must work the same way" — it doesn't

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.

Log levels are a filter, not a suggestion

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.

Common Mistakes

Mistake 1 — Using string interpolation in the message template

logger.LogInformation($"User {userId} logged in") — destroys structure, as explained above.

logger.LogInformation("User {UserId} logged in", userId) — keeps UserId queryable.

Mistake 2 — Logging sensitive data in plain templates

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.

Mistake 3 — Wrong log level for the situation

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.

When Should I Use It?

Mental Model

Message template = the sentence shape, with named holes
Arguments = the actual values that fill those holes, kept as real data
Log level = how loudly this event should be announced

Remember:
· logger.LogInformation("Order {OrderId}", id) — never $"Order {id}".
· ILogger<T> is injected automatically — no manual registration required.
· Never log secrets; always match the level to actual severity.

Key Takeaway


Check Your Understanding

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)?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.