Skip to main content

Spring Host Architecture

Overview​

Spring has three runtime host applications plus one local-development orchestrator. The runtime hosts are thin shells that wire infrastructure, and none contains business logic. The domain project defines what the system does. The hosts define how it runs.

HostRoleReferences
Spring.RuntimeOrleans silo - runs grains, event sourcing, sagasSpring.Domain + Mississippi Runtime SDK
Spring.GatewayASP.NET API + Blazor host - serves endpoints and static filesSpring.Domain + Mississippi Gateway SDK
Spring.ClientBlazor WebAssembly - UI shell with state managementSpring.Domain (compile-only) + Mississippi Client SDK
Spring.AppHost.NET Aspire orchestration for local developmentCoordinates the runtime, gateway, storage, and emulator resources

Spring.Runtime: The Orleans Host​

The silo runs Orleans grains that execute commands, apply events, run effects, and manage saga orchestration. Its Program.cs is infrastructure wiring only.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Generated domain registrations
builder.Services.AddAuthProofAggregate();
builder.Services.AddBankAccountAggregate();
builder.Services.AddTransactionInvestigationQueueAggregate();
builder.Services.AddAuthProofProjection();
builder.Services.AddBankAccountBalanceProjection();
builder.Services.AddBankAccountLedgerProjection();
builder.Services.AddFlaggedTransactionsProjection();
builder.Services.AddMoneyTransferStatusProjection();
builder.Services.AddAuthProofSaga();
builder.Services.AddMoneyTransferSaga();

// Infrastructure: notification service stub
builder.Services.AddSingleton<INotificationService, StubNotificationService>();

// Infrastructure: telemetry and host-owned storage clients
builder.Services.AddHttpClient();
// Existing telemetry registrations are omitted from this concept excerpt.

builder.AddKeyedAzureTableServiceClient("clustering");
builder.AddKeyedAzureBlobServiceClient("grainstate");
builder.AddAzureCosmosClient(
"cosmos",
configureClientOptions: options =>
{
options.ConnectionMode = ConnectionMode.Gateway;
options.LimitToEndpoint = true;
});

// The host owns the clients used by Brooks and Snapshot storage and forwards them under keyed identities.
builder.AddKeyedAzureBlobServiceClient("blobs");
builder.Services.AddKeyedSingleton(
BrookCosmosDefaults.BlobLockingServiceKey,
(sp, _) => sp.GetRequiredKeyedService<BlobServiceClient>("blobs"));
const string sharedCosmosKey = "spring-cosmos";
builder.Services.AddKeyedSingleton(
sharedCosmosKey,
(sp, _) => sp.GetRequiredService<CosmosClient>());

// Mississippi infrastructure
builder.Services.AddInletSilo();
builder.Services.ScanProjectionAssemblies(typeof(BankAccountBalanceProjection).Assembly);
builder.Services.AddJsonSerialization();
builder.Services.AddSnapshotCaching();

// Orleans configuration
builder.UseOrleans(siloBuilder =>
{
siloBuilder.UseMississippi(runtime =>
{
runtime.AddCosmosBrookStorageProvider(cosmos =>
{
cosmos.CosmosClientServiceKey = sharedCosmosKey;
cosmos.DatabaseId = "spring-db";
cosmos.ContainerId = "events";
cosmos.QueryBatchSize = 50;
cosmos.MaxEventsPerBatch = 50;
});
// Configure Cosmos storage for snapshots
runtime.AddCosmosSnapshotStorageProvider(snapshot =>
{
snapshot.CosmosClientServiceKey = sharedCosmosKey;
snapshot.DatabaseId = "spring-db";
snapshot.ContainerId = "snapshots";
snapshot.QueryBatchSize = 100;
});
runtime.AddAqueduct(aqueduct =>
aqueduct.StreamProviderName = "StreamProvider");
runtime.AddEventSourcing(options =>
options.OrleansStreamProviderName = "StreamProvider");
runtime.ConfigureSilo(configuredSilo => configuredSilo.AddActivityPropagation());
runtime.ApplyToSilo(siloBuilder);
});
});

