In the previous article we explored how the LoggerMessage attribute uses source generators to create high-performance logging code via static extension methods. That approach works great for shared log methods, but it requires you to pass the ILogger instance on every call. In this follow-up we'll look at two features that reduce boilerplate even further: non-static partial methods that access the logger from the primary constructor, and automatic exception recognition.

Recap: The Static Extension Method Approach

In the first article, every generated log method was a static partial extension method on ILogger. That means the caller must always pass the logger explicitly:

public static partial class Log
{
    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Processing order {OrderId} for customer {CustomerName}")]
    public static partial void LogProcessingOrder(this ILogger logger, int orderId, string customerName);
}

// Usage โ€” logger instance must be passed every time
_logger.LogProcessingOrder(orderId, customerName);

This is clean enough, but what if the logging methods live inside the class that already owns the logger? We can do better.

Non-Static Partial Methods with Primary Constructors

Starting with C# 12 and .NET 8, primary constructors are available on regular classes. When you combine a primary constructor that receives an ILogger with non-static [LoggerMessage] partial methods, the source generator can resolve the logger field directly โ€” no need to pass it as a parameter.

Here's the key insight: if the partial method is not static, the source generator looks for a field or property of type ILogger (or ILogger<T>) on the class. When you use a primary constructor, the captured parameter satisfies that requirement automatically.

using Microsoft.Extensions.Logging;

namespace OrderProcessing;

public partial class OrderService(ILogger<OrderService> logger)
{
    public async Task ProcessOrderAsync(int orderId, string customerName, decimal amount)
    {
        LogProcessingOrder(orderId, customerName, amount);

        try
        {
            await ProcessPaymentAsync(amount);
            LogOrderProcessed(orderId);
        }
        catch (PaymentException ex)
        {
            LogPaymentFailed(ex, orderId);
            throw;
        }
    }

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Processing order {OrderId} for customer {CustomerName} with amount {Amount}")]
    private partial void LogProcessingOrder(int orderId, string customerName, decimal amount);

    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed successfully")]
    private partial void LogOrderProcessed(int orderId);

    [LoggerMessage(
        EventId = 1003,
        Level = LogLevel.Error,
        Message = "Failed to process payment for order {OrderId}")]
    private partial void LogPaymentFailed(Exception ex, int orderId);

    private Task ProcessPaymentAsync(decimal amount) => Task.CompletedTask;
}

Notice what's different from the static approach:

  1. The class itself is partial โ€” the source generator adds the method bodies to this class.
  2. The log methods are non-static and private partial โ€” they belong to the instance.
  3. No ILogger parameter on the log methods โ€” the generator resolves logger from the primary constructor.
  4. Call sites are shorter โ€” just LogProcessingOrder(orderId, ...) instead of _logger.LogProcessingOrder(orderId, ...).

What the Source Generator Produces

The generator sees that OrderService has a primary constructor parameter of type ILogger<OrderService> and wires it up automatically. For each log method, it generates a readonly struct that implements IReadOnlyList<KeyValuePair<string, object?>> to hold the parameters, plus a Format function for message rendering. The generated method body (simplified) looks like this:

// <auto-generated/>
partial class OrderService
{
    private readonly struct __LogProcessingOrderStruct
        : IReadOnlyList<KeyValuePair<string, object?>>
    {
        private readonly int _orderId;
        private readonly string _customerName;
        private readonly decimal _amount;

        // Constructor, indexer, enumerator, Count...

        public override string ToString()
        {
            var OrderId = this._orderId;
            var CustomerName = this._customerName;
            var Amount = this._amount;
            return $"Processing order {OrderId} for customer {CustomerName} with amount {Amount}";
        }

        public static readonly Func<__LogProcessingOrderStruct, Exception?, string> Format =
            (state, ex) => state.ToString();
    }

    private partial void LogProcessingOrder(int orderId, string customerName, decimal amount)
    {
        if (logger.IsEnabled(LogLevel.Information))
        {
            logger.Log(
                LogLevel.Information,
                new EventId(1001, nameof(LogProcessingOrder)),
                new __LogProcessingOrderStruct(orderId, customerName, amount),
                null,
                __LogProcessingOrderStruct.Format);
        }
    }
}

