Relational

The relational stack is a provider-agnostic data-access layer over Entity Framework Core. You install the core plus a provider for your engine; the…

The relational stack is a provider-agnostic data-access layer over Entity Framework Core. You install the core plus a provider for your engine; the core gives you enveloped repositories, a unit of work, optimistic concurrency, and DI wiring, while the provider teaches it how to talk to PostgreSQL, SQLite, or MySQL.

Install

dotnet add package ArturRios.Data.Relational.Core
dotnet add package ArturRios.Data.Sqlite          # or ArturRios.Data.PostgreSql / ArturRios.Data.MySql
Provider packageDatabaseTypeStatus
ArturRios.Data.SqliteSqLiteAvailable
ArturRios.Data.PostgreSqlPostgreSqlAvailable
ArturRios.Data.MySqlMySqlAvailable — see MySQL and MariaDB

1. Define entities

Derive from Entity<TKey>, where TKey is the primary key type mapped as the first column, or from VersionedEntity<TKey> to opt into optimistic concurrency (adds a Guid ConcurrencyStamp). Any TKey that implements IEquatable<TKey> works — typically long, int, Guid or string.

using ArturRios.Data.Relational.Core.Entities;

public class Product : Entity<long>    // or : VersionedEntity<long>
{
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
}

public class Order : Entity<Guid>
{
    public decimal Total { get; set; }
}

Numeric keys are database-generated and Guid keys are generated by EF Core on insert; string keys must be assigned before Create.

2. Define your context

Derive from BaseDbContext and expose your DbSets. BaseDbContext regenerates the ConcurrencyStamp of modified VersionedEntity<TKey> rows on every SaveChanges / SaveChangesAsync.

using ArturRios.Data.Relational.Core.Configuration;
using Microsoft.EntityFrameworkCore;

public class AppDbContext(DbContextOptions options) : BaseDbContext(options)
{
    public DbSet<Product> Products => Set<Product>();
}

3. Configure

Bind a BaseDbContextOptions (a DatabaseType + a ConnectionString) from configuration. The default section name is "ArturRios.Data.Core" (you can pass a different sectionName to AddDataConfigFromSettings):

{
  "ArturRios.Data.Core": {
    "DatabaseType": "PostgreSql",
    "ConnectionString": "Host=localhost;Database=mydb;Username=app;Password=secret;"
  }
}

