One data-access API for .NET, local or remote: your entities, their caches and their children, the same code everywhere.
A view model or a service asks for IPersonRepository or IEntityCache<Person, long>. It never knows whether the data comes
from SQLite through Dommel, from SQL Server through EF Core, or from a server across the network.
What it is not: not an ORM (it runs on top of Dommel or EF Core, which you keep for complex queries), not a query builder or a query-string filter library, and not a GraphQL server. It is the layer above them: the same store API locally and remotely, in-memory caches that follow the database, parent-child graphs saved in one transaction, and change notifications between clients.
Status: early. Packages are not published on NuGet yet, and the API may still change. To try it now, build them from the sources into a local package source: three commands, in getting started.
dotnet new install Queryeables.Templates
dotnet new queryeables-console -n MyApp # SQLite, a cache, a person and its contacts
dotnet new queryeables-client-server -n Shop # the same, through a MagicOnion server📖 Documentation: getting started, tutorial (local app, cache, graph, server, offline), guides, benchmarks, when to choose something else.
[EntityCache] // an in-memory cache that follows the database
public class Person
{
public long Id { get; set; }
public string Name { get; set; } = "";
[EntityGraph(ParentNavigation = nameof(Contact.Owner))] // saved, removed and loaded with the person
public List<Contact>? Contacts { get; set; } // null = not loaded: saving leaves the contacts alone
}
public class Contact
{
public long Id { get; set; }
public long PersonId { get; set; } // the foreign key, found by its name
public Person? Owner { get; set; }
}
services.UseQueryeables(q => q
.UseSqlite("Data Source=app.db") // the provider
.AddGeneratedEntities()); // what the attributes describe, written by the source generatorpublic sealed class PersonsViewModel(IPersonRepository persons, IEntityCache<Person, long> cache)
{
public IReadOnlyCollection<Person> All => cache.Entities; // kept up to date by the database
public Task<EntityResult> SaveAsync(Person person) => persons.SaveAsync(person); // the person and its contacts, one transaction
}Two attributes and one registration call. To use a remote server instead, the client changes one line:
UseMagicOnion("https://server:5001") instead of UseSqlite(...). The sample
runs this end to end on SQLite, then the same service as a remote client of an in-process server.
Each provider brings the Queryeables package: the contracts, the remote-access core, the
setup and the source generator. There is nothing else to add.
| Data | Package | Setup |
|---|---|---|
| SQLite | Queryeables.Sqlite |
q.UseSqlite("Data Source=app.db"), with change notifications |
| PostgreSQL | Queryeables.PostgreSql |
q.UseNpgsql(connectionString, notifications: n => n.Watch<Person>(p => p.Id)), with change notifications |
| MongoDB | Queryeables.MongoDB |
q.UseMongoDB(connectionString, "app", notifications: n => n.Watch<Person>()), with change streams |
| SQL Server, MySQL... | Queryeables.Dommel |
q.UseDommel<SqlConnection>(connectionString) |
| Any EF Core provider | Queryeables.EntityFramework |
q.UseEntityFramework(sp => new AppDbContext(options)) |
| A Queryeables server | Queryeables.MagicOnion.Client |
q.UseMagicOnion("https://server:5001") |
| Your own store | Queryeables |
q.UseStores(sp => new MyStore(...)) |
dotnet add package Queryeables.Sqlite # or .PostgreSql, .MongoDB, .Dommel, .EntityFramework, .MagicOnion.Client- One store API,
IEntityStore: async CRUD, filters, paging, counting and transactions, identical for Dommel, EF Core and a remote server. Every implementation passes the same contract tests. - Entity graphs,
[EntityGraph]: a parent saved, removed and loaded with its children, navigation properties set, related entities found in their caches. The generator writesI{Parent}Repositoryand{Parent}Graph, and a mistake in the attributes is a compile error (QRY001 to QRY009). - Caches that follow the database,
[EntityCache]: row by row, bursts coalesced, incremental resynchronization after a reconnection. - Transactions that report their outcome: an
EntityResultwith an error kind (Validation,Conflict,NotFound,Forbidden...) instead of an exception. - Remote access without coupling: the remote core speaks to an
IEntityTransport, and MagicOnion is one transport among others. No SQL from the client, a whitelist of filter operators, an access policy per type. - Offline mode,
WithOfflineMode: a remote client keeps working on a local copy (IndexedDB in the browser, SQLite on desktop and mobile), is told when the server is back and when the data differs, and the user chooses when to synchronize. See offline mode and the two-client demo. - Per-user data: row-level security on the server, and
ChangeUserAsync()on the client, which forgets the caches and the offline copy of the previous user before anything is loaded for the next. - Change notifications from SQLite or SQL Server, relayed to clients over MagicOnion or SignalR, without echoing a client's own changes.
- Sync conventions:
[LastModified],[SoftDelete], optimistic concurrency with[ConcurrencyCheck]. - Traces and metrics through
ActivitySourceandMeter(Queryeables): remote calls, server requests, caches, changes, offline synchronizations. See observability. - Trimming and AOT analyzers clean on every library; the generator replaces reflection-based registration. Full Native AOT works for a client over MagicOnion, not over Dommel or EF Core: see Native AOT.
flowchart LR
subgraph Client
VM[IPersonRepository, caches] --> RS[RemoteEntityStore]
M[IChangeFeed] --> VM
end
subgraph Server["Server (ASP.NET Core)"]
D[EntityRequestDispatcher<br/>policy, validation] --> PS[IPersonRepository<br/>children saved]
PS --> ST[IEntityStore]
ST --> DB[(SQLite, SQL Server...)]
DB -. changes .-> H[change hub]
end
RS -- MagicOnion --> D
H -. MagicOnion or SignalR .-> M
Server: Queryeables.MagicOnion.Server.
builder.Services.UseQueryeables(q => q
.UseSqlite("Data Source=server.db")
.AddGeneratedEntities()
.AddMagicOnionServer(server => server
.Expose<Person>(EntityAccessPolicies.Authenticated)
.Expose<Contact>(EntityAccessPolicies.Authenticated)));Client: q.UseMagicOnion("https://server:5001").AddGeneratedEntities(). Keep the entities in a shared library:
AddGeneratedEntities() registers them on both sides.
var page = await stores.Query<Person>()
.Where(p => p.Name.StartsWith(search) || p.Id == id)
.OrderBy(p => p.Name).ThenByDescending(p => p.Id)
.Skip(50).Take(25)
.Select(p => new PersonRow(p.Id, p.Name))
.ToListAsync();The same query locally and remotely: filtered, sorted and paged by the database with EF Core, by the server with a remote store. See stores and transactions.
await using var store = stores.Create(); // IEntityStoreFactory, registered by the provider
EntityResult result = await store.RunInTransactionAsync(async s =>
{
await s.SaveAsync(order, o => o.Id); // insert when the key is 0, update otherwise
await s.UpdateAsync(customer);
});
if (!result.Successful)
Show(result.ErrorCode, result.Exception, result.Errors);Exceptions inside the work and failed commits become a failed EntityResult; cancellation still throws. .OrThrow() turns a
failure back into an EntityStoreException. Register an IEntityStoreFactory, never a store: a store is one connection,
disposed by its user.
Choose Queryeables for an application whose data is local today and may be shared through a server tomorrow, whose clients must see each other's changes, or that saves parent-child aggregates as one unit. Choose something else for an offline-first mobile app with large local databases (Datasync Community Toolkit), strong real-time consistency (ActualLab.Fusion), a complete application framework (ABP), or complex queries (EF Core or Dapper directly, next to Queryeables). The introduction compares them.
| Package | Contents |
|---|---|
Queryeables |
Contracts, remote core, UseQueryeables, source generator. No database, no transport |
Queryeables.Dommel |
Stores on Dommel: generated keys, optimistic concurrency, SQL logging |
Queryeables.Sqlite |
Dommel on SQLite, with change notifications; SqliteOfflineStorage for the offline copy |
Queryeables.PostgreSql |
Dommel on PostgreSQL, with change notifications |
Queryeables.MongoDB |
Stores on MongoDB, with transactions and change streams |
Queryeables.EntityFramework |
Stores on EF Core |
Queryeables.SqlDependency |
SQL Server change notifications |
Queryeables.MagicOnion |
MagicOnion messages and serializer, shared by client and server |
Queryeables.MagicOnion.Client |
UseMagicOnion, change hub subscription |
Queryeables.MagicOnion.Server |
AddMagicOnionServer, the MagicOnion service and change hub |
Queryeables.Browser |
IndexedDbOfflineStorage: the offline copy in IndexedDB, for Blazor WebAssembly |
Queryeables.SignalR, .SignalR.Server |
Change notifications over SignalR |
Queryeables.Replication |
A durable server for the Routeables Remote Protocol (offline-first replication with merge): its log and documents in any store, projected to your entities |
Queryeables.Templates |
dotnet new queryeables-console, queryeables-client-server |
- Never expose
EntityAccessPolicies.AllowAllon a public network. Configure ASP.NET Core authentication and useAuthenticated,ReadOnly, orEntityAccessPolicies.From(request => ...). - Keep connection strings and certificate passwords out of the source code.
- No SQL and no code goes over the wire: a filter is a list of conditions and computations from a fixed set, whose names the server binds to the queryable properties of the types it exposes; a filter through a relation reads the related type with its own access rules.
- Keep each user to its own rows with a row filter:
Expose<Order>(policy, rows: RowLevelSecurity.For<Order, long>(o => o.Id, user => o => o.TenantId == TenantOf(user))). Reads that are not paged stop atmaxReadSize(10,000 by default).
Every library sets IsAotCompatible and builds without trimming or AOT warnings. That is a guarantee about Queryeables'
own code, not about every provider at run time:
| Setup | Trimming | Full Native AOT |
|---|---|---|
| Remote client, caches, graphs, offline mode | Yes | Yes: proven in CI by Queryeables.AotSmoke (in-process transport) |
MagicOnion transport (UseMagicOnion) |
Yes | With the MagicOnion and MessagePack source generators and [MessagePackObject] entities; not run in CI |
Dommel / Dapper (UseSqlite, UseNpgsql, UseDommel) |
Yes | No: Dapper emits IL at run time |
EF Core (UseEntityFramework) |
Yes | No: marked as requiring dynamic code, like EF Core itself |
MongoDB (UseMongoDB) |
No | No: the driver maps by reflection; marked like EF Core |
See Native AOT.
dotnet test Queryeables.slnx
dotnet run --project Samples/Queryeables.Sample
dotnet run --project Samples/Queryeables.OfflineDemo # two clients, a network cut, a conflict
dotnet run -c Release --project Queryeables.Benchmarks -- --filter "*"
dotnet pack Queryeables.slnx -c Release -o ./artifacts -p:Version=0.1.0See CONTRIBUTING.md for the rules of the code and the release steps, and CHANGELOG.md.
Design ideas come from the Datasync Community Toolkit, ActualLab.Fusion, ABP and Breeze: see CREDITS.md. Released under the MIT License.