Skip to content

Configuring the server

The server is an ordinary EF Core application. Five registrations and one endpoint make it an InfoCarrier server.

using InfoCarrier.Core;
using InfoCarrier.Core.AspNetCore;

builder.Services.AddDbContext<ShopContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddScoped<DbContext>(sp => sp.GetRequiredService<ShopContext>());

builder.Services
    .AddSingleton<IInfoCarrierSerializer, SystemTextJsonInfoCarrierSerializer>()
    .AddSingleton<IInfoCarrierServer, InProcessInfoCarrierServer>()
    .AddInfoCarrierStandardValueMappers();

WebApplication app = builder.Build();

app.MapInfoCarrier();
Registration What it is for
AddDbContext<ShopContext> Your context on your real provider. InfoCarrier never sees the connection string.
AddScoped<DbContext> The server resolves the context per request as the base type, so the base type has to resolve. Without this line every request fails with a service-resolution error.
IInfoCarrierSerializer The same format the client uses. Both ends must agree.
IInfoCarrierServer InProcessInfoCarrierServer executes a request against a DbContext from this service provider. In-process means the same process as the database connection; it is the normal implementation, not a test double.
AddInfoCarrierStandardValueMappers() The mappers for BCL types the wire cannot walk, IPAddress and Uri. The client gets these automatically; a server builds its own service collection, so it has to ask. See Value mappers.

The endpoint

app.MapInfoCarrier();               // route: "infocarrier"
app.MapInfoCarrier("api/data");     // or your own

It returns an IEndpointConventionBuilder, so it takes conventions like any other endpoint:

app.MapInfoCarrier()
   .RequireAuthorization("DataAccess")
   .RequireCors("client")
   .WithName("InfoCarrier");

The client's transport must name the same route. See Configuring the client.

A malformed body, or a client speaking a different protocol version, is answered with 400 and a plain-text message naming the problem, with no stack trace and no server paths. Anything the server ran and that failed comes back as a fault inside a normal response. See Handling errors.

Payload limits

A server deserializes what an untrusted peer sent it, so this is the direction that matters. The default is 64 MiB per request. Set your own if you know what your clients legitimately send:

builder.Services.AddSingleton<IInfoCarrierSerializer>(
    _ => new SystemTextJsonInfoCarrierSerializer(
        new InfoCarrierPayloadLimits(maxRequestBytes: 8 * 1024 * 1024)));

A query tree is kilobytes, and a SaveChanges request is no bigger than the graph the client tracked, so a low limit is usually safe. Cap the request bytes at your gateway too; this limit catches whatever the gateway lets through.

Granting what the model does not imply

Two more registrations widen what a client may send. Both deny by default, and both are the server's decision alone.

builder.Services.AddInfoCarrierAllowedTypes(typeof(SqlServerDbFunctionsExtensions));
builder.Services.AddInfoCarrierArbitrarySqlExecution();

The first admits CLR types a payload may name beyond the ones your model implies. The usual reason is the EF.Functions family your provider declares, which your server can name with typeof and the client's package cannot. Register the same types on the client. Read Security before you admit anything else.

The second lets a client send FromSql and Database.SqlQuery<T>. Read the name literally: one command text runs every statement in it, and such a query does not go through OnModelCreating, so your query filters are not in it. What limits the caller is the rights of the database account your connection string uses.

Sending the server's log to the client

EF writes its warnings about a query or a save on the server, so a client never sees them. Forwarding is off until you grant it. Then they arrive in the client's own logger, under the server's category and event id:

builder.Services.AddInfoCarrierServerLogForwarding();   // warnings and above, the default

Pass a LogLevel to change it. At Information the SQL of every command the server runs goes too, which tells a client your schema.

A server whose context enables sensitive data logging forwards nothing until you also call AddInfoCarrierSensitiveServerLogForwarding(). That setting changes what EF's messages say everywhere, so no rule can pick out the ones carrying values.

Model and context events never cross. Both halves build a model, and each logs its own.

Context lifetime

InProcessInfoCarrierServer takes a fresh scope per request, so every request gets a clean change tracker. A client request is self-contained, and leftover tracked entities from a previous request would collide with the next one.

The exception is a transaction, and it is the one case where server state outlives a request. BeginTransaction pins one context and its connection until the commit or rollback, on the instance that minted the token, so a load-balanced deployment needs session affinity for the life of a transaction. Transactions has that and what an abandoned one costs.

Evicting an abandoned transaction

A client that vanishes mid-transaction leaves the server holding a context and a connection; its own DisposeAsync cannot help, because the process is gone. Tell the server how long to wait.

builder.Services.AddInfoCarrierServerTransactionTimeout(TimeSpan.FromMinutes(10));

Off until you call this, so upgrading changes nothing. Every request naming the token refreshes the clock, so the value bounds idleness rather than duration.

An eviction rolls the transaction back and logs at Warning. A later commit then fails rather than reporting success, because the work is gone. A rollback stays silent, so disposing after a commit still behaves.

Binding a transaction to its caller

The token that names a transaction is a bearer credential: whoever holds it can query and save inside that transaction, not merely end it. Tell the server who a caller is, and it refuses a token opened by somebody else.

builder.Services.AddInfoCarrierHttpCallerIdentity(http => http.User.FindFirst("sub")?.Value);

Off until you call this, and it is a second lock rather than the first. Without RequireAuthorization on the endpoint every caller is anonymous, and every anonymous caller matches.

Choose a value that stays the same for as long as a transaction lives, because a caller whose value changes mid-transaction loses its own work. You pick it: a subject id, a login name and a tenant claim behave differently under a token refresh.

Nothing changes on the wire, so an existing client needs no rebuild.

Saying the store is a document store

MongoDB, Cosmos DB and anything else whose unit of write is a whole record keep an owned type inside its owner. There is no partial write. Changing a customer's name rewrites the customer document, so an address the change set never mentioned is written out of existence: the save reports success and the next read fails on a field that is gone.

builder.Services.AddInfoCarrierServerDocumentStore();

The server then reads the stored document and puts back whatever the change set left out. Off until you call this. Do not call it for a relational store, where an owned type is columns of the owner's row or a row of its own, and neither can go missing.

The client has a switch of its own, UseNonRelationalServerStore(), which sends the whole document and saves the server that read. Set both, and rely on this one. A client can only send what its change tracker holds, so a stub it attached carries a bare root even with the switch on, and a deployment that never set the switch carries one always.

The read happens only where something could be missing. A change set that names every owned navigation the model declares is written as it stands, and an insert or a delete is never read at all.

Where the checks go

A global query filter on the server's model applies to every query by default, which is what you want for your own honest client. It is not a control. IgnoreQueryFilters() is an ordinary EF Core operator: it travels in the expression tree like any other, and the server honours it. No query filter reaches SaveChanges, so a client can submit a row whose key belongs to someone else.

So put a write check in the server's SaveChanges override or an EF interceptor, and a read check in a query interceptor, which sees the client's tree before EF translates it. Multi-tenancy works both of them through, and Security has the threat model.

Keep the filter anyway, as the default for your own client:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenant.Current);
}

Everything else you register on the server's context runs too: EF interceptors, SaveChanges overrides, auditing, soft-delete conventions. The client's request is work arriving at your context. Nothing registered on the client reaches the server, so anything a client's own model declares is a convenience.

Model parity

Both halves build a model from the same DbContext source, and the wire names entity types and properties, so deploy them together: a property the client names and the server does not know is a failed request. Enable a model-shaping option on both sides or neither. A browser client is the deliberate exception, covered on the Blazor WebAssembly page.