DatabaseType is an enum: PostgreSql, MySql, or SqLite — note the lowercase q in SqLite. (Configuration binding is case-insensitive, so "SQLite" in JSON still binds, but the C# member is DatabaseType.SqLite.)

Registering from environment variables

When configuration lives in environment variables rather than appsettings, call AddDataConfigFromEnvironment<TContext> with a name prefix instead of AddDataConfigFromSettings:

builder.Services.AddDataConfigFromEnvironment<AppDbContext>("ARTURRIOS_DATA");

It reads ARTURRIOS_DATA_DATABASETYPE (one of PostgreSql, MySql, SqLite) and ARTURRIOS_DATA_CONNECTIONSTRING. The appsettings section is not consulted on this path. A missing or invalid ..._DATABASETYPE throws; a missing ..._CONNECTIONSTRING defaults to an empty string.

4. Register

Call your provider’s registration extension and AddDataConfigFromSettings<TContext>. The provider registers its IDatabaseProvider; AddDataConfigFromSettings reads the configured DatabaseType, resolves the matching provider, wires up your DbContext, and registers the repositories and unit of work. It fails fast at registration if no provider matches the configured DatabaseType.

using ArturRios.Data.PostgreSql;                       // brings AddPostgreSqlProvider()
using ArturRios.Data.Relational.Core.DependencyInjection;

builder.Services.AddPostgreSqlProvider();               // or AddSqliteProvider() / AddMySqlProvider()
builder.Services.AddDataConfigFromSettings<AppDbContext>(builder.Configuration, "ArturRios.Data.Core");

5. Repositories

AddDataConfigFromSettings registers all four repository interfaces (backed by EfRepository<T, TKey>). There are two tiers — read-only and full read/write — each in a sync and an async flavour:

InterfaceMembers
IReadOnlyRepository<T, TKey>Query(), GetAll(), GetById(TKey)
IRepository<T, TKey>the above + Create, CreateRange, Update, UpdateRange, Delete, DeleteRange
IAsyncReadOnlyRepository<T, TKey>async mirror of the read-only tier
IAsyncRepository<T, TKey>async mirror of the full tier

TKey must match the entity’s key type. Inject whichever tier you need. Every method returns a DataOutput<T> envelope — check Success / Data / Errors:

using ArturRios.Data.Relational.Core.Interfaces;
using ArturRios.Output;

public class ProductService(IAsyncRepository<Product, long> repo)
{
    public async Task<Product?> GetAsync(long id)
    {
        DataOutput<Product?> result = await repo.GetByIdAsync(id);   // not-found = Success + null data
        return result.Success ? result.Data : null;
    }

    public async Task<long> CreateAsync(Product p)
    {
        var result = await repo.CreateAsync(p);                      // DataOutput<long> (the new id)
        return result.Success ? result.Data : throw new InvalidOperationException(string.Join(", ", result.Errors));
    }
}

The Query() escape hatch. When you need composable LINQ or paging, Query() returns a raw, deferred IQueryable<T> (not enveloped — it does no I/O until materialized):

var page = repo.Query()
    .Where(p => p.Price > 10)
    .OrderBy(p => p.Name)
    .Skip(20).Take(10)
    .ToList();

6. Transactions (unit of work)

AddDataConfigFromSettings also registers IUnitOfWork / IAsyncUnitOfWork. Run several repository operations atomically with the delegate helper — it commits on success and rolls back on any exception, returning an envelope:

using ArturRios.Data.Relational.Core.Transactions;

public class OrderService(IAsyncRepository<Product, long> repo, IAsyncUnitOfWork unitOfWork)
{
    public Task<DataOutput<long>> CreateTwoAtomicallyAsync(Product a, Product b) =>
        unitOfWork.ExecuteInTransactionAsync(async () =>
        {
            var first = await repo.CreateAsync(a);
            await repo.CreateAsync(b);
            return first.Data;
        });
}

Every failure comes back on the envelope, including one to start the transaction (no connection, or a transaction already open on the context). After a rollback the context’s change tracker is cleared, since the database kept none of the transaction’s writes.

A failed repository write outside a transaction leaves the context usable as well: the rejected changes are discarded, so the next write does not replay them, and a VersionedEntity<TKey> keeps the stamp the database still holds.

7. Optimistic concurrency

Derive an entity from VersionedEntity<TKey> to opt in. On update, the stored ConcurrencyStamp is checked; if another writer changed the row, the update returns a concurrency-conflict error envelope (Success == false) instead of throwing:

var result = await repo.UpdateAsync(product);
if (!result.Success)
{
    // e.g. "Concurrency conflict: the record was modified or removed by another process."
}

8. Dapper read path (optional)

For raw-SQL reads alongside EF-based persistence, add ArturRios.Data.Dapper and register AddDapper() after AddDataConfigFromSettings:

dotnet add package ArturRios.Data.Dapper
using ArturRios.Data.Dapper;

builder.Services.AddSqliteProvider();                  // or AddPostgreSqlProvider() / AddMySqlProvider()
builder.Services.AddDataConfigFromSettings<AppDbContext>(builder.Configuration, "ArturRios.Data.Core");
builder.Services.AddDapper();

Inject IAsyncSqlQuery (or the sync ISqlQuery) and run enveloped, parameterized queries:

public class ReportService(IAsyncSqlQuery sql)
{
    public async Task<long> ActiveCountAsync()
    {
        var result = await sql.ExecuteScalarAsync<long>(
            "SELECT COUNT(*) FROM Products WHERE IsActive = @active", new { active = true });

        return result.Success ? result.Data : 0;
    }
}

ISqlQuery/IAsyncSqlQuery expose Query<T>, QueryFirstOrDefault<T>, QuerySingleOrDefault<T>, and ExecuteScalar<T> (all enveloped, generic T unconstrained so you can map to DTOs or scalars).

The Dapper path is read-only — all writes go through the EF repositories. It runs on the same DbContext connection and enlists in the active IUnitOfWork transaction, so a Dapper read inside a unit of work sees the not-yet-committed EF writes.

Upgrading

Upgrade guides live in the Changelog, in the entry of the version that needs them:

MySQL and MariaDB

ArturRios.Data.MySql registers MySqlProvider for DatabaseType.MySql, and works against both MySQL and MariaDB. Register it like any other provider:

using ArturRios.Data.MySql;                            // brings AddMySqlProvider()
using ArturRios.Data.Relational.Core.DependencyInjection;

builder.Services.AddMySqlProvider();
builder.Services.AddDataConfigFromSettings<AppDbContext>(builder.Configuration, "ArturRios.Data.Core");

The provider calls UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)), so the server version is probed from the connection string at configuration time — the connection must be reachable the first time DbContext options are built for that connection string. The detected version is kept and reused, so later contexts (one per request, in a web app) do not query the server again.

A note on the underlying EF Core provider

This package is built on Microting.EntityFrameworkCore.MySql, an MIT-licensed, actively maintained fork of Pomelo.EntityFrameworkCore.MySql. Upstream Pomelo’s latest release (9.0.0) still targets EF Core 9 and has no EF Core 10 build; the fork tracks EF Core 10 patch releases and keeps Pomelo’s API and its MIT MySqlConnector driver (true async, MariaDB support).

Two practical consequences:

  • The dependency is pinned to an exact fork version. The fork constrains Microsoft.EntityFrameworkCore.Relational to [10.0.10, 10.0.999], so moving this library to a future EF Core major requires a matching fork release first.
  • The provider type is not part of this package’s public API — only MySqlProvider and AddMySqlProvider() are. If upstream Pomelo ships an EF Core 10 release, swapping back is an internal change here rather than a change to your code.