LoggerMessage Part 2: Instance Methods and Automatic Exception Handling
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:
- The class itself is
partialโ the source generator adds the method bodies to this class. - The log methods are non-static and
private partialโ they belong to the instance. - No
ILoggerparameter on the log methods โ the generator resolvesloggerfrom the primary constructor. - 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:
- The generator references
loggerdirectly โ the same parameter name from the primary constructor. No backing field, no manual wiring, no ceremony. - The struct's
ToString()produces the formatted message, while the indexer exposes each parameter as aKeyValuePairfor 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-
Exceptionparameter 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 SYSLIB1015or 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
ILoggerparameter on any log method โ the primary constructor parameterloggeris 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 โ
quantityisn't in the template ofLogReservationFailed, 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
ILoggerfrom 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.