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 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 package | DatabaseType | Status |
|---|---|---|
ArturRios.Data.Sqlite | SqLite | Available |
ArturRios.Data.PostgreSql | PostgreSql | Available |
ArturRios.Data.MySql | MySql | Available — 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:
| Interface | Members |
|---|---|
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:
- From 4.x to 5.0: Upgrading from 4.x to 5.0
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.Relationalto[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
MySqlProviderandAddMySqlProvider()are. If upstream Pomelo ships an EF Core 10 release, swapping back is an internal change here rather than a change to your code.