WebApplication app = builder.Build();
// The health endpoint mapping is omitted from this concept excerpt.
await app.RunAsync();

Spring uses generated aggregate, projection, and saga registration methods from its domain definitions. The host registers the Aspire-created Cosmos and Blob clients before the Orleans callback, then forwards them under the keyed identities expected by Brooks and Snapshot storage. runtime.AddCosmosBrookStorageProvider(...) and runtime.AddCosmosSnapshotStorageProvider(...) capture their settings and stage their graphs inside the same runtime composition. Both providers use the host-forwarded sharedCosmosKey alias, while Brooks writes to the events Cosmos container and Snapshot storage writes to the snapshots container. Snapshot storage also keeps its provider-owned keyed container alias, so the shared client does not merge the two persisted resource identities.

The runtime composition callback registers Brooks, Snapshot storage, Aqueduct, and event-sourcing settings together, and stages native Orleans configuration before terminal attachment. Aqueduct selects the StreamProvider that Spring.AppHost supplies; it does not provision a second provider. runtime.ApplyToSilo(siloBuilder) is explicit in this sample, although the runtime terminal can apply queued native callbacks automatically when the hook is omitted. See Runtime Composition, Brooks Cosmos Provider, Snapshot Cosmos Provider, and Aqueduct Reference for the attachment, storage ownership, and validation contracts.

(Spring.Runtime/Program.cs)

What the Silo Owns​

Beyond Program.cs, the silo contains a small set of non-generated support files:

  • Grains/GreeterGrain.cs - A simple demo grain (not event-sourced) that demonstrates basic Orleans communication.
  • Grains/GreeterGrainLoggerExtensions.cs - Logging extension declarations used by the greeter grain.
  • Services/StubNotificationService.cs - A stub implementation of INotificationService that logs instead of sending real notifications.
  • Services/StubNotificationServiceLoggerExtensions.cs - Logging extension declarations used by the stub notification service.

These files are infrastructure/support concerns rather than domain business logic.

(GreeterGrain.cs | StubNotificationService.cs)

Spring.Gateway: The API Host​

The gateway host serves ASP.NET controllers, the Inlet SignalR hub, and the static files for the Blazor client. It also connects to the Orleans silo as a client. Its builder.Services.AddAqueduct<InletHub>(...) call is the gateway-side hub integration; the runtime host uses the separate nested runtime.AddAqueduct(...) extension shown above.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

SpringAuthOptions springAuthOptions =
builder.Configuration.GetSection("SpringAuth").Get<SpringAuthOptions>() ?? new();
builder.Services.Configure<SpringAuthOptions>(builder.Configuration.GetSection("SpringAuth"));
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = springAuthOptions.Scheme;
options.DefaultChallengeScheme = springAuthOptions.Scheme;
})
.AddScheme<AuthenticationSchemeOptions, SpringLocalDevAuthenticationHandler>(
springAuthOptions.Scheme,
_ => { });
builder.Services.AddAuthorizationBuilder()
.AddPolicy("spring.generated-api", policy => policy.RequireAuthenticatedUser())
.AddPolicy("spring.write", policy => policy.RequireRole("banking-operator"))
.AddPolicy("spring.transfer", policy => policy.RequireRole("transfer-operator", "banking-operator"))
.AddPolicy("spring.auth-proof.claim", policy => policy.RequireClaim("spring.permission", "auth-proof"));

// Infrastructure: telemetry, Orleans client
builder.Services.AddOpenTelemetry()
.WithTracing(/* ... */)
.WithMetrics(/* ... */);
builder.AddKeyedAzureTableServiceClient("clustering");
builder.UseOrleansClient(clientBuilder =>
clientBuilder.AddActivityPropagation());

