DEV Community

Cover image for Clean Architecture in .NET 10: Validation, Logging, and Production Polish
Brian Spann
Brian Spann

Posted on

Clean Architecture in .NET 10: Validation, Logging, and Production Polish

Part 6 of 7. Start from the beginning if you're new here.


Your architecture is in place. The layers are clean. But production-ready code needs more: consistent validation, proper logging, caching, and robust error handling. This is where MediatR's pipeline behaviors shine.


Pipeline Behaviors: The MediatR Superpower

Pipeline behaviors intercept every request before and after it hits the handler. Think ASP.NET middleware, but for your application layer.

Request → [Logging] → [Validation] → [Caching] → Handler → Response
Enter fullscreen mode Exit fullscreen mode

We already have ValidationBehavior. Let's add more.


Logging Behavior

src/PromptVault.Application/Common/Behaviors/LoggingBehavior.cs

using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.Logging;

namespace PromptVault.Application.Common.Behaviors;

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    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 cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        var requestId = Guid.NewGuid().ToString("N")[..8];

        _logger.LogInformation("[{RequestId}] Handling {RequestName}", requestId, requestName);

        var stopwatch = Stopwatch.StartNew();

        try
        {
            var response = await next();
            stopwatch.Stop();

            _logger.LogInformation(
                "[{RequestId}] Handled {RequestName} in {ElapsedMs}ms",
                requestId, requestName, stopwatch.ElapsedMilliseconds);

            // Warn on slow requests
            if (stopwatch.ElapsedMilliseconds > 500)
            {
                _logger.LogWarning(
                    "[{RequestId}] {RequestName} took {ElapsedMs}ms (slow)",
                    requestId, requestName, stopwatch.ElapsedMilliseconds);
            }

            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex,
                "[{RequestId}] {RequestName} failed after {ElapsedMs}ms",
                requestId, requestName, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Register in Program.cs:

// Order matters! Logging first means we time everything including validation
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Enter fullscreen mode Exit fullscreen mode

Caching Behavior

For read-heavy queries, caching can dramatically improve performance.

Define a marker interface:

namespace PromptVault.Application.Common.Interfaces;

public interface ICacheableQuery
{
    string CacheKey { get; }
    TimeSpan? CacheDuration { get; }
}
Enter fullscreen mode Exit fullscreen mode

Make a query cacheable:

public record GetPromptByIdQuery(Guid Id, bool IncludeVersions = false) 
    : IRequest<Result<PromptDto>>, ICacheableQuery
{
    public string CacheKey => $"prompt:{Id}:versions:{IncludeVersions}";
    public TimeSpan? CacheDuration => TimeSpan.FromMinutes(5);
}
Enter fullscreen mode Exit fullscreen mode

The caching behavior:

using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using PromptVault.Application.Common.Interfaces;

namespace PromptVault.Application.Common.Behaviors;

public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<CachingBehavior<TRequest, TResponse>> _logger;

    public CachingBehavior(IMemoryCache cache, ILogger<CachingBehavior<TRequest, TResponse>> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Only cache if request implements ICacheableQuery
        if (request is not ICacheableQuery cacheableQuery)
            return await next();

        var cacheKey = cacheableQuery.CacheKey;

        // Try cache first
        if (_cache.TryGetValue(cacheKey, out TResponse? cached))
        {
            _logger.LogDebug("Cache hit for {CacheKey}", cacheKey);
            return cached!;
        }

        // Execute handler
        var response = await next();

        // Cache the response
        var options = new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = cacheableQuery.CacheDuration ?? TimeSpan.FromMinutes(5)
        };

        _cache.Set(cacheKey, response, options);
        _logger.LogDebug("Cached {CacheKey}", cacheKey);

        return response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register:

builder.Services.AddMemoryCache();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation

Don't forget to clear cache when data changes:

public class UpdatePromptCommandHandler : IRequestHandler<UpdatePromptCommand, Result<bool>>
{
    private readonly IPromptRepository _repository;
    private readonly IMemoryCache _cache;

    public async Task<Result<bool>> Handle(UpdatePromptCommand request, CancellationToken ct)
    {
        // ... update logic ...

        // Invalidate cache
        _cache.Remove($"prompt:{request.Id}:versions:true");
        _cache.Remove($"prompt:{request.Id}:versions:false");

        return Result<bool>.Success(true);
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Real-world callout: In-memory cache doesn't work across multiple instances. For distributed apps, use Redis with IDistributedCache.


Enhanced Validation with Async Rules

Sometimes validation needs database access:

using FluentValidation;
using PromptVault.Application.Interfaces;

namespace PromptVault.Application.Commands.CreatePrompt;

public class CreatePromptCommandValidator : AbstractValidator<CreatePromptCommand>
{
    public CreatePromptCommandValidator(IPromptRepository repository)
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required")
            .MaximumLength(200).WithMessage("Title cannot exceed 200 characters")
            .MustAsync(async (title, ct) => !await repository.TitleExistsAsync(title, null, ct))
            .WithMessage("A prompt with this title already exists");

        RuleFor(x => x.Content)
            .NotEmpty().WithMessage("Content is required")
            .MaximumLength(50000);

        RuleFor(x => x.ModelType)
            .NotEmpty()
            .Must(BeValidModelType).WithMessage("Invalid model type");
    }

    private static bool BeValidModelType(string modelType)
    {
        var validPrefixes = new[] { "gpt", "claude", "gemini", "llama", "mistral" };
        return validPrefixes.Any(p => modelType.ToLowerInvariant().StartsWith(p));
    }
}
Enter fullscreen mode Exit fullscreen mode

The MustAsync rule runs during validation—the pipeline behavior handles it automatically.


Structured Logging with Serilog

Replace default logging for production:

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
Enter fullscreen mode Exit fullscreen mode

Program.cs:

using Serilog;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
    .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .WriteTo.Console(outputTemplate: 
        "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

try
{
    Log.Information("Starting PromptVault API");

    var builder = WebApplication.CreateBuilder(args);
    builder.Host.UseSerilog();

    // ... rest of setup
}
catch (Exception ex)
{
    Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
    Log.CloseAndFlush();
}
Enter fullscreen mode Exit fullscreen mode

Health Checks

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database");

// In app configuration
app.MapHealthChecks("/health");
Enter fullscreen mode Exit fullscreen mode

For detailed checks:

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>("database")
    .AddCheck("self", () => HealthCheckResult.Healthy());
Enter fullscreen mode Exit fullscreen mode

Access at /health — returns Healthy, Degraded, or Unhealthy.


Configuration Best Practices

appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=PromptVault;Trusted_Connection=True;"
  },
  "Caching": {
    "DefaultDurationMinutes": 5,
    "Enabled": true
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "PromptVault": "Debug",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Strongly-typed settings:

public class CachingSettings
{
    public int DefaultDurationMinutes { get; set; } = 5;
    public bool Enabled { get; set; } = true;
}

// Registration
builder.Services.Configure<CachingSettings>(
    builder.Configuration.GetSection("Caching"));

// Usage via IOptions<CachingSettings>
Enter fullscreen mode Exit fullscreen mode

Real-World Callout: Pipeline Behavior Explosion

I've seen projects with 10+ pipeline behaviors:

  • Logging
  • Validation
  • Authorization
  • Caching
  • Transaction
  • Retry
  • Timeout
  • Metrics
  • Audit...

The problem: Every request goes through all of them. Performance overhead adds up. Debugging becomes a nightmare.

💡 My recommendation:

  • Always use: Logging, Validation
  • Use when needed: Caching (specific queries only)
  • Be cautious: Authorization (often better at API layer)

Keep it minimal. Add complexity only when you have the problem.


Complete Pipeline Registration

// Order matters!
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
Enter fullscreen mode Exit fullscreen mode

Request flow:

  1. LoggingBehavior — Starts timer, logs request name
  2. ValidationBehavior — Runs FluentValidation, throws if invalid
  3. CachingBehavior — Returns cached response or continues
  4. Handler — Does the actual work
  5. Returns back through the pipeline

Key Takeaways

  1. Pipeline behaviors are powerful — Add cross-cutting concerns once
  2. Order matters — Behaviors run in registration order
  3. Caching needs invalidation — Don't forget to clear stale data
  4. Async validation works — MustAsync for database checks
  5. Structured logging pays off — Use Serilog in production
  6. Don't over-engineer — 3-4 behaviors is usually enough

Coming Up

In Part 7 (final), we'll cover testing:

  • Unit tests for domain and handlers
  • Integration tests for the full API
  • What to test vs. what to skip

👉 Part 7: Testing What Matters


Full source: promptvault

Top comments (1)

Collapse
 
ramapratheeba profile image
Rama Pratheeba

Great article! Validation and logging are two pillars that really separate maintainable systems from fragile ones.

In production systems with high event traffic, I’ve found that structured logging (with correlation IDs and context) makes tracing issues across distributed layers dramatically easier.

Thanks for covering this!