Host the API
Compose the modules in an ASP.NET Core host, publish a JSON-RPC endpoint, expose the same handlers as MCP tools, and wire OpenTelemetry.
The application project declared what the app does. The host wires how it runs: the database provider, the scheduler and resilience runtimes, caching, authentication, the JSON-RPC and MCP endpoints, and telemetry. None of this leaks back into the modules.
Add the generated host partial
One partial class tells the generator to emit the cross-module wiring in the host assembly.
using Elarion.AspNetCore;
[assembly: GenerateModuleBootstrapper]ElarionBootstrapper gains AddElarion, MapElarionEndpoints, RegisterHandlers (the single
transport-neutral registry feeding both JSON-RPC and MCP), GetMcpMetadata (MCP), and
GetAllJsonTypeInfoResolvers. Every transport is feature-flag-gated per module, so a disabled
module disappears from all of them at once.
The project layout is your choice: the bootstrapper discovers handlers both from referenced module assemblies and from the host compilation itself, so a single-project app wires identically. This tutorial uses the multi-project split because it is the shape that grows well.
Install the remaining host packages
The host adds authentication and OpenTelemetry on top of the Elarion packages from the overview:
cd src/Billing.Api
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
cd ../..Handler caching and resilience are opt-in packages — Elarion.Caching ([Cacheable]/[CacheInvalidate])
and Elarion.Resilience ([Resilient] and deferred scheduler retries). Since ADR-0017 the Elarion core
no longer ships these runtimes, so reference each package only when you use it; the
installation page lists them.
Register the services
Compose the host. Each block is a platform capability the modules consume through an abstraction.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Billing.Api; // the generated ElarionBootstrapper lives in the host's root namespace
using Billing.Application;
using Billing.Application.Modules.Invoicing.Services;
using Billing.Application.Persistence;
using Billing.Infrastructure.Email;
using Elarion.Abstractions.Diagnostics;
using Elarion.Abstractions.Messaging;
using Elarion.Diagnostics;
using Elarion.Abstractions.Scheduling;
using Elarion.AspNetCore;
using Elarion.AspNetCore.Identity;
using Elarion.AspNetCore.Mcp;
using Elarion.Auditing.EntityFrameworkCore;
using Elarion.Caching;
using Elarion.Messaging.Outbox;
using Elarion.JsonRpc;
using Elarion.Resilience;
using Elarion.Scheduling;
using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateSlimBuilder(args);
// Clock — used by handlers, jobs, and the current-user snapshot.
builder.Services.AddSingleton(TimeProvider.System);
// Database: the context lives in the application's persistence layer (the database is application logic);
// handlers inject the concrete BillingDbContext. Provider registration (UseNpgsql + connection) is the host's job.
builder.Services.AddDbContext<BillingDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("billing")));
builder.Services.AddScoped<DbContext>(sp => sp.GetRequiredService<BillingDbContext>());
// Integration events: durable, after-commit delivery via the EF Core outbox on the billing context.
builder.Services.AddElarionOutbox<BillingDbContext>();
// Framework audit trail (ADR-0045): the durable EF sink over the billing context, so [Auditable] handlers
// record one compliance AuditRecord per invocation (committed with the transaction; denials included).
builder.Services.AddElarionAuditingEntityFrameworkCore<BillingDbContext>();
// Infrastructure capability: the concrete email sender behind the module's port. (The account-standing
// policy is a Core [ModuleContract] with a Core-internal [Service] impl — so it self-registers.)
builder.Services.AddScoped<IInvoiceEmailSender, SmtpInvoiceEmailSender>();
// Scheduler runtime. Job descriptors and event consumers are composed per module by the
// services.AddElarion(...) call below — there is no explicit Add…ScheduledJobs call.
builder.Services.AddElarionScheduler(builder.Configuration);
// Resilience: generated policy metadata + the Microsoft/Polly-backed runtime. The generated method
// name is Add{AssemblyName}ResiliencePolicies with dots replaced by underscores.
builder.Services.AddBilling_ApplicationResiliencePolicies();
builder.Services.AddElarionResilience();
// Per-user handler caching, backed by HybridCache.
builder.Services.AddElarionHandlerCaching();
// Request validation: enforces the DataAnnotations on request DTOs through the generated
// per-module resolvers (the same constraints the schemas and the Zod client export).
builder.Services.AddElarionValidation();
// Transport-neutral current user, filled from the authenticated principal.
builder.Services.AddElarionCurrentUser(options => options.UserIdClaimType = "sub");
// Authentication: a JWT bearer issuer of your choice (Entra, Auth0, Keycloak, …).
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer(options => {
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
});
builder.Services.AddAuthorization();
// Compose every module's services — handlers, services, validation metadata, scheduled jobs, and event
// consumers — each gated by Modules:{Name}:Enabled. This also contributes every enabled module's
// source-generated JSON context to the canonical serializer every subsystem reads.
builder.Services.AddElarion(builder.Configuration);
// JSON-RPC: methods gated per module. Serialization comes from the canonical IElarionJsonSerialization
// (customize it via ConfigureElarionJson) — no hand-built JsonSerializerOptions is threaded in.
builder.Services.AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers);
// MCP: a thin adapter over the same handler bus, equally gated; both surfaces share one registry.
builder.Services.AddElarionMcp(
builder.Configuration.GetMcpMetadata(),
ElarionBootstrapper.RegisterHandlers,
o => o.ServerName = "Billing");
// Telemetry: register the Elarion sources/meters; the host owns the exporters.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t
.AddSource(
JsonRpcTelemetry.ActivitySourceName,
SchedulerTelemetry.ActivitySourceName,
HandlerCacheTelemetry.ActivitySourceName,
ResilienceTelemetry.ActivitySourceName,
HandlerTelemetry.ActivitySourceName,
EventTelemetry.ActivitySourceName)
.AddAspNetCoreInstrumentation()
.AddOtlpExporter())
.WithMetrics(m => m
.AddMeter(
JsonRpcTelemetry.MeterName,
SchedulerTelemetry.MeterName,
HandlerCacheTelemetry.MeterName,
ResilienceTelemetry.MeterName,
HandlerTelemetry.MeterName,
EventTelemetry.MeterName)
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());Build, order the middleware, and map endpoints
UseElarionCurrentUser() must run after authentication so the principal exists, and before the
endpoints so handlers can read it.
var app = builder.Build();
// Apply pending migrations on startup, against the Aspire-provisioned database.
using (var scope = app.Services.CreateScope()) {
var db = scope.ServiceProvider.GetRequiredService<BillingDbContext>();
await db.Database.MigrateAsync();
}
app.UseAuthentication();
app.UseElarionCurrentUser(); // snapshot claims into the scoped ICurrentUser
app.UseAuthorization();
app.MapElarionEndpoints(app.Configuration);
app.MapElarionJsonRpc().RequireAuthorization(); // POST /rpc
app.MapElarionMcp().RequireAuthorization(); // /mcp — independent of /rpc
app.Run();MapElarionEndpoints publishes every handler that carries [HttpEndpoint] as a minimal-API REST
route — the same handler, pipeline, and Result mapping as JSON-RPC, just a different projection.
None of the Billing handlers opt in so far; making one RESTful is a single attribute:
[HttpEndpoint("clients/{id}")] // verb inferred from the IQuery marker → GET /clients/{id}
[Handler("clients.get")] // …and still exposed over JSON-RPC/MCP — one handler, two transports
public sealed class GetClient(BillingDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> { /* … */ }See HTTP endpoints for routing, binding, and verb rules.
The JSON-RPC dispatcher, the MCP server, and schema export all read the one canonical
IElarionJsonSerialization — so the generated TypeScript types match exactly what the server serializes.
AddElarion(builder.Configuration) contributes every module's source-generated context automatically; use
ConfigureElarionJson only to change naming or add an extra resolver.
JSON-RPC and MCP are thin adapters over one handler bus, so you could expose MCP without ever calling
MapElarionJsonRpc().
Add the scheduler section to configuration so the in-memory runtime is enabled:
{
"Scheduler": {
"Enabled": true,
"MaxConcurrentExecutions": 8
}
}Export the schema on every build
Wire the build-time schema exporter so rpc-schema.json is written
to the repo root whenever the host compiles. The frontend client is generated from it.
<ItemGroup>
<PackageReference Include="Elarion.AspNetCore.SchemaGeneration" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<ElarionJsonRpcGenerateSchema>true</ElarionJsonRpcGenerateSchema>
<ElarionJsonRpcSchemaOutputPath>$(MSBuildProjectDirectory)/../../rpc-schema.json</ElarionJsonRpcSchemaOutputPath>
</PropertyGroup>The target launches the host up to builder.Build(), reads the frozen dispatcher, and writes the
schema. Code after builder.Build() does not run during generation, so there is nothing to guard here.
Run it
Run the Aspire app host — it starts PostgreSQL, applies migrations, and launches the API, then opens the Aspire dashboard with traces and metrics:
dotnet run --project src/Billing.AppHostWith a valid bearer token, create a client over JSON-RPC (use the API URL the dashboard shows):
curl -s http://localhost:5000/rpc \
-H 'authorization: Bearer <token>' \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "clients.create",
"params": { "name": "Acme Inc.", "email": "billing@acme.test" }
}'A success returns { "id": "…", "number": "C-000001" }; a duplicate email returns the JSON-RPC error
your host mapped from AppError.Conflict. The MCP server is live at /mcp for any MCP client, exposing
clients.create, invoices.create, and the rest as tools.
For local runs without a real issuer, stamp a development principal before UseElarionCurrentUser()
(Development only) so ICurrentUser resolves and RequireAuthorization() passes — exactly what the
runnable samples/Billing app
does. In production the JWT bearer registration above takes over.
What the host owns — and what it doesn't
The host owns the database provider, authentication, middleware order, the scheduler/resilience/cache runtimes, telemetry exporters, and endpoint publication. It owns nothing about clients or invoices beyond registering one email capability. Swap PostgreSQL for SQL Server, or the OTLP exporter for Prometheus, without touching a single module.
Background work
Build the Invoicing module — send invoice emails through a resilient retrying job, observe its status, and chase overdue invoices on a nightly cron.
Build the frontend
Generate a typed client from the schema and call the Billing handlers from a React app with TanStack Query, AbortSignal cancellation, and shadcn/ui.