An EF Core alternative for .NET apps with complex object graphs — full LINQ, no migrations, no DbContext

redb

Today I'd like to slow down a bit and talk about redb.Core — the data engine at the heart of the RedBase ecosystem. The other pieces (redb.Route for pipelines, redb.Tsak for cluster runtime) lean on it, but this post is just about the database part.

I've been working on this project for several years. It started as an attempt to get rid of migrations and turned into what it is now — a typed object store for .NET over PostgreSQL and MSSQL.

It's not a weekend prototype. The free packages on NuGet are at version 2.0, there are 43 packages across the ecosystem, the architecture went through three rewrites, and as of this week it's been running 3 months on production at a 30-year national food distributor (more on that below).

This post is a technical walkthrough of redb.Core — what it is, how it differs from EF Core, what the generated SQL actually looks like, what the production workload looks like, and what's shipping next.


What redb.Core actually is

github.com/redbase-app/redb — Apache 2.0

RedBase stores typed C# objects in two tables (_objects + _values) over PostgreSQL or Microsoft SQL Server. Not JSON blobs. Not JSONB. Real typed columns with FK constraints — NUMERIC(38,18) for money, timestamptz for dates, uuid for GUIDs. Real B-tree indexes. ACID transactions.

The schema is your C# class:

[RedbScheme("Employee")]
public class EmployeeProps
{
    public string FirstName   { get; set; } = "";
    public string LastName    { get; set; } = "";
    public int    Age         { get; set; }
    public decimal Salary     { get; set; }
    public DateTime HireDate  { get; set; }
    public string[]? Skills   { get; set; }
    public Address? HomeAddress { get; set; }
    public Dictionary<int, decimal>? BonusByYear { get; set; }
}

That attribute is the entire schema definition. Call SyncSchemeAsync<EmployeeProps>() once — done. Add a property next sprint — redeploy, call sync again. Old objects still work. No migration files. No DBA ticket. No 2am rollback story.

// Save — entire object graph, one call
await redb.SaveAsync(employee);

// Load — full graph, arrays, dicts, nested classes — all materialized
var e = await redb.LoadAsync<EmployeeProps>(id);

// Query — real LINQ, real SQL
var seniors = await redb.Query<EmployeeProps>()
    .Where(e => e.Salary > 100_000 && e.Age >= 35)
    .OrderByDescending(e => e.Salary)
    .Take(50)
    .ToListAsync();

No DbContext. No Include chains. No Add-Migration. No mapper layer.


Object graphs in one call

This is the part that surprises people coming from EF. Props can contain other Props — single references, arrays, dictionaries — and the entire graph saves and loads as one operation:

[RedbScheme("Order")]
public class OrderProps
{
    public Customer Customer { get; set; }                          // nested class
    public Address ShippingAddress { get; set; }                    // nested class
    public Product[] Products { get; set; }                         // array of classes
    public RedbObject<PaymentProps>[] Payments { get; set; }        // array of full objects with own IDs
    public Dictionary<string, RedbObject<CouponProps>> Coupons { get; set; }  // dict of objects
    public Dictionary<(int Year, string Quarter), string> Reviews { get; set; } // tuple-key dict
}

await redb.SaveAsync(order);   // entire graph persisted, FK ordering handled
var loaded = await redb.LoadAsync<OrderProps>(id);
// loaded.Props.Customer.Address — ready
// loaded.Props.Payments[0].Props — ready (full RedbObject with own Id, DateCreate, etc.)
// loaded.Props.Coupons["SUMMER20"].Props — ready

In EF Core this would be 28 tables, ~40 Include/ThenInclude calls, manual junction tables for the many-to-many, and INSERT ordering that breaks every time someone adds a non-nullable FK without a default.

In RedBase: one SaveAsync, one LoadAsync. The nested RedbObject instances are real first-class objects — they have their own IDs, their own timestamps, they can be queried independently, they participate in tree structures. They are not denormalised JSON glued to the parent.


What the LINQ actually compiles to

Where(e => e.Salary > 100_000 && e.Age >= 35) doesn't get serialized to JSON and re-parsed (that's the Free engine's path — covered later). In Pro, the C# expression tree is walked node-by-node and emitted as parameterized SQL. Roughly:

