Elarion

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

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

MyApp.Application/ElarionAssembly.cs
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.

MyApp.Application/Modules/Clients/ClientsModule.cs
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.

MyApp.Application/Data/Client.cs
namespace MyApp.Application.Data;

public sealed class Client {
    public Guid Id { get; set; }
    public required string Name { get; set; }
}
MyApp.Application/Data/ClientConfiguration.cs
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);
    }
}
MyApp.Application/Data/AppDbContext.cs
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>.

MyApp.Application/Modules/Clients/Handlers/GetClient.cs
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.

MyApp.Application/Modules/Clients/ClientsJsonContext.cs
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.

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

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

Then 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 clients except the database provider.

Where to go next

On this page