Elarion

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.

src/Billing.Api/ElarionAssembly.cs
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.

src/Billing.Api/Program.cs
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.

src/Billing.Api/Program.cs (continued)
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:

src/Billing.Api/appsettings.json
{
  "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.

src/Billing.Api/Billing.Api.csproj
<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.AppHost

With 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.

On this page