Observe a DbSet
Once ordinary EF reads work, observation can refresh a result when the underlying table changes. Arc combines in-process SaveChanges interception with optional provider notifications. SQLite does not poll external writes.
Configure the host
Section titled “Configure the host”WithEntityFrameworkCore() registers observation services. For explicit registration in an existing Arc host, import Cratis.Arc.EntityFrameworkCore, Cratis.Arc.EntityFrameworkCore.Observe, Microsoft.EntityFrameworkCore, and Microsoft.Extensions.DependencyInjection, then use this startup fragment:
services.AddEntityFrameworkCoreObservation();services.AddDbContextWithConnectionString<OrdersDbContext>("Data Source=orders.db");Arc’s context helpers add the observation interceptor when those services are available. For ordinary EF registration, add .AddObservation(serviceProvider) in the two-argument options callback. The context must also be resolvable from a fresh scope: observation resolves its concrete type for initial and subsequent queries.
Keep the normal Arc registration and activation. Observe() accesses Arc’s initialized service provider and current query context; adding EF services alone is not a complete observation host.
Observe results
Section titled “Observe results”These are query-method fragments using your application’s context/entities. Here Customer.Id and customerId use the application’s CustomerId : ConceptAs<Guid> domain identity. Import Microsoft.EntityFrameworkCore and System.Reactive.Linq.
var orders = dbContext.Orders.Observe(order => order.IsPending);var customer = dbContext.Customers.ObserveSingle(customer => customer.Email == email);var byId = dbContext.Customers.ObserveById<Customer, CustomerId>(customerId);Observe() returns ISubject<IEnumerable<TEntity>>. ObserveSingle() and ObserveById() return ISubject<TEntity>. When no entity matches, they emit null and keep the subscription open: initially (a late subscriber replays that null), after a delete, and after an update moves the entity out of the filter. This matches MongoDB’s single-document observation. Treat null as “not found” on the client. Observation supports EF Core indexer primary keys on named shared-type sets such as dbContext.Set<Dictionary<string, object?>>("PropertyBag"). ObserveById() filters on a mapped public CLR Id property when one exists; otherwise it falls back to a single, non-shadow EF primary key. Collection and single-entity observation use that primary key for identity, falling back to a mapped public CLR Id for composite, keyless, or shadow-key entities. Shadow-only keys without a usable CLR Id fail at observation setup.
To customize loading, use the second callback:
var orders = dbContext.Orders.Observe( order => order.IsPending, configure: set => set.Include(order => order.Lines));Arc applies the filter and current query-context paging/sorting to the configured query, including sorting by mapped indexer properties. Initial querying is synchronous and can throw before a subject is returned. Later re-query errors are logged, not sent as OnError to subscribers.
Compose a primary observation with auxiliary sources
Section titled “Compose a primary observation with auxiliary sources”When a query combines observations from several DbSets, client paging and sorting belong to the primary source. Existing composed queries must opt in on each auxiliary observation to get this behavior; otherwise those observations still use the client query context. Pass ignoreQueryContext: true to an auxiliary observation to read all entities matching its filter without client paging or sorting. This also prevents that source from writing the query’s TotalItems, both at startup and on changes:
var primary = dbContext.Orders.Observe();var auxiliary = dbContext.Customers.Observe(ignoreQueryContext: true);var selected = dbContext.Customers.ObserveSingle(customer => customer.Email == email, ignoreQueryContext: true);var byId = dbContext.Customers.ObserveById<Customer, CustomerId>(customerId, ignoreQueryContext: true);These query-method fragments assume the dbContext, email, and customerId from the examples above. The option is available on filtered and filterless Observe and ObserveSingle calls, as well as ObserveById; the optional configure callback still works. The primary observation keeps its usual paging, sorting, and TotalItems behavior; you decide which source is primary. TotalItems describes the primary DbSet’s filtered count, not the size of an arbitrary result you derive by combining sources. If your composed result has different pagination semantics, compute its count explicitly rather than treating a DbSet’s count as the composed count.
Provider capabilities
Section titled “Provider capabilities”| Provider | In-process writes | External writes and prerequisites |
|---|---|---|
| SQLite | SaveChanges through contexts with Arc’s observation interceptor | No external notifier; StartListening is a no-op. No polling fallback. |
| PostgreSQL | Same interception path | LISTEN/NOTIFY; Arc attempts to create a function and table trigger. The database user needs suitable permissions, or an administrator must supply a compatible trigger/channel. |
| SQL Server | Same interception path | SqlDependency with Service Broker enabled and query-notification permissions/compatible queries. Arc attempts to enable Service Broker. No general polling fallback. |
A PostgreSQL listener can start even when trigger creation fails. That alone does not establish that external changes are observable. Notifier setup failures are logged and observation can continue with in-process notifications only. Monitor logs and verify an external write in a test database before relying on cross-process updates.
Notifications cause a query refresh; they are not a durable change log. Do not assume every intermediate state is delivered, that raw/bulk SQL passes through SaveChanges interception, or that changes to every included relationship independently trigger the root-table observation.
Lifetime and disposal
Section titled “Lifetime and disposal”Arc’s streaming transports dispose their observer subscriptions when clients disconnect; they do not complete the EF subject. EF producer cleanup is tied to subject completion, so returning a raw EF subject does not give its producer a disconnect-driven lifetime. An HTTP snapshot, including waitForFirstResult=true, does not close this ownership gap.
Before exposing EF observation through an observable query, establish an application-owned producer lifetime and test disconnects, concurrent clients, and shutdown. Do not complete a shared subject just because one client leaves: that would stop other clients too.
For a subject you own directly, retain both the subject and subscription and complete the subject when finished. Disposing the Rx subscription alone does not stop this EF observation implementation.
Lifecycle fragment inside an asynchronous method:
var subject = dbContext.Orders.Observe(order => order.IsPending);using var subscription = subject.Subscribe(orders => Console.WriteLine(orders.Count()));try{ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);}finally{ subject.OnCompleted();}Completion initiates notifier/interceptor cleanup; it is not an awaitable guarantee that notifier disposal has finished. Test cleanup against concurrent notifications with your provider. This lifecycle differs from MongoDB collection observation, whose subject tracks subscriber disposal.
Scope and isolation
Section titled “Scope and isolation”Observation recreates scopes for reads. Arc’s pooled EF registrations keep the configured connection string; they do not infer per-tenant databases from the current tenant. Validate your application’s tenant strategy across initial reads, notification callbacks, and pooled reuse before using observation in a multi-tenant application.
Continue with read-only contexts and registration.