// ASP.NET and Mississippi infrastructure
builder.Services.AddControllers();
builder.Services.AddOpenApi(/* ... */);
builder.Services.AddJsonSerialization();
builder.Services.AddAggregateSupport();
builder.Services.AddUxProjections();
builder.Services.AddSignalR();
builder.Services.AddAqueduct<InletHub>(options =>
options.StreamProviderName = "StreamProvider");
if (springAuthOptions.Enabled)
{
builder.Services.AddInletServer(options =>
{
options.GeneratedApiAuthorization.Mode =
GeneratedApiAuthorizationMode.RequireAuthorizationForAllGeneratedEndpoints;
options.GeneratedApiAuthorization.DefaultPolicy = "spring.generated-api";
options.GeneratedApiAuthorization.AllowAnonymousOptOut = true;
});
}
else
{
builder.Services.AddInletServer();
}
builder.Services.ScanProjectionAssemblies(
typeof(BankAccountBalanceProjection).Assembly);

// Source-generated gateway registrations
builder.Services.AddAuthProofAggregateMappers();
builder.Services.AddBankAccountAggregateMappers();
builder.Services.AddMoneyTransferSagaAggregateMappers();
builder.Services.AddAuthProofProjectionMappers();
builder.Services.AddBankAccountBalanceProjectionMappers();
builder.Services.AddBankAccountLedgerProjectionMappers();
builder.Services.AddFlaggedTransactionsProjectionMappers();
builder.Services.AddMoneyTransferStatusProjectionMappers();

WebApplication app = builder.Build();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapOpenApi();
app.MapScalarApiReference(/* ... */);
app.MapControllers();
app.MapInletHub();
app.MapGet("/health", /* ... */);
app.MapFallbackToFile("index.html");
await app.RunAsync();

Spring.Gateway currently registers the generated aggregate and projection mapper extensions explicitly in Program.cs. Those mapper methods are source-generated from the annotations in Spring.Domain. The gateway still does not contain CommandHandler code, EventReducer code, or domain-specific business logic. It maps HTTP requests to Orleans grain calls and hosts the transport endpoints around that generated surface.

(Spring.Gateway/Program.cs)

When SpringAuth:Enabled is true, the gateway enables generated API force mode with:

  • GeneratedApiAuthorization.Mode = RequireAuthorizationForAllGeneratedEndpoints
  • GeneratedApiAuthorization.DefaultPolicy = "spring.generated-api"
  • GeneratedApiAuthorization.AllowAnonymousOptOut = true

This applies a default authenticated policy to generated HTTP APIs while preserving explicit GenerateAllowAnonymous opt-outs.

Development Auth-Proof Mode​

Spring includes an opt-in development mode that proves generated endpoint authorization behavior.

The complete setup, endpoint matrix (200/401/403), and troubleshooting guidance are documented on Spring Auth-Proof Mode.

What the Gateway Owns​

The gateway has no domain-specific code files. Its Program.cs configures middleware and infrastructure. The API controllers that accept commands and return projections are entirely source-generated from the domain annotations.

Spring.Client: The Blazor UI​

The client is a Blazor WebAssembly application that dispatches commands and subscribes to projections through the Mississippi client builder.

WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped<AuthSimulationHeadersHandler>();
builder.Services.AddScoped(sp =>
{
AuthSimulationHeadersHandler authSimulationHeadersHandler = sp.GetRequiredService<AuthSimulationHeadersHandler>();
authSimulationHeadersHandler.InnerHandler = new HttpClientHandler();
return new HttpClient(authSimulationHeadersHandler)
{
BaseAddress = new(builder.HostEnvironment.BaseAddress),
};
});

