DEV Community

Cover image for 🍯 Announcing HoneyDrunk.Data: A Multi-Tenant Persistence Layer for Distributed .NET Applications
Tatted Dev
Tatted Dev

Posted on

🍯 Announcing HoneyDrunk.Data: A Multi-Tenant Persistence Layer for Distributed .NET Applications

TL;DR: We just released HoneyDrunk.Data, a provider-agnostic persistence layer built for multi-tenant, distributed .NET 10 applications. It features automatic correlation tracking in SQL queries, tenant-aware repositories, and seamless integration with OpenTelemetry—all without coupling your domain logic to Entity Framework.


The Problem We're Solving

Building multi-tenant applications with proper observability is harder than it should be. You end up with:

  • Scattered tenant checks throughout your codebase
  • No correlation between your distributed traces and database queries
  • Tight coupling between your domain logic and EF Core
  • Test infrastructure that requires spinning up real databases

We built HoneyDrunk.Data to solve these problems as part of HoneyDrunk.OS—our distributed application framework for .NET.


Architecture: Layers That Actually Make Sense

┌─────────────────────────────────────┐
│   HoneyDrunk.Data.Abstractions      │  ← Zero EF Core dependencies
│   (IRepository, IUnitOfWork, etc.)  │
└─────────────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│        HoneyDrunk.Data              │  ← Kernel integration, no EF Core
│   (Tenant accessor, telemetry)      │
└─────────────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│  HoneyDrunk.Data.EntityFramework    │  ← EF Core implementation
│   (EfRepository, EfUnitOfWork)      │
└─────────────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│    HoneyDrunk.Data.SqlServer        │  ← SQL Server specifics
│   (Retry, Azure SQL, datetime2)     │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Why this matters: Your domain projects reference only HoneyDrunk.Data.Abstractions. No EF Core leaking into your business logic. Swap providers without touching domain code.


Feature Highlight: Correlation Tracking in SQL

This is my favorite feature. Every SQL command is automatically tagged with the Grid correlation ID:

/* correlation:01JXY7ABC123DEF456 */
SELECT [o].[Id], [o].[CustomerId], [o].[Total]
FROM [Orders] AS [o]
WHERE [o].[TenantId] = @p0
Enter fullscreen mode Exit fullscreen mode

Why it matters:

  • Find the exact query from a slow API response
  • Correlate database logs with distributed traces
  • Debug production issues without guessing

The implementation uses EF Core's DbCommandInterceptor:

public sealed class CorrelationCommandInterceptor : DbCommandInterceptor
{
    private readonly IDataDiagnosticsContext _diagnosticsContext;

    public override InterceptionResult<int> NonQueryExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<int> result)
    {
        AddCorrelationComment(command);
        return base.NonQueryExecuting(command, eventData, result);
    }

    private void AddCorrelationComment(DbCommand command)
    {
        var correlationId = _diagnosticsContext.CorrelationId;
        if (string.IsNullOrEmpty(correlationId)) return;

        // SQL comments don't affect query plan caching
        command.CommandText = $"/* correlation:{correlationId} */\n{command.CommandText}";
    }
}
Enter fullscreen mode Exit fullscreen mode

We use SQL comments specifically because they don't affect query plan caching—your cached plans stay cached.


Multi-Tenancy: From HTTP Header to Database Query

Tenant context flows automatically from the HTTP request through to database queries:

HTTP Request                 Kernel                    Data Layer
     │                         │                          │
X-Tenant-Id: acme  →  IOperationContext.TenantId  →  ITenantAccessor
     │                         │                          │
                                                   TenantId.FromString("acme")
Enter fullscreen mode Exit fullscreen mode

The KernelTenantAccessor bridges our Kernel context system with the data layer:

public sealed class KernelTenantAccessor : ITenantAccessor
{
    private readonly IOperationContextAccessor _contextAccessor;

    public TenantId GetCurrentTenantId()
    {
        var tenantId = _contextAccessor.CurrentContext?.TenantId;
        return string.IsNullOrEmpty(tenantId) 
            ? default 
            : TenantId.FromString(tenantId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Your DbContext can then apply global query filters:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Every query automatically filters by tenant
    modelBuilder.Entity<Order>()
        .HasQueryFilter(o => o.TenantId == CurrentTenantId.Value);
}
Enter fullscreen mode Exit fullscreen mode

No more "did we remember to filter by tenant?" code reviews.


The Repository Pattern, Done Right

I know, I know—"repository pattern is an anti-pattern with EF Core." Hear me out.

Our repositories aren't about abstracting EF Core. They're about:

  1. Separating read from write concerns
  2. Providing a testable boundary
  3. Enabling future provider swaps (Dapper for hot paths, anyone?)
public interface IReadOnlyRepository<TEntity>
{
    ValueTask<TEntity?> FindByIdAsync(object id, CancellationToken ct = default);
    Task<IReadOnlyList<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default);
    Task<bool> ExistsAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default);
}