WITH pvt AS (
  SELECT v._id_object,
         (array_agg(v._Numeric) FILTER (WHERE v._id_structure = $1))[1] AS "Salary",
         (array_agg(v._Long)    FILTER (WHERE v._id_structure = $2))[1] AS "Age"
    FROM _values v
   WHERE v._id_structure = ANY($3::bigint[])
     AND v._id_object IN (SELECT o._id FROM _objects o WHERE o._id_scheme = $4)
   GROUP BY v._id_object
)
SELECT o.*
  FROM _objects o
  JOIN pvt ON pvt._id_object = o._id
 WHERE pvt."Salary" > $5
   AND pvt."Age"    >= $6
 ORDER BY pvt."Salary" DESC
 LIMIT 50;

Parameterized. Plan-cached by PostgreSQL. One index scan on (_id_structure, _id_object), one aggregation pass, B-tree filter on flat columns. The number of filter fields doesn't change the shape of the query.

The C# → SQL compiler handles arithmetic (*, +, %), Math.*, String.Contains/StartsWith/Trim/ToLower, DateTime.Year/Month/..., nullable navigation (x.Address?.City) compiled to IS NOT NULL, the ternary operator compiled to CASE WHEN, StringComparison.OrdinalIgnoreCase compiled to native ILIKE, dictionary access dict["key"] compiled to pivot columns, and a few more edge cases that EF Core itself doesn't always handle.

You can preview the SQL of any query without executing it:

var sql = await query.ToSqlStringAsync();

Like IQueryable.ToQueryString() in EF, but works for trees, GroupBy, window functions too.


Why bulk save is so fast — two tables, two streams

Something to understand before the change-tracking section: the storage layout is two tables, so SaveAsync of a batch is two bulk operations, not N round-trips.

On PostgreSQL the provider uses Npgsql's BeginBinaryImportAsync — native COPY protocol, binary format:

// from redb.Postgres/Data/NpgsqlBulkOperations.cs
await using var writer = await conn.BeginBinaryImportAsync(
    "COPY _objects (_id, _id_parent, _id_scheme, _name, ...) FROM STDIN (FORMAT BINARY)");
foreach (var obj in objectsList) {
    await writer.StartRowAsync();
    await writer.WriteAsync(obj.Id,        NpgsqlDbType.Bigint);
    await writer.WriteAsync(obj.IdScheme,  NpgsqlDbType.Bigint);
    // ... typed writes
}
await writer.CompleteAsync();

For a batch save: one COPY stream writes the _objects rows, another writes the _values rows. Two streams, no per-row round-trips, no string-formatted INSERTs. MSSQL uses SqlBulkCopy for the same role.

This is why 1000 routes × ~40 fields = ~40,000 value rows save in tens of milliseconds inside the 200–300 ms budget. The bottleneck is the network round-trip and the COPY write, not the ORM machinery — there isn't any ORM machinery in the hot path.


Change tracking without DbContext (Pro)

DbContext keeps an in-memory snapshot of every entity you load — that's how it knows what changed. It's also why it isn't thread-safe and why the cache dies with the request.

RedBase Pro takes a different approach. With PropsSaveStrategy.ChangeTracking (Pro only; the free tier uses DeleteInsert), SaveAsync does this on a batch:

  1. One bulk SELECT of existing values for all object IDs being saved.
  2. Build two ValueTreeNode trees — one from your in-memory objects, one from the DB state.
  3. Structural diff — subtrees with matching hashes are skipped entirely (no value comparison, no child traversal). Inserts, updates, and deletes are computed per node.
  4. Three bulk operationsBulkInsertValuesAsync, BulkUpdateValuesAsync, BulkDeleteValuesAsync — each a single round-trip. Inserts go through COPY BINARY again.

Net effect: changing one field in a deeply nested object emits one UPDATE, not a full delete-and-reinsert of the entire props graph. And the comparison happens in C# on the application side — no DbContext lifetime to worry about, safe to run from a background Channel consumer or a Parallel.ForEach.

(In the production code from the previous section, the application also does its own obj.ComputeHash() check at the route level. That part is optional business-code — you could call SaveAsync on every route and the Pro tree-diff would still skip unchanged values internally. It's there as a coarse pre-filter so unchanged routes don't even enter the save pipeline at all, saving the diff work too.)


Production deployment — the numbers