Two things to note:

  1. The generator references logger directly โ€” the same parameter name from the primary constructor. No backing field, no manual wiring, no ceremony.
  2. The struct's ToString() produces the formatted message, while the indexer exposes each parameter as a KeyValuePair for structured logging sinks. The {OriginalFormat} entry preserves the raw template for log aggregation tools.

Traditional Constructor Works Too

You don't need a primary constructor for this to work. A regular constructor with a stored field is equally valid โ€” the generator looks for an ILogger field or property on the class:

public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger)
    {
        _logger = logger;
    }

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Processing order {OrderId}")]
    private partial void LogProcessingOrder(int orderId);
}

The primary constructor approach simply removes that boilerplate.

Automatic Exception Recognition

In the first article we showed that Exception parameters work with static extension methods. But the behaviour deserves a closer look because it's surprisingly smart.

When the source generator encounters a parameter of type Exception (or any type derived from it) in a [LoggerMessage] method, it automatically treats it as the exception to attach to the log entry. You don't need to reference it in the message template, and it won't appear as a structured property in the message โ€” it goes straight to the Exception slot of the log entry.

[LoggerMessage(
    EventId = 2001,
    Level = LogLevel.Error,
    Message = "Payment failed for order {OrderId}")]
private partial void LogPaymentFailed(Exception ex, int orderId);

Notice that {ex} does not appear in the message template. Despite that, the generated code correctly forwards the exception:

// Generated (simplified)
private partial void LogPaymentFailed(Exception ex, int orderId)
{
    if (logger.IsEnabled(LogLevel.Error))
    {
        logger.Log(
            LogLevel.Error,
            new EventId(2001, nameof(LogPaymentFailed)),
            new __LogPaymentFailedStruct(orderId),
            ex,                                    // โ† exception goes here
            __LogPaymentFailedStruct.Format);
    }
}

The exception is passed as the dedicated Exception? parameter of logger.Log() โ€” separate from the state struct that holds the message parameters. This means:

  • Full stack trace is preserved and available to every logging provider.
  • Structured logging sinks (Seq, Application Insights, Elasticsearch) index the exception separately from the message properties.
  • The message template stays clean โ€” it describes what happened, not the exception itself.
  • No risk of accidentally formatting the exception into the message, which would bloat log output.

What Happens If You Do Reference the Exception in the Template?

If you include the exception parameter name in the message template, the compiler will produce a warning:

// โš ๏ธ SYSLIB1013: Don't include a template for ex in the logging message since it is implicitly taken care of
[LoggerMessage(
    EventId = 2002,
    Level = LogLevel.Error,
    Message = "Payment failed for order {OrderId}, exception: {ex}")]
private partial void LogPaymentFailed(Exception ex, int orderId);

The generator reserves Exception-typed parameters exclusively for the exception slot. If you genuinely want exception details in the message text, use ex.Message as a separate string parameter โ€” but in practice, let the logging infrastructure handle exception rendering.

The Exception Parameter Can Appear Anywhere

The position of the Exception parameter in the method signature doesn't matter. The generator identifies it by type, not by position:

// All of these are equivalent โ€” the generator finds the Exception by type

[LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "Failed for {OrderId}")]
private partial void LogFailed1(Exception ex, int orderId);

[LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Failed for {OrderId}")]
private partial void LogFailed2(int orderId, Exception ex);

Parameters Not in the Template Are Still Captured

An interesting detail shows up when a method parameter is not referenced in the message template. Consider LogReservationFailed below โ€” its template is "Stock reservation failed for {Sku}", but the method also accepts a quantity parameter. The generator still includes quantity in the state struct as structured data:

// Inside the generated __LogReservationFailedStruct
public KeyValuePair<string, object?> this[int index]
{
    get => index switch
    {
        0 => new KeyValuePair<string, object?>("Sku", this._sku),       // PascalCase โ€” from template
        1 => new KeyValuePair<string, object?>("quantity", this._quantity), // lowercase โ€” from parameter name
        2 => new KeyValuePair<string, object?>("{OriginalFormat}", "Stock reservation failed for {Sku}"),
        _ => throw new IndexOutOfRangeException(nameof(index)),
    };
}

Template-referenced parameters get the casing from the placeholder (Sku), while extra parameters keep their original parameter name (quantity). This means structured logging sinks like Seq or Application Insights will still index the extra parameters โ€” useful for filtering and correlation even when they don't appear in the human-readable message.

Note: The compiler will emit warning SYSLIB1015 ("Argument 'quantity' is not referenced from the logging message") when a non-Exception parameter isn't used in the message template. This is intentional โ€” the analyzer wants you to be explicit about whether the omission is deliberate. If you want the parameter only as structured data without it appearing in the message text, you can suppress the warning with #pragma warning disable SYSLIB1015 or add the parameter to the template.

Putting It All Together

Here's a complete example combining both features โ€” instance methods on a class with a primary constructor, and automatic exception handling:

using Microsoft.Extensions.Logging;

namespace Inventory;

public partial class InventoryService(
    ILogger<InventoryService> logger,
    IInventoryRepository repository)
{
    public async Task<bool> ReserveStockAsync(string sku, int quantity)
    {
        LogReservingStock(sku, quantity);

        try
        {
            var success = await repository.TryReserveAsync(sku, quantity);

            if (success)
                LogStockReserved(sku, quantity);
            else
                LogInsufficientStock(sku, quantity);

            return success;
        }
        catch (Exception ex)
        {
            LogReservationFailed(ex, sku, quantity);
            throw;
        }
    }

    [LoggerMessage(EventId = 3001, Level = LogLevel.Information,
        Message = "Reserving {Quantity} units of {Sku}")]
    private partial void LogReservingStock(string sku, int quantity);

    [LoggerMessage(EventId = 3002, Level = LogLevel.Information,
        Message = "Reserved {Quantity} units of {Sku}")]
    private partial void LogStockReserved(string sku, int quantity);

    [LoggerMessage(EventId = 3003, Level = LogLevel.Warning,
        Message = "Insufficient stock for {Sku}, requested {Quantity}")]
    private partial void LogInsufficientStock(string sku, int quantity);

    [LoggerMessage(EventId = 3004, Level = LogLevel.Error,
        Message = "Stock reservation failed for {Sku}")]
    private partial void LogReservationFailed(Exception ex, string sku, int quantity);
}

Compared to the static extension method approach from the first article:

  • No ILogger parameter on any log method โ€” the primary constructor parameter logger is used automatically.
  • Log calls read like plain method calls โ€” LogReservingStock(sku, quantity) instead of _logger.LogReservingStock(sku, quantity).
  • Exception handling is invisible โ€” LogReservationFailed(ex, sku, quantity) attaches the full exception without any template placeholder.
  • Extra parameters are still captured โ€” quantity isn't in the template of LogReservationFailed, but it still ends up as structured data in the log entry.
  • All the performance benefits are preserved โ€” the generated code uses log-level guards and zero-allocation state structs.

When to Use Which Approach

Scenario Approach
Shared log methods used across multiple classes Static extension methods on ILogger
Log methods private to a single service class Non-static partial methods with primary constructor
Library code where you don't control the ILogger lifetime Static extension methods
Application services registered in DI Non-static partial methods

Both approaches produce equally optimized generated code. The choice comes down to where the log methods live and how many classes use them.

Conclusion

The LoggerMessage attribute keeps getting more ergonomic without sacrificing performance. Non-static partial methods combined with primary constructors let you drop the logger parameter from every call, and automatic exception recognition keeps your message templates clean while ensuring full stack traces reach your logging infrastructure.

Key takeaways:

  • Non-static partial methods resolve the ILogger from the class itself โ€” primary constructor parameters work out of the box.
  • Exception parameters are detected by type, not by template placeholders โ€” the generator routes them to the dedicated exception slot automatically.
  • Message templates stay focused on what happened, while exceptions are handled structurally by the logging pipeline.
  • All performance benefits are identical to the static extension method approach โ€” the generated code is equally optimized.