public interface IRepository<TEntity> : IReadOnlyRepository<TEntity>
{
    Task AddAsync(TEntity entity, CancellationToken ct = default);
    void Update(TEntity entity);
    void Remove(TEntity entity);
}
Enter fullscreen mode Exit fullscreen mode

The Unit of Work coordinates changes:

public async Task CheckoutAsync(Cart cart, CancellationToken ct)
{
    var orders = _unitOfWork.Repository<Order>();
    var inventory = _unitOfWork.Repository<InventoryItem>();

    var order = new Order { Items = cart.Items };
    await orders.AddAsync(order, ct);

    foreach (var item in cart.Items)
    {
        var inv = await inventory.FindByIdAsync(item.ProductId, ct);
        inv!.Quantity -= item.Quantity;
        inventory.Update(inv);
    }

    // Atomic save across all repositories
    await _unitOfWork.SaveChangesAsync(ct);
}
Enter fullscreen mode Exit fullscreen mode

Testing Without Docker: SQLite In-Memory

Spinning up SQL Server containers for every test run is slow. We provide SQLite-based test infrastructure:

public class OrderRepositoryTests : IAsyncDisposable
{
    private readonly SqliteTestDbContextFactory<AppDbContext> _factory;
    private readonly AppDbContext _context;

    public OrderRepositoryTests()
    {
        _factory = new SqliteTestDbContextFactory<AppDbContext>(
            options => new AppDbContext(
                options,
                TestDoubles.CreateTenantAccessor("test-tenant"),
                TestDoubles.CreateDiagnosticsContext()));

        _context = _factory.Create();
    }

    [Fact]
    public async Task AddAsync_ShouldPersistOrder()
    {
        var repo = new EfRepository<Order, AppDbContext>(_context);
        var order = new Order { Id = Guid.NewGuid(), Total = 99.99m };

        await repo.AddAsync(order);
        await _context.SaveChangesAsync();

        var found = await repo.FindByIdAsync(order.Id);
        Assert.NotNull(found);
    }

    public async ValueTask DisposeAsync()
    {
        await _context.DisposeAsync();
        await _factory.DisposeAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

Test doubles included for tenant and diagnostics contexts—no mocking frameworks required.


SQL Server Specifics: datetime2 and Retry Logic

The HoneyDrunk.Data.SqlServer package handles SQL Server-specific concerns:

builder.Services.AddHoneyDrunkDataSqlServer<AppDbContext>(options =>
{
    options.ConnectionString = config.GetConnectionString("Default");
    options.EnableRetryOnFailure = true;
    options.MaxRetryCount = 3;
});

// Or for Azure SQL with optimized settings
builder.Services.AddHoneyDrunkDataAzureSql<AppDbContext>(options =>
{
    options.ConnectionString = config.GetConnectionString("AzureSql");
});
Enter fullscreen mode Exit fullscreen mode

Model conventions automatically apply SQL Server best practices:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .UseDateTime2ForAllDateTimeProperties()  // No more datetime truncation
        .ConfigureDecimalPrecision(18, 4);       // Explicit money precision
}
Enter fullscreen mode Exit fullscreen mode

Getting Started

# Full stack with SQL Server
dotnet add package HoneyDrunk.Data.SqlServer

# Or just EF Core (bring your own provider)
dotnet add package HoneyDrunk.Data.EntityFramework

# Or abstractions only (for domain libraries)
dotnet add package HoneyDrunk.Data.Abstractions
Enter fullscreen mode Exit fullscreen mode

Minimal setup:

var builder = WebApplication.CreateBuilder(args);

// Register Kernel (required for context propagation)
builder.Services.AddHoneyDrunkGrid(options =>
{
    options.NodeId = "order-service";
    options.StudioId = "acme-corp";
});

// Register Data layer
builder.Services.AddHoneyDrunkData();
builder.Services.AddHoneyDrunkDataSqlServer<AppDbContext>(options =>
{
    options.ConnectionString = builder.Configuration.GetConnectionString("Default");
});

var app = builder.Build();

app.MapGet("/orders/{id}", async (Guid id, IUnitOfWork<AppDbContext> unitOfWork) =>
{
    var order = await unitOfWork.Repository<Order>().FindByIdAsync(id);
    return order is not null ? Results.Ok(order) : Results.NotFound();
});

app.Run();
Enter fullscreen mode Exit fullscreen mode

What's Next?

This is v0.1.0—the foundation. On the roadmap:

  • PostgreSQL provider (HoneyDrunk.Data.PostgreSQL)
  • Read replica support for CQRS patterns
  • Outbox pattern integration for reliable messaging
  • Query profiling with automatic slow query detection

Links

Built with 🍯 by HoneyDrunk Studios


Got questions? Open an issue or find us on GitHub. PRs welcome—especially if you want to add that PostgreSQL provider.


Originally published at https://tatteddev.com/blog/honeydrunk-data-multi-tenant-persistence-layer/

Top comments (0)