Quickstart
Build a module, a handler, and a working JSON-RPC endpoint with Elarion in a few minutes.
This walkthrough builds a minimal but complete Elarion application: one module, one handler exposed over JSON-RPC, and a host that wires it together. By the end you can call the handler with a JSON-RPC request over HTTP.
It assumes you have followed Installation and have two
projects: an application library (MyApp.Application) and an ASP.NET Core host (MyApp.Api).
The data-model step uses two packages beyond the base installation: the application project needs the EF Core integration/generator, and the host needs the demo-only in-memory provider.
dotnet add MyApp.Application package Elarion.EntityFrameworkCore --version 0.2.5
dotnet add MyApp.Api package Microsoft.EntityFrameworkCore.InMemory --version 10.0.9Turn on the generators
In the application project, opt in once. This enables the framework-owned application generators across the assembly; the narrower trigger list is in Source generation.
using Elarion.Abstractions;
[assembly: UseElarion]Define a module
A module is a static partial class marked with [AppModule], placed at the root of a namespace that
will contain your handlers and services. Modules are enabled by default.
using System.Text.Json.Serialization.Metadata;
using Elarion.Abstractions.Modules;
namespace MyApp.Application.Modules.Clients;
[AppModule("Clients")]
public static partial class ClientsModule {
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() =>
ClientsJsonContext.Default;
}That is the whole module declaration. You don't register its handlers or services by hand —
under [GenerateModuleBootstrapper] (added below) the generators emit a ConfigureDefaultServices that
the host bootstrapper calls for you, gated by Modules:Clients:Enabled. The optional ConfigureServices
hook is reserved for additional, non-generated wiring (options binding, third-party libraries):
[AppModule("Clients")]
public static partial class ClientsModule {
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() => ClientsJsonContext.Default;
// Optional hook — only for wiring the generators can't emit (options, third-party services):
public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) {
services.Configure<ClientsOptions>(configuration.GetSection("Clients"));
}
}See Conventions.
Define the data model
Handlers access data through the concrete AppDbContext directly — there is no repository layer and no
context interface. Write an IEntityTypeConfiguration<T> for the entity and mark it with
[EntityConfiguration], annotate the concrete partial AppDbContext with [GenerateDbSets], and let the
EF Core generator emit the DbSets. The entity itself is a plain
class — [EntityConfiguration] on its configuration is the single source of truth that drives both the
generated DbSet<Client> and the Configure(...) application.
namespace MyApp.Application.Data;
public sealed class Client {
public Guid Id { get; set; }
public required string Name { get; set; }
}using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MyApp.Application.Data;
[EntityConfiguration]
public sealed class ClientConfiguration : IEntityTypeConfiguration<Client> {
public void Configure(EntityTypeBuilder<Client> builder) {
builder.HasKey(c => c.Id);
}
}using Microsoft.EntityFrameworkCore;
using Elarion.EntityFrameworkCore;
namespace MyApp.Application;
[GenerateDbSets]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
ConfigureEntities(modelBuilder); // generated
}
}This step needs Elarion.EntityFrameworkCore (which bundles the EF Core source generator) in any
assembly that declares [EntityConfiguration]/[GenerateDbSets] types, and an EF
Core provider — the quickstart uses Microsoft.EntityFrameworkCore.InMemory. In a real application
the concrete AppDbContext lives in the application's persistence layer (the database is application
logic, not an infrastructure detail); the quickstart keeps everything in one project for brevity.
Write a handler
Handlers are the unit of work. This one is exposed over JSON-RPC with [Handler], injects
AppDbContext, and returns a Result<T>.
using Elarion.Abstractions;
using Microsoft.EntityFrameworkCore;
using MyApp.Application;
namespace MyApp.Application.Modules.Clients.Handlers;
[Handler("clients.get")]
public sealed class GetClient(AppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> {
public sealed record Query(Guid Id);
public sealed record Response(Guid Id, string Name);
public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) {
var client = await db.Clients
.Where(c => c.Id == query.Id)
.Select(c => new Response(c.Id, c.Name))
.FirstOrDefaultAsync(ct);
return client is null
? AppError.NotFound($"Client {query.Id} was not found.")
: client;
}
}The handler generator reads the request and response straight from the
IHandler<TRequest, Result<TResponse>> interface — here the nested Query and Response types, though
they could equally be top-level; nesting carries no semantic weight. The handler queries the generated
db.Clients DbSet directly with EF Core async LINQ.
Add a JSON context
Each module contributes source-generated JSON metadata for its request/response types. This keeps serialization AOT-friendly and scoped to the module.
using System.Text.Json.Serialization;
using MyApp.Application.Modules.Clients.Handlers;
namespace MyApp.Application.Modules.Clients;
[JsonSerializable(typeof(GetClient.Query))]
[JsonSerializable(typeof(GetClient.Response))]
public sealed partial class ClientsJsonContext : JsonSerializerContext;Add the host bootstrapper
In the host, one small partial class tells the generator to emit the cross-module wiring — including the
gated handler registration, so disabling a module also removes its [Handler] operations.
using Elarion.AspNetCore;
[assembly: GenerateModuleBootstrapper]Wire the host
The host composes modules, configures serialization, and publishes the JSON-RPC endpoint. The host
owns infrastructure (here, the database provider); modules own application logic. The concrete
AppDbContext is registered once and injected into handlers directly.
using Elarion.AspNetCore;
using Microsoft.EntityFrameworkCore;
using MyApp.Api; // the generated ElarionBootstrapper lives in the host's root namespace
using MyApp.Application;
using MyApp.Application.Data;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("quickstart"));
// Registers modules and contributes every enabled module's source-generated JSON context to the
// canonical serializer every subsystem reads — no hand-built JsonSerializerOptions needed.
builder.Services.AddElarion(builder.Configuration);
// Gated dispatcher: only enabled modules' [Handler] operations are registered.
builder.Services.AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers);
var app = builder.Build();
// Seed one client so the call below returns data.
using (var scope = app.Services.CreateScope()) {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Clients.Add(new Client {
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
Name = "Acme Inc.",
});
db.SaveChanges();
}
app.MapElarionEndpoints(app.Configuration);
app.MapElarionJsonRpc();
app.Run();UseInMemoryDatabase is a demo shortcut so this quickstart runs without Docker — it is not a real
database, and its behavior diverges from one. A real application uses PostgreSQL (UseNpgsql). Do not
test database behavior on it either: a green InMemory test is false confidence (it skips constraints, real
transactions, and provider SQL). Test against a real database
with Testcontainers instead.
Build and call it
dotnet run --project MyApp.ApiThen send a JSON-RPC request:
curl -s http://localhost:5000/rpc \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "clients.get",
"params": { "id": "00000000-0000-0000-0000-000000000001" }
}'A registered client returns a result envelope; a missing one returns the JSON-RPC error your host
mapped from AppError.NotFound.
What you just built
- A module that publishes its own handlers and services with no host-side registration list.
- A handler that is simultaneously a use case, a DI registration, and a JSON-RPC method.
- A host that composes modules and maps the transport — and owns nothing about
clientsexcept the database provider.
Where to go next
Project structure
The conventions generators rely on, and how to lay out a real application.
Core concepts
Handlers, results, modules, services, validation, and pipelines in depth.
TypeScript client
Export a schema and generate a typed frontend client for clients.get.
Scheduled jobs
Add recurring and one-off background work to your module.