Relational (EF Core)

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
Provider packageDatabaseTypeStatus
ArturRios.Data.SqliteSqLiteAvailable
ArturRios.Data.PostgreSqlPostgreSqlAvailable
ArturRios.Data.MySqlMySqlDeferred — see MySQL status

1. Define entities

Derive from Entity (an int Id mapped as the first column), or VersionedEntity to opt into optimistic concurrency (adds a Guid ConcurrencyStamp).

using ArturRios.Data.Relational.Core;

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

2. Define your context

Derive from BaseDbContext and expose your DbSets. BaseDbContext regenerates the ConcurrencyStamp of modified VersionedEntity 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()
builder.Services.AddDataConfigFromSettings<AppDbContext>(builder.Configuration, "ArturRios.Data.Core");

5. Repositories

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

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

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> repo)
{
    public async Task<Product?> GetAsync(int id)
    {
        DataOutput<Product?> result = await repo.GetByIdAsync(id);   // not-found = Success + null data
        return result.Success ? result.Data : null;
    }

    public async Task<int> CreateAsync(Product p)
    {
        var result = await repo.CreateAsync(p);                      // DataOutput<int> (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> repo, IAsyncUnitOfWork unitOfWork)
{
    public Task<DataOutput<int>> CreateTwoAtomicallyAsync(Product a, Product b) =>
        unitOfWork.ExecuteInTransactionAsync(async () =>
        {
            var first = await repo.CreateAsync(a);
            await repo.CreateAsync(b);
            return first.Data;
        });
}

7. Optimistic concurrency

Derive an entity from VersionedEntity 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()
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.

MySQL status

ArturRios.Data.MySql is written but deferred: it depends on Pomelo.EntityFrameworkCore.MySql, whose latest release still targets EF Core 9, while this library is on EF Core 10. The project is kept in the repository (excluded from the build) and will ship once Pomelo publishes an EF Core 10 release. An alternative provider (Oracle’s MySql.EntityFrameworkCore, which does support EF Core 10) is under consideration; it would trade Pomelo’s MySqlConnector (MIT, true async) and MariaDB support for immediate availability.