Guides

Coming from EF Core or Dapper

ARX.SDK isn't an ORM and doesn't replace EF Core or Dapper. It is a data-access layer with one entity model run against five databases. This page maps what you know to how ARX does it, and says plainly what it doesn't do.

The main differences

You're used to In ARX
A class per table (EF Core), or mapping rows to your classes (Dapper) ArxEntity: a table name and a dictionary of attributes. You map to and from your own classes, if you want them
LINQ (EF Core), or SQL strings (Dapper) ArxQueryExpression: conditions, columns, order, links (joins), paging and aggregates as objects, translated per database
DbContext with change tracking: SaveChanges writes what changed No tracking. UpdateAsync writes exactly the attributes the entity carries; one left out keeps its value
Migrations create and change the tables You own the tables and their DDL. The schema catalog declares what ARX needs to know (ids, keys, lookups), and ValidateSchemaAsync() checks it against the database
Keys found by convention (Id) Declared, never guessed: .Table("account", ArxIdColumn.Generated<long>("id"))
Navigation properties, Include A lookup reads back as an ArxEntityReference with the referenced record's display name (not on DynamoDB, which makes no joins: Name is null there); a link in the query joins. Read through a link, a lookup column is its plain id
SaveChanges in a transaction, TransactionScope service.BeginTransactionAsync() (from 0.1.0-preview.5, on every provider), BeginTransactionAsync() on the provider's connection (ArxPgConnection, …), or a batch scope (RunBatchAsync), with the transaction passed to each call
Exceptions of the database driver ArxException with a stable code, the same on every database; the driver's exception is the InnerException
SaveChanges returning 0, or Dapper's rows affected, when nothing matched UpdateAsync throws ARX2002 RecordNotFound; DeleteAsync returns false (or 0); RetrieveAsync returns null (Errors in practice, "When the record isn't there")
A concurrency token ([ConcurrencyCheck], rowversion) A version column declared in the catalog, .Version("account", "row_version"): a stale update or delete is ARX2004 RecordChanged (every provider from 0.1.0-preview.4)
ToQueryString(), or the SQL you wrote The driver's own logging, or the database's statement views: Logging and tracing, "Seeing the SQL ARX runs"
Raw SQL when you need it Procedures and functions through ExecuteAsync. For any other SQL, use the driver (or Dapper) beside ARX, in the same transaction when it must be (below)

Mapping to your own classes

ARX hands you entities; keep the mapping in one place:

public sealed record Account(long Id, string Name, int? Employees);

static Account ToAccount(ArxEntity entity) => new(
    entity.RecordId!.AsInt64(),   // set on every read with a catalog; null only without one
    Required<string>(entity, "name"),
    entity.GetAttributeValue<int?>("employee_count"));

// A misspelt name reads as null through entity["nmae"] or GetAttributeValue (a column read as NULL is null too).
// Where a column must be there, check that it was read, so a typo fails at once instead of mapping null.
// On MongoDB and DynamoDB a name in the column set that no record has reads back as null (there are no columns to
// check it against), so Contains is true there: declare the fields and use Strict, or keep the names in constants.
static T Required<T>(ArxEntity entity, string column)
    => entity.Contains(column) ? entity.GetAttributeValue<T>(column)! : throw new InvalidOperationException($"'{column}' wasn't read.");

GetAttributeValue<T> converts numbers when nothing is lost (a Count, always a long, read as int): ARX4001 ValueOutOfRange when the number doesn't fit (1.5 read as int), ARX4004 DataTypeMismatch for a value of another kind, so it's the safer read where a value can come back as more than one .NET type (see Switching providers). A misspelt name in a write, a filter or an order is caught: ARX5002 on the SQL databases.

From a JSON request body

An entity takes .NET values, not JsonElement (ARX1007 UnsupportedValueType). Convert each field to its column's type, and accept only the fields the caller may write: the list is also what keeps a request from setting columns it shouldn't.

static readonly Dictionary<string, Type> Writable = new()
{
    ["name"] = typeof(string),
    ["email"] = typeof(string),
    ["credit_limit"] = typeof(decimal),
    ["birthday"] = typeof(DateOnly)
};

static ArxEntity FromBody(string table, JsonElement body)
{
    var entity = new ArxEntity(table);

    foreach (var field in body.EnumerateObject())
    {
        if (!Writable.TryGetValue(field.Name, out var type))
        {
            throw new BadHttpRequestException($"'{field.Name}' can't be written.");
        }

        entity[field.Name] = field.Value.ValueKind == JsonValueKind.Null ? null : field.Value.Deserialize(type);
    }

    return entity;
}

A field left out of the body is left out of the entity, so UpdateAsync keeps its value, and an explicit null clears it: a PATCH works without more code. With a request class per endpoint, map its properties to attributes instead.

When nothing matched