The biggest deployment right now:

  • 30-year national HoReCa food distributor
  • ~150,000 orders/month, ~20,000 B2B customers, 600+ cities
  • 3-node cluster, 4 cores / 8 GB RAM / 50 GB SSD per node
  • ~550 daily internal users (operators, drivers, supervisors, dispatch, back-office)
  • 10–15% CPU under full load
  • Integrations: SAP, Kafka, RabbitMQ, GPS feeds, Mercury / EGAIS / government APIs

Three months in production, no data-layer incidents. Two projects in the company use it, the second one came after the first one proved stable.

Real workload, real timings

The hottest pipeline is the SAP monitoring sync. Every 60 seconds a SQL polling consumer calls a stored procedure on SAP S/4 (usp_TsUM_MonitoringReport_xml), gets ~1000 transportation orders back as XML, syncs reference dictionaries (drivers, vehicles, list items), bulk-loads existing routes from RedBase, hash-compares each one, and saves only the changed ones.

The whole loop fits in ~200–300 ms for ~1000 routes. The actual production code:

From("direct://tsum")
    .RouteId("tsum-processing")
    .ProcessWithRedb(async (redb, exchange, ct) =>
    {
        var orders = (List<TransportationOrder>)exchange.In.Body;

        var sw = Stopwatch.StartNew();

        // 1. Sync dictionaries (Drivers, Vehicles, ListItems) — only new/changed
        var dicts = await DictionarySyncService.SyncFromOrdersAsync(redb, orders, ct);
        if (dicts.DriversNew + dicts.DriversChanged + dicts.VehiclesNew
            + dicts.VehiclesChanged + dicts.ListItemsRelinked > 0)
            await RefDataCache.RefreshAsync(redb);
        var syncMs = sw.ElapsedMilliseconds;

        // 2. Bulk load existing routes by Code — one query, ~1000 codes
        var codes = orders.Select(o => o.Code).ToList();
        var existing = await redb.Query<TransportationRoute>()
            .WhereRedb(o => codes.Contains(o.ValueString!))
            .ToListAsync();
        var existingByCode = existing.ToDictionary(o => o.ValueString!, o => o);

        // 3. Merge: update if hash changed, insert if new, skip if unchanged
        var toSave = new List<IRedbObject>();
        var updatedCount = 0;
        var skippedCount = 0;
        foreach (var order in orders)
        {
            var routeProps = MapOrderToRouteProps(order, dicts);
            if (existingByCode.TryGetValue(order.Code, out var obj))
            {
                var hashBefore = obj.ComputeHash();
                EnrichRouteFromOrder(obj.Props!, routeProps);
                if (obj.ComputeHash() != hashBefore) { toSave.Add(obj); updatedCount++; }
                else skippedCount++;
            }
            else
            {
                toSave.Add(new RedbObject<TransportationRoute>
                {
                    name = $"Route {order.Code}",
                    value_string = order.Code,
                    Props = routeProps
                });
            }
        }

        // 4. One batched save — mixed inserts + updates
        if (toSave.Count > 0)
            await redb.SaveAsync(toSave);

        Logger.LogInformation(
            "[TSUM] orders={Orders} routes(+{Created} ~{Updated} ={Skipped}) " +
            "drivers(+{DN} ~{DC}) vehicles(+{VN} ~{VC}) " +
            "sync={Sync} query={Query} save={Save} total={Total}ms",
            orders.Count, toSave.Count - updatedCount, updatedCount, skippedCount,
            dicts.DriversNew, dicts.DriversChanged,
            dicts.VehiclesNew, dicts.VehiclesChanged,
            syncMs, queryMs, saveMs, sw.ElapsedMilliseconds);
    });

TransportationRoute has ~40 fields and 12 RedbListItem references (Driver, Vehicle, CarMark, ShippingPoint, BusinessType, PlaceTo, PlaceFrom, LoadingZone, TransportStatus, Risk, DeliveryStatus, LoadStatus) plus 2 object references to AD users. Every single one is a foreign key in the database. None of them require a JOIN at query time — the materializer handles it.

Smaller queries (point-lookup of a single route, dictionary fetch, REST endpoints for the UI) run in 50–100 ms including HTTP overhead.

What's actually in the database

After running this in production, the storage looks like this:

  • ~1000 transportation routes/day, plus delivery points, transport snapshots, garage states, slice settings, slice snapshots, drivers, vehicles, yard places, AD user refs — about a dozen [Red