Elarion
Getting Started

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

Turn on the generators

In the application project, opt in once. This enables generation for module handlers, services, validators, and scheduled jobs across the assembly.

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;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace MyApp.Application.Modules.Clients;

[AppModule("Clients")]
public static partial class ClientsModule {
    public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) {
        services.AddClientsHandlers();   // generated
    }

    public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() =>
        ClientsJsonContext.Default;
}

AddClientsHandlers() does not exist yet — the generator emits it from the handlers you add under the Clients namespace. It appears after your next build. (When the module has [Service] classes, also call the generated AddClientsServices().)

Define the data model

Handlers access data through a generated IAppDbContext interface — there is no repository layer. Mark an entity with [DbEntity], declare the context interface with [GenerateDbSets], and let the EF Core generator emit the DbSets.

MyApp.Application/Data/Client.cs
using Elarion.EntityFrameworkCore;

namespace MyApp.Application.Data;

[DbEntity]
public sealed class Client {
    public Guid Id { get; set; }
    public required string Name { get; set; }
}
MyApp.Application/Data/IAppDbContext.cs
using Microsoft.EntityFrameworkCore;
using Elarion.EntityFrameworkCore;

namespace MyApp.Application;

[GenerateDbSets]
public partial interface IAppDbContext {
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
    DbContext AsDbContext();
}
MyApp.Application/Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace MyApp.Application;

public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options), IAppDbContext {
    public DbContext AsDbContext() => this;

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        base.OnModelCreating(modelBuilder);
        ConfigureEntities(modelBuilder);   // generated
    }
}

This step needs Elarion.EntityFrameworkCore, the Elarion.EntityFrameworkCore.Generators analyzer, and an EF Core provider — the quickstart uses Microsoft.EntityFrameworkCore.InMemory. In a real application the concrete AppDbContext lives in an infrastructure project; for the quickstart we keep it in the application project.

Write a handler

Handlers are the unit of work. This one is exposed over JSON-RPC with [RpcMethod], injects IAppDbContext, 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;

[RpcMethod("clients.get")]
public sealed class GetClient(IAppDbContext 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 RPC generator requires the conventional shape: a nested Query (or Command) request type, a nested Response type, and an IHandler<TRequest, Result<TResponse>> implementation. 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 JSON-RPC registration, so disabling a module also removes its [RpcMethod] methods.

MyApp.Api/Hosting/ModuleBootstrapper.cs
using Elarion.AspNetCore;

namespace MyApp.Api.Hosting;

[GenerateModuleBootstrapper]
public static partial class ModuleBootstrapper;

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 exposed to handlers through IAppDbContext.

MyApp.Api/Program.cs
using Microsoft.EntityFrameworkCore;
using MyApp.Api.Hosting;
using MyApp.Application;
using MyApp.Application.Data;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("quickstart"));
builder.Services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());

ModuleBootstrapper.ConfigureAllServices(builder.Services, builder.Configuration);

var resolvers = ModuleBootstrapper.GetAllJsonTypeInfoResolvers(builder.Configuration);
var serializerOptions = new JsonSerializerOptions {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    PropertyNameCaseInsensitive = true,
    TypeInfoResolver = JsonTypeInfoResolver.Combine(
        [JsonRpcJsonContext.Default, .. resolvers, new DefaultJsonTypeInfoResolver()]),
};

builder.Services.AddSingleton(serializerOptions);
// Gated dispatcher: only enabled modules' [RpcMethod] handlers are registered.
builder.Services.AddJsonRpc(serializerOptions, ModuleBootstrapper.RegisterRpcMethods);

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();
}

ModuleBootstrapper.MapAllEndpoints(app, app.Configuration);
app.MapJsonRpc();

app.Run();

UseInMemoryDatabase is for demos and tests only — it is not a real database. Swap in a persistent provider such as Npgsql, SQL Server, or SQLite for an actual application.

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