# Logging Strategy in Clean Architecture

> One pipeline behavior can log every use case with timing and failures, so handlers stay clean and the domain never sees an ILogger. Here is a layer-by-layer logging strategy for Clean Architecture: domain events instead of logs in the domain, behaviors in the application, direct logging in infrastructure, and middleware at the edge.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/logging-strategy-clean-architecture

In Clean Architecture, each layer has a different logging job.
The domain doesn't log at all (it raises domain events instead), the application logs through a single pipeline behavior, infrastructure logs external interactions directly, and the presentation layer logs through middleware and a global exception handler.

Ask five developers where logging belongs and you'll get five answers, from `ILogger` in every entity to nothing outside middleware.
This article maps it out: one pipeline behavior that covers every use case, free logging in infrastructure, and a domain layer that stays clean without going dark.

## The Logging Dilemma

You want observability - [structured logging](https://milanjovanovic.tech/blog/structured-logging-in-asp-net-core-with-serilog), trace IDs, request timings. But you also want clean domain logic without `ILogger` scattered everywhere.

In [Clean Architecture](https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design), logging has clear boundaries.

## Where to Log (And Where Not To)

- **Domain**: don't log. Raise domain events instead, and log in their handlers.
- **Application**: log minimally, through a `LoggingBehavior` pipeline behavior rather than inside handlers.
- **Infrastructure**: log freely. Inject `ILogger` directly and record every external interaction.
- **Presentation**: log via middleware - HTTP request/response logging and the global exception handler.

The [Domain layer](https://milanjovanovic.tech/blog/domain-layer-clean-architecture) should never reference `ILogger`. Domain entities don't need to know about logging infrastructure.

![Logging responsibility per Clean Architecture layer: middleware and the global handler at Presentation, a LoggingBehavior pipeline at Application, direct ILogger use at Infrastructure, and no logging in the Domain which raises events instead](https://milanjovanovic.tech/blogs/articles/logging-strategy-clean-architecture/logging-by-layer.png)

## Pipeline Behavior: Log Every Use Case

A single [MediatR pipeline behavior](https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors) can log every command and query automatically:

```csharp
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;

        _logger.LogInformation(
            "Handling {RequestName} {@Request}",
            requestName,
            request);

        var stopwatch = Stopwatch.StartNew();
        var response = await next();
        stopwatch.Stop();

        if (stopwatch.ElapsedMilliseconds > 500)
        {
            _logger.LogWarning(
                "Long-running request: {RequestName} took {ElapsedMs}ms",
                requestName,
                stopwatch.ElapsedMilliseconds);
        }

        if (response is Result { IsFailure: true } result)
        {
            _logger.LogError(
                "Handled {RequestName} with error {ErrorCode} in {ElapsedMs}ms",
                requestName,
                result.Error.Code,
                stopwatch.ElapsedMilliseconds);
        }
        else
        {
            _logger.LogInformation(
                "Handled {RequestName} in {ElapsedMs}ms",
                requestName,
                stopwatch.ElapsedMilliseconds);
        }

        return response;
    }
}
```

Register it in the Application layer's DI:

```csharp
services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(assembly);
    config.AddOpenBehavior(typeof(LoggingBehavior<,>));
    config.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
```

Now every command and query is logged (entry, exit, duration, and failed results) without touching a single handler.
Note that the behavior doesn't catch exceptions.
Those propagate to the global exception handler, so each failure gets logged exactly once.

## Structured Properties

Enrich your logs with contextual properties using [structured logging](https://milanjovanovic.tech/blog/structured-logging-in-asp-net-core-with-serilog):

```csharp
_logger.LogInformation(
    "Handling {RequestName} with {UserId} {@Request}",
    requestName,
    currentUser.UserId,
    request);
```

Structured properties make logs searchable. You can find all requests from a specific user or all executions of `PlaceOrderCommand`.

**Be careful with `{@Request}`.** This destructures the entire request object. If a command contains sensitive data (passwords, tokens), you'll log it.

Note that `[JsonIgnore]` won't help here - Serilog uses its own destructuring, not System.Text.Json.
Use the [Destructurama.Attributed](https://github.com/destructurama/attributed) package to mask properties:

```csharp
public sealed record LoginCommand(
    string Email,
    [property: NotLogged] string Password) : ICommand<TokenResponse>;
```

Or configure a destructuring policy in your Serilog setup.
The safest default: log the request *name*, not the request *body*, and opt in to logging specific properties.
I cover this and other pitfalls in [**5 Serilog best practices**](https://milanjovanovic.tech/blog/5-serilog-best-practices-for-better-structured-logging).

## Infrastructure Layer Logging

The [Infrastructure layer](https://milanjovanovic.tech/blog/infrastructure-layer-clean-architecture) is where you log external interactions:

```csharp
public class EmailService : IEmailService
{
    private readonly ILogger<EmailService> _logger;
    private readonly SmtpClient _client;

    public EmailService(ILogger<EmailService> logger, SmtpClient client)
    {
        _logger = logger;
        _client = client;
    }

    public async Task SendAsync(
        string to, string subject, string body, CancellationToken ct)
    {
        _logger.LogInformation(
            "Sending email to {Recipient} with subject {Subject}",
            to,
            subject);

        try
        {
            await _client.SendMailAsync(
                new MailMessage("noreply@app.com", to, subject, body), ct);

            _logger.LogInformation(
                "Email sent to {Recipient}", to);
        }
        catch (SmtpException ex)
        {
            _logger.LogError(ex,
                "Failed to send email to {Recipient}: {Error}",
                to,
                ex.Message);
            throw;
        }
    }
}
```

Log before and after external calls. Log failures with full exception details. This is your debugging lifeline when third-party services fail.

## Request/Response Logging Middleware

Log HTTP requests at the API layer with middleware:

```csharp
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await _next(context);
        }
        finally
        {
            stopwatch.Stop();

            _logger.LogInformation(
                "HTTP {Method} {Path} responded {StatusCode} in {ElapsedMs}ms",
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                stopwatch.ElapsedMilliseconds);
        }
    }
}
```

Or use Serilog's built-in request logging:

```csharp
app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("UserId",
            httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier));
    };
});
```

## Domain Events Instead of Logging

Instead of injecting `ILogger` into domain entities, raise [domain events](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems):

```csharp
public class Order : AggregateRoot
{
    public OrderStatus Status { get; private set; }
    public string? CancellationReason { get; private set; }

    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Shipped)
            throw new DomainException("Cannot cancel a shipped order.");

        Status = OrderStatus.Cancelled;
        CancellationReason = reason;

        // Don't log here - raise an event
        RaiseDomainEvent(new OrderCancelledDomainEvent(Id, reason));
    }
}
```

Handle the event in the Application or Infrastructure layer where logging is appropriate:

```csharp
public class OrderCancelledEventHandler
    : INotificationHandler<OrderCancelledDomainEvent>
{
    private readonly ILogger<OrderCancelledEventHandler> _logger;

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

    public Task Handle(
        OrderCancelledDomainEvent notification, CancellationToken ct)
    {
        _logger.LogInformation(
            "Order {OrderId} cancelled. Reason: {Reason}",
            notification.OrderId,
            notification.Reason);

        return Task.CompletedTask;
    }
}
```

The domain stays clean. The logging happens in the right layer.

## Error Logging

Log exceptions in the [global error handler](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers):

```csharp
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

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

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken ct)
    {
        _logger.LogError(
            exception,
            "Unhandled exception: {Message}",
            exception.Message);

        httpContext.Response.StatusCode = 500;
        await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = 500,
            Title = "Internal Server Error"
        }, ct);

        return true;
    }
}
```

Log the full exception with stack trace. Return a generic message to the client.

## Log Levels

Use the right level for the right situation:

- `Trace` - detailed diagnostic info (never in production)
- `Debug` - development-time debugging
- `Information` - normal operations, use case execution
- `Warning` - slow requests, degraded performance
- `Error` - unhandled exceptions, failed operations
- `Critical` - application startup failures, data corruption

## Key Takeaways

Logging in Clean Architecture follows the dependency rule:

1. **Domain layer** - No logging. Raise domain events instead.
2. **Application layer** - Pipeline behaviors for automatic use case logging.
3. **Infrastructure layer** - Direct `ILogger` injection for external integrations.
4. **Presentation layer** - Middleware for HTTP request/response logging.
5. **Global error handler** - Catches and logs all unhandled exceptions.

Use structured logging everywhere. Enrich with correlation IDs and user context. Let the architecture guide where logging belongs.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### Should the domain layer have logging in Clean Architecture?

No. Domain entities should never reference ILogger. Raise domain events for meaningful occurrences and log in the event handlers, which live in the application or infrastructure layer.

### How do you log every use case without touching handlers?

With a MediatR pipeline behavior (or a handler decorator). One LoggingBehavior logs entry, exit, duration, and failures for every command and query automatically.

### Is it safe to log the whole request object with Serilog?

Not by default. Destructuring with the @ operator serializes every property, including passwords and tokens. Use Serilog destructuring policies or the Destructurama.Attributed package to mask sensitive fields, or log only selected properties.

### What log level should I use for slow requests?

Warning. Information is for normal operations, Warning for degraded behavior like slow or retried requests, Error for failed operations, and Critical for startup failures or data corruption.

### Where should exceptions be logged in Clean Architecture?

In the global exception handler at the presentation boundary. Log the full exception with stack trace there, and return a generic Problem Details response to the client. Avoid logging the same exception at multiple layers.
