Building a robust ASP.NET Core application is only half the battle. Once your code is deployed to a production environment, it becomes a black box. When an integration fails, a user gets locked out, or a database query hangs, how do you find the root cause?
The answer is observability—and the foundation of observability is structured logging.
In this guide, we will explore how to transition an enterprise-level ASP.NET Core application from silent (or poorly logged) operations to a fully observable, highly performant system. We will walk through real-world examples at the controller, service, and repository layers; fix dangerous performance anti-patterns along the way; and finally wire up Serilog to capture it all.
Part 1: The Trap of String Interpolation
Before diving into the architecture, it is vital to understand the difference between basic logging and structured logging.
Developers often write logs like this:
C#
// The Anti-Pattern
_logger.LogInformation($"User {userId} successfully authenticated at {DateTime.Now}");
While this looks helpful to a human reading a console window, it is a nightmare for log aggregation tools (like Elasticsearch, Seq, or Datadog). The resulting log is just a flat string. You cannot easily query your database for "all logs where the UserId was 123."
The Structured Approach:
// The Best Practice
_logger.LogInformation("User {UserId} successfully authenticated.", userId);
By using message templates ({UserId}), ASP.NET Core preserves the property names and their values. Your logging provider stores UserId as an indexed, queryable column. This is the cornerstone of modern observability.
Part 2: Layer-by-Layer Implementation
To make an application truly observable, logging must be treated as a first-class citizen across all layers of the architecture.
1. Controllers: Auditing the Entry Points
Controllers are the front door to your application. Logging here should focus on who is making the request, what they are trying to do, and the outcome of the HTTP lifecycle.
In an AccountController managing user logins and 2FA, logs act as a security audit trail. If an account is breached, security teams will look here first.
Log Failed Attempts: Catch invalid credentials or locked-out states.
Track Administrative Actions: If an Admin locks a user's account, capture the Admin's ID, not just the target user's ID.
var currentAdminId = User.FindFirstValue(ClaimTypes.NameIdentifier);
_logger.LogInformation("Admin {AdminId} locked User ID {TargetUserId}.", currentAdminId, userId);
2. Services: Securing Logic and Tracking External Integrations
The Service layer contains the heart of your application's logic. This is where transactions happen, API keys are generated, and external systems are integrated.
Securing Cryptography Logs: When dealing with token generation (like our ApiKeyService), never log the secret keys or tokens. Logging a hashed key or a plain-text secret creates a massive vulnerability. Instead, log the action and the parameters used to generate them.
_logger.LogInformation("Generating JWT token for ClientId: {ClientId} with an expiry of {ExpiryHours} hours.", clientId, expiryHours);
Tracing External APIs (Anti-DOS and Resiliency): When integrating with a third-party gateway (like one), external APIs are prone to timeouts and rate limits. Log your exact intent, the response payload, and explicitly catch it HttpRequestException so you don't mistake a firewall block for a bug in your own code.
Uncovering Architectural Bugs: Implementing logging forces a code review, often revealing hidden anti-patterns. For example, during a logging audit of an InvoiceService, you might spot a dangerous "sync-over-async" pattern:
C#
// Dangerous: Blocking the thread pool inside a LINQ Select
ClientName = _clientRepository.GetByIdAsync(invoice.ClientOnboardingId).Result.BusinessName
By refactoring this to pre-fetch the clients asynchronously before the LINQ projection, you not only make the code safe to log but also prevent catastrophic thread-pool starvation under heavy load.
3. Repositories: The Database Frontier
The Repository layer interacts directly with your database via Entity Framework Core. Logging here is essential for diagnosing slow queries or missing data.
Fixing Synchronous Bottlenecks: Just as in the service layer, logging audits often reveal blocking code. Upgrading .FirstOrDefault() to await ... .FirstOrDefaultAsync() ensures your database calls don't block application threads.
Avoiding Silent Failures: Empty catch blocks are the enemy of debugging. If a database query fails, returning an empty list might keep the UI from crashing, but it hides the underlying issue. Always capture the Exception object in your log.
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected error occurred while retrieving internal invoices.");
return Array.Empty<InternalInvoice>();
}
Part 3: The Engine – Integrating Serilog
Because we used the standard Microsoft ILogger<T> abstraction everywhere in our controllers, services, and repositories, we don't need to change a single line of our business logic to upgrade our logging engine. We simply wire up Serilog at the application's entry point using the Two-Stage Initialization pattern.
Step 1: Install Packages
Bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
Step 2: Configure appsettings.json
Define your log levels and sinks in configuration so you can tweak them without recompiling. Note the use of the JsonFormatter for the file sink—this ensures all your structured {ClientId} properties are saved as actual JSON fields.
JSON
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "Logs/SystemLog-.json",
"rollingInterval": "Day",
"formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog"
}
}
],
"Enrich": [ "FromLogContext", "WithMachineName" ]
}
}
Step 3: Two-Stage Initialization in Program.cs
This setup catches errors that happen before the app fully builds and replaces ASP.NET Core's noisy default HTTP logging with clean, single-line structured requests.
using Serilog;
// 1. Create a Bootstrap Logger to catch startup errors
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting web host...");
var builder = WebApplication.CreateBuilder(args);
// 2. Configure Serilog to read from appsettings.json
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
// Add Services...
builder.Services.AddControllersWithViews();
var app = builder.Build();
// 3. Clean, single-line HTTP Request Logging
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Conclusion
Transitioning to structured logging transforms an application from a fragile, silent entity into a communicative, observable system. By strategically injecting ILogger<T> across your architecture and backing it with Serilog, you achieve:
Security Auditing: Tracking exact user actions and authentication flows without exposing secrets.
Faster Debugging: Queryable JSON properties (
{ClientId},{ScenarioId}) that allow you to pinpoint failures instantly.System Resiliency: Clear visibility into external API blocks, database bottlenecks, and thread-blocking anti-patterns.
Logging is not an afterthought to be added when something breaks; it is a fundamental architectural feature. A well-logged application is a maintainable application.

Join the conversation! Your thoughts help the community grow.