Updated on
ASP.NET Core ships logging in the box. We inject ILogger<T> into a class, call it, and the messages go wherever the registered providers send them: the console by default, and nowhere durable.
NLog is one of those providers, and it is the one that writes files. Adding it takes one package and one line in Program.cs, after which the same ILogger<T> calls land in a file we configure. This part wires that up for the Web API we started in part 2.
If you want to see all the basic instructions and complete navigation for this series, please follow the following link: Introduction page for this tutorial.
For the previous part check out: Service Configuration in ASP.NET Core Web API With Extension Methods
How Does Logging Work in ASP.NET Core?
Logging in ASP.NET Core is one API in front of many destinations. We inject ILogger<T> where we need it, call a method with a level, and every registered provider decides what to do with the message.
The type parameter is not decoration. It sets the message’s category, which is how output is filtered per class from configuration without touching code.
Providers are the destinations. The console provider is registered by default; a file, a database, or a hosted service each arrive as another provider, and adding one changes nothing at the call sites.
Levels rank severity from trace to critical, and configuration decides which of them survive in which environment. Development usually keeps debug and above, production usually starts at information or warning.
Messages are templates rather than sentences. Writing the placeholders as names and passing the values separately lets a destination store them as fields, which is what makes logs searchable later.
ILogger method | LogLevel | NLog level | Use it for |
|---|---|---|---|
LogTrace | Trace | Trace | The most detailed diagnostics, off outside development |
LogDebug | Debug | Debug | Values and flow while developing |
LogInformation | Information | Info | Normal events worth a record: a request served, a job finished |
LogWarning | Warning | Warn | Something unexpected that did not stop the operation |
LogError | Error | Error | An operation failed and the caller was affected |
LogCritical | Critical | Fatal | The application cannot continue |
Three interfaces carry the whole model, and our article on ILogger, ILoggerFactory and ILoggerProvider takes each of them apart. For this part, the one that matters is ILogger<T>, because it is the only one we inject.
The template form is worth adopting from the first call rather than retrofitting later. Writing message templates rather than interpolated strings is what lets a destination keep the values as fields instead of flattening them into a sentence, and no provider can recover a value that was interpolated away before it arrived.
How Do We Add NLog to an ASP.NET Core Web API?
Two steps. Install the NLog package for ASP.NET Core, then tell the host to use it.
The package to install is the ASP.NET Core one rather than the base library. It brings NLog itself along and adds the layout renderers that know about requests, which is most of the reason to use NLog in a web application at all.
The wiring is one line in Program.cs. From that point ILogger<T> messages reach NLog alongside the providers already registered, and we choose whether to clear those first.
NLog reads its configuration from nlog.config in the project folder, and that file is where every decision about destinations, file names, and line format lives. None of it appears in C#.
Nothing at the call sites changes. Code that logged before this step logs the same way after it, and now the messages land in a file.
The package is NLog.Web.AspNetCore, and one command installs it into the project that needs it:
dotnet add package NLog.Web.AspNetCore
It pulls in NLog and NLog.Extensions.Logging as transitive dependencies, so this single reference is the whole installation. In the sample it sits on the LoggerService project.
The wiring is two lines in Program.cs, and only the second of them is about NLog:
using NLog.Web; var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Host.UseNLog();
There is nothing to load by hand. NLog finds nlog.config in the application’s output directory on its own, so no call has to name the file or build a path to it.
ClearProviders() is a decision about duplication, not about whether NLog works. A default host registers four providers before we add anything, so without that line every message goes to the file and to the console and to the debug and event sinks as well. With it, NLog is the only destination left.
How Do We Configure nlog.config?
nlog.config is an XML file with two parts that matter: targets and rules.
A target is a destination. The file target here writes to disk, and its fileName decides both where the file lives and how it rolls: putting ${shortdate} in the name gives one file per day with no other configuration.
A rule connects loggers to targets. The sample’s single rule sends everything at debug level and above to the file target, matching every category with *.
The layout attribute decides what one line looks like. It is a string of renderers, and it is where the timestamp, the level, the message, and the exception are placed in the order we want them.
Relative paths are resolved from the application’s base directory, so the file lands beside the built application rather than at an absolute location we have to invent.
| Renderer | Produces |
|---|---|
${longdate} | Full date and time |
${shortdate} | Date only, which is what makes one file per day |
${level} | The level name, with uppercase=true to match the sample |
${message} | The rendered message |
${exception} | The exception, with format=ToString for the stack trace |
${logger} | The category, which is the class name when using ILogger<T> |
${aspnet-request-url} | The request URL, which needs NLog.Web.AspNetCore |
${aspnet-mvc-action} | The action name, which needs NLog.Web.AspNetCore |
The last two rows need one extra line in the file. Layout renderers that read the current request ship in NLog.Web.AspNetCore rather than in NLog itself, and NLog registers them only when the assembly is declared: <extensions><add assembly="NLog.Web.AspNetCore"/></extensions>. Copy an ${aspnet-request-url} without it and the configuration fails to parse rather than rendering an empty value.
The sample’s file is deliberately small, and every attribute in it is one of the four ideas above:
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true">
<targets>
<target name="logfile" xsi:type="File"
fileName="logs/${shortdate}_logfile.txt"
layout="${longdate} ${level:uppercase=true} ${message}"/>
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="logfile" />
</rules>
</nlog>
The fileName is relative, which is the whole reason this sample runs on any machine. An absolute path pinned to one developer’s drive is the classic way this file goes wrong, and the failure is silent: NLog discards the message rather than raising anything, so the application runs perfectly and the file never appears.
autoReload="true" means NLog watches the file and picks up an edit while the application is running, which is what makes changing a level or a layout a one-file operation instead of a restart.
A file is not the only target worth knowing. The same configuration shape sends the same messages to a database if we point a target at one, which our article on sending the same messages to SQL Server instead covers end to end. And if structured output is the priority rather than a file on disk, Serilog behind the same ILogger API is the provider most teams reach for.
How Do We Write Log Messages From a Controller?
We ask for an ILogger<T> in the constructor and call it. There is nothing to register: the container already knows how to build a logger for any type, so the controller states what it needs and receives it.
The type argument is the class doing the logging, which is what gives every message from this controller its own category:
[ApiController]
[Route("[controller]")]
public class WeatherForecastWithILoggerController : ControllerBase
{
private readonly ILogger<WeatherForecastWithILoggerController> _logger;
public WeatherForecastWithILoggerController(ILogger<WeatherForecastWithILoggerController> logger)
{
_logger = logger;
}
[HttpGet]
public IEnumerable<string> Get()
{
_logger.LogInformation("Serving {Count} forecast values", 2);
return ["value1", "value2"];
}
}
{Count} is a placeholder rather than a variable, and the value travels beside the template rather than inside it. A file target renders it into the sentence; a structured target keeps it as a field we can query on.
Starting the application and calling /weatherforecast produces the line in logs/, beside the built application rather than beside the project file. That folder is created on the first write, so it does not exist until a message is actually logged.
The sample’s controller injects ILoggerManager instead, which is the wrapper the next section builds and argues for. Both reach the same file, because NLog is registered as a provider for one and used directly by the other.
Should We Write a Custom Logger Wrapper?
Usually not, and the reason is worth stating before the code.
ILogger<T> is already an abstraction. Wrapping it in our own interface adds a second one, and the second one is narrower than the first: a wrapper that takes a string per level cannot carry message templates, scopes, event IDs, or an exception argument.
The category suffers too. ILogger<T> names each message after the class that logged it, which is what per-class filtering in configuration matches on. A single shared wrapper class names every message after itself.
There is one honest argument for it. A project that expects to change its logging approach across many files gains one seam to change instead of many, and that is the argument this series is making.
We build it here for that reason, and we keep it deliberately thin, so nothing below depends on it that could not use ILogger<T> directly.
The wrapper is deliberately thin enough that the library behind it is a detail: the current editions of our Ultimate ASP.NET Core Web API course keep this same ILoggerManager and put Serilog behind it instead of NLog, and nothing above the interface changes when they do.
The usual second argument, testability, does not survive contact with the platform: testing code that logs, without a wrapper is a solved problem, because FakeLogger ships for exactly that purpose.
The wrapper lives in two class libraries. Contracts holds the interface and LoggerService holds the implementation, so the web project references LoggerService, which references Contracts. Splitting them this way is what lets the later parts of the series depend on the contract without depending on the logging library.
The interface is four methods, one per level the series uses:
namespace Contracts
{
public interface ILoggerManager
{
void LogInfo(string message);
void LogWarn(string message);
void LogDebug(string message);
void LogError(string message);
}
}
Four is the deliberate cost of this design, and it is worth naming: the table above lists six levels, so a codebase that only ever calls this interface has no way to write a trace or a critical message.
The implementation forwards each call to a static NLog logger:
using Contracts;
using NLog;
namespace LoggerService
{
public class LoggerManager : ILoggerManager
{
private static readonly ILogger Logger = LogManager.GetCurrentClassLogger();
public void LogDebug(string message) => Logger.Debug(message);
public void LogError(string message) => Logger.Error(message);
public void LogInfo(string message) => Logger.Info(message);
public void LogWarn(string message) => Logger.Warn(message);
}
}
GetCurrentClassLogger() names the logger after the class it is called in, and here that class is LoggerManager. Every message written through this wrapper is therefore categorised as LoggerService.LoggerManager, whichever controller or service produced it, which is the concrete form of the cost the answer block above describes.
Unlike ILogger<T>, the wrapper is ours, so the container has to be told about it. The registration goes in the ServiceExtensions class we built in part 2:
public static void ConfigureLoggerService(this IServiceCollection services)
{
services.AddSingleton<ILoggerManager, LoggerManager>();
}
Then we invoke it in Program.cs, right above builder.Services.AddControllers(): builder.Services.ConfigureLoggerService();
A singleton is the right lifetime here because the wrapper holds no per-request state. It is one object for the application, handing every call to the same NLog logger.
With that in place, a controller can take the wrapper instead of ILogger<T>, which is what the sample’s WeatherForecastController does:
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILoggerManager _logger;
public WeatherForecastController(ILoggerManager logger)
{
_logger = logger;
}
[HttpGet]
public IEnumerable<string> Get()
{
_logger.LogInfo("Here is info message from the controller.");
_logger.LogDebug("Here is debug message from the controller.");
_logger.LogWarn("Here is warn message from the controller.");
_logger.LogError("Here is error message from the controller.");
return ["value1", "value2"];
}
}
One request writes four lines to the day’s file, one per level. That is the seam the rest of the series uses: parts 4, 5 and 6 inject this same interface, and none of them has to know which library is underneath it.
What Are Dependency Injection and the IoC Container?
Dependency injection means a class states what it needs and receives it, instead of creating it.
A controller that logs declares an ILogger<T> parameter in its constructor. It never calls a constructor on a logger, never reads configuration, and never knows which provider is behind it.
The container is what makes that work. Service registrations tell it how to build each type, and when it constructs the controller it supplies the arguments from those registrations.
Lifetime is the choice we make at registration. A singleton is built once for the application, a scoped service once per request, and a transient one every time it is asked for.
Loggers are registered for us, so nothing here has to be wired by hand. The registrations we wrote in part 2 and this part follow the same rules, and they are the reason a class can ask for an interface and receive a working implementation.
The three lifetimes are the choice we make every time we register something of our own:
AddSingletoncreates the service the first time it is requested, and every later request receives that same instance. Every component shares one object for the lifetime of the application.AddScopedcreates the service once per request. Each HTTP request gets its own instance, and everything handling that request shares it.AddTransientcreates the service every time it is asked for. If several components need it while handling one request, each of them gets a separate instance.
Conclusion
We added NLog to the Web API as a logging provider, configured a file target in nlog.config, and wrote messages from a controller through ILogger<T>. We also built the ILoggerManager wrapper the rest of this series injects, with the argument for it stated rather than assumed.
One thing is still out of reach at this point: messages produced before the host is built, which our article on logging from Program.cs before the host is built covers.
Thank you for reading, and next up is Repository Pattern in .NET Core Web API, where we start reading and writing the database. The series introduction page lists every part if you would rather jump around.
Logging in a production API goes further than a file on disk: correlation across requests, structured output a query can search, and where exceptions get caught. The Ultimate ASP.NET Core Web API course builds that on top of this same project.
Tested with .NET 10 and NLog.Web.AspNetCore 6.2.0.