EF Core's SaveChanges and Dapper's Execute return 0 when an update finds no row. UpdateAsync throws ARX2002 instead, so the change isn't lost without a word. Where "not there" is an expected answer (a PUT to an id that may have been deleted), catch that code:

async Task<bool> TryUpdateAsync(ArxEntity change)
{
    try
    {
        await service.UpdateAsync(change);
        return true;
    }
    catch (ArxException error) when (error.Code == ArxErrorCode.RecordNotFound)
    {
        return false;   // 404 in a web API
    }
}

DeleteAsync already returns false (by id) or 0 (by columns), and RetrieveAsync returns null. A write by a filter (next section) returns 0 when nothing matched, as EF Core's ExecuteUpdateAsync does.

Updating or deleting by a condition

EF Core's ExecuteUpdateAsync and ExecuteDeleteAsync, or a hand-written UPDATE … WHERE in Dapper, change every row a condition matches in one statement. In ARX the entity carries the values to set and an ArxFilterExpression says which rows (from 0.1.0-preview.5):

var change = new ArxEntity("customer");
change["optin_marketingemail"] = false;

var filter = new ArxFilterExpression();
filter.AddCondition("country", ArxConditionOperator.Equal, "PH");
filter.AddCondition("last_login", ArxConditionOperator.LessThan, cutoff);

int changed = await service.UpdateMultipleAsync(change, filter);
int removed = await service.DeleteMultipleAsync("session", expiredFilter);

The filter takes the operators of a query, Or groups included, on the table's own columns; values are parameters. The entity must not carry the id (with a filter as well, ARX can't tell which rows you meant: ARX1001), and an empty filter is refused unless you pass new ArxFilterWriteOptions { AllRows = true }. A version column goes up by one on every changed row. What ARX doesn't express is a value computed from the row (SET n = n + 1): use Dapper on the same transaction for that (below).

For a large insert where you don't need the new ids, CreateMultipleAsync(entities, new ArxCreateMultipleOptions { ReturnIds = false }) reads none back, so a large load takes PostgreSQL's binary COPY (5,000 rows or more of one shape) or SQL Server's SqlBulkCopy (1,000 or more), the paths EFCore.BulkExtensions or a hand-written importer would take, still all or nothing.

ARX and Dapper in one transaction

For SQL ARX doesn't express (an UPDATE … SET n = n + 1, an upsert, a CTE), write it with Dapper or the driver on the same connection and transaction. The operations have an overload that takes the driver's connection and transaction for this:

await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();

var account = new ArxEntity("account");
account["name"] = "Northwind Traders";
ArxRecordId id = await service.CreateAsync(account, connection, transaction);

await connection.ExecuteAsync(
    "UPDATE account SET employee_count = COALESCE(employee_count, 0) + 10 WHERE id = @id",
    new { id = id.AsInt64() },
    transaction);

await transaction.CommitAsync();   // both, or (on RollbackAsync or an error) neither

Application code that only uses ARX passes an ArxTransaction instead (from ArxPgConnection, …); the driver's types are for this kind of mixing.

Text matching

BeginsWith, Contains, EndsWith and Like ignore case on PostgreSQL (ILIKE) and MongoDB (a case-insensitive regular expression); on SQL Server and MySQL the column's collation decides. EF Core's StartsWith is case-sensitive on PostgreSQL, so code moved from it can return more rows. An ordinary index doesn't serve ILIKE or a case-insensitive regex: on a large PostgreSQL table, add a trigram index (pg_trgm) or a lower(column) index if those searches matter.

The driver and the session

ARX.SDK.PostgreSql works with Npgsql 8.0.3 or later and is tested on 8.0.3 and 10.0.3. In an application that also uses EF Core 10, NuGet picks Npgsql 10 for both (Versions and compatibility, "Supported driver versions"). The connections ARX opens itself run in a UTC session (Timezone=UTC is added unless your connection string names a zone), so stored instants don't depend on the server's zone. A connection you pass in keeps its own session.

What ARX doesn't do

  • No change tracking, lazy loading or identity map: each call reads or writes what you ask, when you ask.
  • No migrations or table creation.
  • No LINQ provider.
  • No update from an expression of the row (SET n = n + 1); a write by a filter sets values.
  • No caching.
  • No logging of its own: from 0.1.0-preview.2 every call is an activity and a metric that OpenTelemetry reads (AddSource("ARX.SDK"), AddMeter("ARX.SDK")); log ARX's exceptions, which carry stable codes.

When it fits

ARX is worth it when the same data code has to run on more than one database, or when you want the same error codes, the same refusals of what a database can't do, and the same injection checks on every one. On one database with a team fluent in EF Core or Dapper, those are the better-known tools.

How ARX compares with the raw drivers, Dapper and EF Core on each database is measured on each provider's Benchmarks page. Read the numbers there rather than any general claim.