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
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;
}
}
}
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<,>));
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; }
}
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);
}
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;
}
}
Register:
builder.Services.AddMemoryCache();
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));
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);
}
}
⚠️ 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));
}
}
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
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();
}
Health Checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database");
// In app configuration
app.MapHealthChecks("/health");
For detailed checks:
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database")
.AddCheck("self", () => HealthCheckResult.Healthy());
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"
}
}
}
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>
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<,>));
Request flow:
- LoggingBehavior — Starts timer, logs request name
- ValidationBehavior — Runs FluentValidation, throws if invalid
- CachingBehavior — Returns cached response or continues
- Handler — Does the actual work
- Returns back through the pipeline
Key Takeaways
- Pipeline behaviors are powerful — Add cross-cutting concerns once
- Order matters — Behaviors run in registration order
- Caching needs invalidation — Don't forget to clear stale data
- Async validation works — MustAsync for database checks
- Structured logging pays off — Use Serilog in production
- 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)
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!