January 12 2026
public interface IAuditableEntity
{
DateTimeOffset CreatedAt { get; set; }
string? CreatedBy { get; set; }
DateTimeOffset UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
}
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
public sealed class Invoice : IAuditableEntity, ISoftDelete
{
Guid Id { get; set; }
Guid TenantId { get; set; }
decimal Amount { get; set; }
string Currency { get; set; } = "EUR";
DateTimeOffset CreatedAt { get; set; }
string? CreatedBy { get; set; }
DateTimeOffset UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
public interface ICurrentActor
{
string? UserId { get; }
Guid? TenantId { get; }
string CorrelationId { get; }
}
public sealed class CurrentActor : ICurrentActor
{
public CurrentActor(IHttpContextAccessor accessor)
{
var http = accessor.HttpContext;
UserId = http?.User?.Identity?.Name;
TenantId = Guid.TryParse(http?.Request.Headers["X-Tenant-Id"], out var tid) ? tid : null;
CorrelationId =
http?.TraceIdentifier
?? Activity.Current?.Id
?? Guid.NewGuid().ToString("N");
}
public string? UserId { get; }
public Guid? TenantId { get; }
public string CorrelationId { get; }
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public sealed class AuditAndSoftDeleteInterceptor : SaveChangesInterceptor
{
private readonly ICurrentActor _actor;
public AuditAndSoftDeleteInterceptor(ICurrentActor actor)
=> _actor = actor;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
ApplyRules(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
ApplyRules(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void ApplyRules(DbContext? context)
{
if (context is null) return;
var now = DateTimeOffset.UtcNow;
var user = _actor.UserId ?? "system";
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.Entity is IAuditableEntity auditable)
{
if (entry.State == EntityState.Added)
{
auditable.CreatedAt = now;
auditable.CreatedBy = user;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
auditable.UpdatedAt = now;
auditable.UpdatedBy = user;
}
}
if (entry.Entity is ISoftDelete softDelete && entry.State == EntityState.Deleted)
{
// Convert hard delete into soft delete
entry.State = EntityState.Modified;
softDelete.IsDeleted = true;
softDelete.DeletedAt = now;
softDelete.DeletedBy = user;
// Also counts as an update
if (entry.Entity is IAuditableEntity a)
{
a.UpdatedAt = now;
a.UpdatedBy = user;
}
}
}
}
}
using System.Data.Common;
using System.Diagnostics;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
public sealed class ObservabilityCommandInterceptor : DbCommandInterceptor
{
private readonly ICurrentActor _actor;
private readonly ILogger<ObservabilityCommandInterceptor> _logger;
// Tune for your system
private static readonly TimeSpan SlowQueryThreshold = TimeSpan.FromMilliseconds(250);
public ObservabilityCommandInterceptor(
ICurrentActor actor,
ILogger<ObservabilityCommandInterceptor> logger)
{
_actor = actor;
_logger = logger;
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
Tag(command);
eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew());
return base.ReaderExecuting(command, eventData, result);
}
public override void ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
LogIfSlow(eventData, command);
base.ReaderExecuted(command, eventData, result);
}
public override InterceptionResult<int> NonQueryExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<int> result)
{
Tag(command);
eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew());
return base.NonQueryExecuting(command, eventData, result);
}
public override void NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
LogIfSlow(eventData, command);
base.NonQueryExecuted(command, eventData, result);
}
public override InterceptionResult<object> ScalarExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<object> result)
{
Tag(command);
eventData.Context?.Items.TryAdd(eventData.CommandId, Stopwatch.StartNew());
return base.ScalarExecuting(command, eventData, result);
}
public override void ScalarExecuted(
DbCommand command,
CommandExecutedEventData eventData,
object result)
{
LogIfSlow(eventData, command);
base.ScalarExecuted(command, eventData, result);
}
private void Tag(DbCommand command)
{
var tenant = _actor.TenantId?.ToString() ?? "none";
var corr = _actor.CorrelationId;
// SQL comments are ignored by most engines but show up in logs/traces
// Keep it short to avoid huge command texts
command.CommandText = $"/* tenant:{tenant} corr:{corr} */\n{command.CommandText}";
}
private void LogIfSlow(CommandExecutedEventData eventData, DbCommand command)
{
if (eventData.Context is null) return;
if (eventData.Context.Items.TryGetValue(eventData.CommandId, out var swObj) &&
swObj is Stopwatch sw)
{
sw.Stop();
if (sw.Elapsed >= SlowQueryThreshold)
{
_logger.LogWarning(
"Slow SQL ({ElapsedMs} ms) tenant:{TenantId} corr:{CorrelationId}\n{CommandText}",
sw.Elapsed.TotalMilliseconds,
_actor.TenantId,
_actor.CorrelationId,
command.CommandText);
}
eventData.Context.Items.Remove(eventData.CommandId);
}
}
}
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentActor, CurrentActor>();
// Interceptors: usually safe as singleton if they are stateless.
// Ours depends on scoped ICurrentActor, so we register them as scoped.
builder.Services.AddScoped<AuditAndSoftDeleteInterceptor>();
builder.Services.AddScoped<ObservabilityCommandInterceptor>();
builder.Services.AddDbContext<BillingDbContext>((sp, options) =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("db"));
// Add interceptors from DI
options.AddInterceptors(
sp.GetRequiredService<AuditAndSoftDeleteInterceptor>(),
sp.GetRequiredService<ObservabilityCommandInterceptor>());
});
var app = builder.Build();
app.MapGet("/", () => "OK");
app.Run();
1. Design Patterns that Deliver
This isn’t just another design patterns book. Dive into real-world examples and practical solutions to real problems in real applications.Check out it here.
Go-to resource for understanding the core concepts of design patterns without the overwhelming complexity. In this concise and affordable ebook, I've distilled the essence of design patterns into an easy-to-digest format. It is a Beginner level. Check out it here.
Every Monday morning, I share 1 actionable tip on C#, .NET & Arcitecture topic, that you can use right away.
Join 18,000+ subscribers to improve your .NET Knowledge.
Subscribe to the TheCodeMan.net and be among the 18,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.