builder.UseMississippi(client =>
{
client.AddMississippiSamplesSpringDomainClient();
client.Reservoir(reservoir =>
{
// UI features
reservoir.AddDualEntitySelectionFeature();
reservoir.AddDemoAccountsFeature();
reservoir.AddAuthSimulationFeature();
reservoir.AddReservoirBlazorBuiltIns();
reservoir.AddReservoirDevTools(options =>
{
options.Enablement = ReservoirDevToolsEnablement.Always;
options.Name = "Spring Sample";
options.IsStrictStateRehydrationEnabled = true;
});

// Real-time projection updates via SignalR
reservoir.AddInletClient();
reservoir.AddInletBlazorSignalR(signalR => signalR
.WithHubPath("/hubs/inlet")
.ScanProjectionDtos(typeof(BankAccountBalanceProjectionDto).Assembly));
});
});

await builder.Build().RunAsync();

The client now starts with builder.UseMississippi(...), uses the generated AddMississippiSamplesSpringDomainClient() domain compositor on ClientBuilder, and then drops into client.Reservoir(...) for hand-written UI features plus Inlet registrations. The client still never directly calls Orleans grains or knows about event-sourcing internals.

(Spring.Client/Program.cs)

How the Client References the Domain​

The client's .csproj file uses a compile-only reference to Spring.Domain:

<ProjectReference Include="..\Spring.Domain\Spring.Domain.csproj"
ExcludeAssets="runtime" />

The ExcludeAssets="runtime" flag means the source generators can see domain types at compile time (to generate client-side DTOs and dispatchers), but the domain assembly is not deployed to the browser. The client only ships the generated code.

(Spring.Client.csproj)

Source-Generated Registration Methods​

Mississippi's generators produce builder-based client feature registrations and host-specific domain registrations from the annotations in Spring.Domain:

MethodHostWhat It Registers
AddSpringDomainSilo()RuntimeAggregate grains, saga grains, CommandHandlers, EventReducers, effects, projection grains
Add{Domain}Server()GatewayDomain-level gateway registration convenience method for generated API/controller mapper registrations
Add{Aggregate}AggregateFeature(), Add{Saga}SagaFeature(), AddProjectionsFeature()ClientReservoir-level client feature registrations for generated state, reducers, effects, and projection support
Add{Domain}Client()ClientMississippi client-builder convenience method that aggregates the generated Reservoir-level feature registrations

Gateway generators can emit a domain-level convenience method, but Spring.Gateway currently composes the generated mapper registrations explicitly in Program.cs. The client-side feature generators still target IReservoirBuilder, while the domain client generator now targets ClientBuilder and routes its work through client.Reservoir(...). Spring uses the generated domain client method for the write-side and projection slice, then adds hand-written UI and Inlet composition on the same Reservoir builder.

Spring.AppHost is separate from those generated methods. It is an Aspire entry point that provisions Azurite, Cosmos emulator resources, Orleans configuration, and project startup order for local development.

For more details on domain registration generators, see Domain Registration Generators.

The Key Insight​

Compare the domain project to the host projects:

MetricSpring.DomainSpring.RuntimeSpring.GatewaySpring.Client
Domain business logic ownershipAll domain business logicNo domain business logicNo domain business logicNo domain business logic
Business rulesAllNoneNoneNone
Infrastructure wiringNoneCompact host setup in Program.csCompact host setup in Program.csCompact host setup in Program.cs
External dependenciesPrimarily Mississippi abstractions, plus minimal framework/build dependencies (Microsoft.Orleans.Sdk, Microsoft.Extensions.Http)Azure Storage, Cosmos, Orleans, OpenTelemetryOrleans Client, ASP.NET, Blazor hostingBlazor WASM, SignalR

The hosts are replaceable shells. The domain is the permanent asset. You could swap Cosmos for PostgreSQL by changing only the runtime host's storage configuration. You could replace Blazor with React by writing a new client that calls the same generated API. The business logic in Spring.Domain would not change.

Summary​

Mississippi's source generators transform domain annotations into infrastructure wiring. Spring.Runtime stays a thin Orleans host, Spring.Gateway composes generated gateway mapper registrations around its transport infrastructure, and Spring.Client now starts with UseMississippi(...), uses the generated domain-level client method, and composes the remaining client features through client.Reservoir(...).

Next Steps​