Elarion

Write the features

Build the Clients module end to end — a decorator pipeline, current-user scoping, handlers, declarative validation, results, and per-user caching.

The Clients module is where most of Elarion's cross-cutting machinery earns its keep. You will set up a decorator pipeline once, then write handlers that get validation, transactions, logging, current-user scoping, and caching for free — declared next to the code, generated into the wiring.

This page builds Clients in full. The Invoicing module follows the same patterns and is built in Background work.

Define the decorator pipeline

Elarion auto-attaches its framework gates — authorization, feature gating, and request validation — from the handler's own attributes; you never list those. What you define is the app-specific cross-cutting behavior that depends on your logging and your DbContext, exposed as a pipeline with [DecoratorList]. A decorator can declare where it attaches — by a generic constraint (where TRequest : ICommand) or a static bool AppliesTo(HandlerMetadata handler) predicate the generator evaluates per handler — so one pipeline serves commands, queries, and event handlers alike.

src/Billing.Application/Decorators/Decorators.cs
using System;
using Elarion.Abstractions;
using Elarion.Abstractions.Messaging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Billing.Application.Decorators;

public sealed class LoggingDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    ILogger<LoggingDecorator<TRequest, TResponse>> logger
) : IHandler<TRequest, TResponse> {
    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        logger.LogInformation("Handling {Request}", typeof(TRequest).DeclaringType?.Name ?? typeof(TRequest).Name);
        return await inner.HandleAsync(request, ct);
    }
}

public sealed class TransactionDecorator<TRequest, TResponse>(
    IHandler<TRequest, TResponse> inner,
    DbContext db
) : IHandler<TRequest, TResponse> {
    // Attach only where a new unit of work is needed — commands and integration-event handlers. The
    // generator calls this once per handler type, so queries and domain-event handlers never get it.
    public static bool AppliesTo(HandlerMetadata handler) =>
        handler.RequestType.IsAssignableTo(typeof(ICommand)) ||
        handler.RequestType.IsAssignableTo(typeof(IIntegrationEvent));

    public async ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct) {
        await using var transaction = await db.Database.BeginTransactionAsync(ct);
        var response = await inner.HandleAsync(request, ct);

        if (response is IResultLike { IsSuccess: true }) {
            await transaction.CommitAsync(ct);
        } else {
            await transaction.RollbackAsync(ct);
        }

        return response;
    }
}

Now expose one pipeline for the whole application. TransactionDecorator declares a static bool AppliesTo predicate, so the generator attaches it at compile time only to commands and integration-event handlers — queries and domain-event handlers skip it automatically. A single pipeline is correct everywhere, with no second "read-only" pipeline to define and no per-handler tag.

src/Billing.Application/Pipeline/Pipelines.cs
using Billing.Application.Decorators;
using Elarion.Abstractions.Pipeline;

namespace Billing.Application.Pipeline;

[DecoratorList(
    typeof(LoggingDecorator<,>),
    typeof(TransactionDecorator<,>))]
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class)]
public sealed class DefaultPipelineAttribute : Attribute;

Decorators run outermost first, in list order. Logging wraps everything; the transaction wraps only the handler — and only on commands and integration-event handlers, since its AppliesTo predicate keeps the generator from attaching it to queries or domain-event handlers. The framework's validation gate runs before this whole list, so a bad request is rejected without ever opening a transaction. Apply the pipeline across the whole assembly:

src/Billing.Application/ElarionAssembly.cs
using Billing.Application.Pipeline;
using Elarion.Abstractions;

[assembly: DefaultPipeline]
[assembly: UseElarion]

Add a cross-module capability ([ModuleContract])

Invoicing needs a decision that isn't its own to make: may this customer be invoiced right now, or would a new invoice breach their credit limit? That is a domain policy the foundation Core module owns. A module never reaches into another module's internals (ADR-0002); it depends on a published contract. So Core publishes the policy as a [ModuleContract] and keeps the implementation internal:

src/Billing.Application/Modules/Core/Contracts/IAccountStanding.cs
using Elarion.Abstractions;
using Elarion.Abstractions.Modules;

namespace Billing.Application.Modules.Core.Contracts;

[ModuleContract]
public interface IAccountStanding {
    ValueTask<Result> EnsureCanInvoiceAsync(Guid clientId, long amountCents, CancellationToken ct = default);
}
src/Billing.Application/Modules/Core/Services/AccountStanding.cs
using Billing.Application.Domain;
using Billing.Application.Modules.Core.Contracts;
using Billing.Application.Persistence;
using Elarion.Abstractions;
using Microsoft.EntityFrameworkCore;

namespace Billing.Application.Modules.Core.Services;

[Service(typeof(IAccountStanding))]
internal sealed class AccountStanding(BillingDbContext db) : IAccountStanding {
    private const long CreditLimitCents = 1_000_000;   // €10,000 per customer, for the sample

    public async ValueTask<Result> EnsureCanInvoiceAsync(Guid clientId, long amountCents, CancellationToken ct = default) {
        var outstanding = await db.Invoices
            .Where(i => i.ClientId == clientId && (i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue))
            .SumAsync(i => i.AmountCents, ct);

        return outstanding + amountCents > CreditLimitCents
            ? AppError.BusinessRule("Credit limit exceeded.")
            : Result.Success();
    }
}

[Service(typeof(IAccountStanding))] registers the internal impl against the contract in Core's generated ConfigureDefaultServices — no host wiring. Invoicing injects IAccountStanding (a contract, so ELMOD002 allows the cross-module dependency) and consults it before raising an invoice — you'll see that in CreateInvoice. The rule lives in Core, written and tested once, rather than being reimplemented in each module that invoices.

A [ModuleContract] is for a genuine, bespoke domain call between modules. It is not a platform port (intent-only infrastructure like sending email lives outside the modules, with an adapter in Billing.Infrastructure), and it is not for cross-cutting concerns the framework already solves — auditing ([Auditable], next), validation, and authorization all attach in the pipeline with no contract to publish.

Record who did what (the framework audit trail)

Compliance usually needs a durable record of who performed which action, with what outcome. Don't hand-roll it — tag a handler [Auditable] and the framework audit trail writes one structured record per invocation, committed atomically with the handler's transaction (a rolled-back command leaves no misleading "success" row) and capturing denied and failed attempts too.

Turn it on with one host line — the durable EF sink over the same BillingDbContext (the elarion_audit_log table is mapped beside the outbox):

src/Billing.Api/Program.cs (excerpt)
builder.Services.AddElarionAuditingEntityFrameworkCore<BillingDbContext>();
src/Billing.Application/Persistence/BillingDbContext.cs (excerpt)
modelBuilder.UseElarionAuditing();   // maps the elarion_audit_log table

A handler opts in with [Auditable] (you'll see it on CreateClient next). An entity opts into automatic field-level change capture with [Audited], excluding sensitive columns with [AuditIgnore] — nothing on your entities is needed for the record itself; [Audited] only adds the old → new diffs:

src/Billing.Application/Domain/Client.cs (excerpt)
[Audited]
public sealed class Client {
    public Guid Id { get; set; }
    // …
    [AuditIgnore]   // PII kept out of the audit trail's change capture
    public required string Email { get; set; }
}

See Audit trail for the full model — including the distinction from an app-owned activity log, when history is a queryable feature rather than a compliance record.

Add a module-owned service

Clients get a human-friendly number like C-000123. A [Service] keeps that policy in one place.

src/Billing.Application/Modules/Clients/Services/ClientNumberGenerator.cs
using Billing.Application.Persistence;
using Elarion.Abstractions;
using Microsoft.EntityFrameworkCore;

namespace Billing.Application.Modules.Clients.Services;

public interface IClientNumberGenerator {
    ValueTask<string> NextAsync(string ownerId, CancellationToken ct);
}

[Service(typeof(IClientNumberGenerator))]
public sealed class ClientNumberGenerator(BillingDbContext db) : IClientNumberGenerator {
    public async ValueTask<string> NextAsync(string ownerId, CancellationToken ct) {
        var count = await db.Clients.CountAsync(c => c.OwnerId == ownerId, ct);
        return $"C-{count + 1:D6}";
    }
}

Write the command handler

CreateClient is a state change, so its request is a nested Command. It runs through the pipeline (the framework's validation gate, then authorization, then logging → transaction), scopes the row to the current user, invalidates the clients cache on success, and is exposed over JSON-RPC. [RequirePermission("clients", "create")] enforces the clients.create permission claim in the handler pipeline — transport-neutral, so the same gate protects the JSON-RPC and MCP surfaces alike (Authorization). [Auditable] records the compliance audit trail automatically (audit.SetResource just pins which client was acted on, using the same clients resource vocabulary). [Handler] names the operation; the name is optional, and when omitted Elarion infers {module}.{operation} by convention (here clients.createClient from module Clients and the handler type name minus its Handler/Command/Query/Request suffix, camelCased). An explicit name like "clients.create" is recommended for stable public/wire contracts. The [Description] attributes flow straight through to the MCP tool surface.

src/Billing.Application/Modules/Clients/Handlers/CreateClient.cs
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Billing.Application.Domain;
using Billing.Application.Modules.Clients.Services;
using Billing.Application.Persistence;
using Elarion.Abstractions;
using Elarion.Abstractions.Auditing;
using Elarion.Abstractions.Authorization;
using Elarion.Abstractions.Caching;
using Elarion.Abstractions.Identity;
using Microsoft.EntityFrameworkCore;

namespace Billing.Application.Modules.Clients.Handlers;

[Handler("clients.create")]
[RequirePermission("clients", "create")]   // pipeline-enforced: caller needs the "clients.create" permission claim
[CacheInvalidate("clients")]
[Auditable]   // framework audit trail: one compliance record per invocation (SetResource pins which client)
[Description("Creates a new client for the current account.")]
public sealed class CreateClient(
    BillingDbContext db,
    ICurrentUser user,
    IClientNumberGenerator numbers,
    IAuditScope audit,
    TimeProvider clock
) : IHandler<CreateClient.Command, Result<CreateClient.Response>> {
    public sealed record Command : ICommand {
        [Description("The client's display name.")]
        [StringLength(200, MinimumLength = 1)]
        public required string Name { get; init; }

        [Description("The client's billing email address.")]
        [EmailAddress, MaxLength(320)]
        public required string Email { get; init; }
    }

    public sealed record Response(Guid Id, string Number);

    public async ValueTask<Result<Response>> HandleAsync(Command command, CancellationToken ct) {
        var exists = await db.Clients
            .AnyAsync(c => c.OwnerId == user.UserId && c.Email == command.Email, ct);
        if (exists) {
            return AppError.Conflict($"A client with email {command.Email} already exists.");
        }

        var client = new Client {
            Id = Guid.CreateVersion7(),
            OwnerId = user.UserId,
            Number = await numbers.NextAsync(user.UserId, ct),
            Name = command.Name,
            Email = command.Email,
            CreatedAt = clock.GetUtcNow(),
        };

        db.Clients.Add(client);
        await db.SaveChangesAsync(ct);

        audit.SetResource("clients", client.Id.ToString());   // audit resource type = the [RequirePermission] vocabulary

        return new Response(client.Id, client.Number);
    }
}

The handler returns a value, never throws for expected failures: a duplicate email becomes AppError.Conflict, which the host later maps to a JSON-RPC error code. Only a successful result triggers the clients cache invalidation.

Runtime feature gating uses the same declarative shape: [FeatureGate("client-portal")] on a handler makes it return the generic not-found outcome while the flag is off — the feature's existence is never leaked (Feature flags). The Billing tutorial doesn't gate any handler, so the attribute appears here only for completeness.

Validate the command

The [StringLength]/[EmailAddress]/[MaxLength] attributes on the Command are the input rules — there is no validator class. Because the request carries validation attributes, the generator auto-attaches the framework ValidationDecorator, which rejects a bad request with field-keyed AppError.Validation errors before logging or the transaction ever run. Requiredness needs no attribute at all: required string Name already means the member must be present.

The same attributes flow into every contract surface — the exported rpc-schema.json, the MCP tool input schemas, and the generated Zod client (which pre-validates the form input with the identical rules, in Build the frontend).

Enforcement comes from the Elarion.Validation package added during scaffolding; the host registers it with one line in Host the API:

src/Billing.Api/Program.cs (excerpt)
builder.Services.AddElarionValidation();

Business rules stay in the handler: the duplicate-email check above is a database question, so it runs inside the transaction and returns AppError.Conflict — see Validation for where that dividing line sits.

Write the cached read handlers

Reads are IQuery types, so TransactionDecorator's AppliesTo predicate excludes them — no per-handler tag, no transaction around a read. GetClient is marked [Cacheable]: the generator inserts a cache decorator that keys off the request and only runs the handler on a miss. The default CurrentUser scope isolates entries per account, so one user's cached client is never served to another.

src/Billing.Application/Modules/Clients/Handlers/GetClient.cs
using Billing.Application.Persistence;
using Elarion.Abstractions;
using Elarion.Abstractions.Caching;
using Elarion.Abstractions.Identity;
using Microsoft.EntityFrameworkCore;

namespace Billing.Application.Modules.Clients.Handlers;

[Cacheable("clients", DurationSeconds = 120)]
[Handler("clients.get")]
public sealed class GetClient(BillingDbContext db, ICurrentUser user)
    : IHandler<GetClient.Query, Result<GetClient.Response>> {
    public sealed record Query(Guid Id) : IQuery;
    public sealed record Response(Guid Id, string Number, string Name, string Email);

    public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) {
        var client = await db.Clients
            .Where(c => c.OwnerId == user.UserId && c.Id == query.Id)
            .Select(c => new Response(c.Id, c.Number, c.Name, c.Email))
            .FirstOrDefaultAsync(ct);

        return client is null
            ? AppError.NotFound($"Client {query.Id} was not found.")
            : client;
    }
}

ListClients is the read the frontend's table calls. It is cached under the same clients tag, so CreateClient's [CacheInvalidate("clients")] clears it the moment a client is added.

src/Billing.Application/Modules/Clients/Handlers/ListClients.cs
using System.Collections.Generic;
using Billing.Application.Persistence;
using Elarion.Abstractions;
using Elarion.Abstractions.Caching;
using Elarion.Abstractions.Identity;
using Microsoft.EntityFrameworkCore;

namespace Billing.Application.Modules.Clients.Handlers;

[Cacheable("clients", DurationSeconds = 60)]
[Handler("clients.list")]
public sealed class ListClients(BillingDbContext db, ICurrentUser user)
    : IHandler<ListClients.Query, Result<ListClients.Response>> {
    public sealed record Query : IQuery;
    public sealed record Item(Guid Id, string Number, string Name, string Email);
    public sealed record Response(IReadOnlyList<Item> Clients);

    public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) {
        var items = await db.Clients
            .Where(c => c.OwnerId == user.UserId)
            .OrderBy(c => c.Number)
            .Select(c => new Item(c.Id, c.Number, c.Name, c.Email))
            .ToListAsync(ct);

        return new Response(items);
    }
}

Caching is opt-in per handler and composes with the rest of the pipeline. The default CurrentUser scope fits Billing perfectly. For genuinely shared, non-personalized data — say a list of supported currencies — you would set Scope = HandlerCacheScope.Global so all accounts share one entry.

Register the JSON metadata

Each module contributes source-generated JSON metadata for its request/response types. Add an entry per type the module exposes.

src/Billing.Application/Modules/Clients/ClientsJsonContext.cs
using System.Text.Json.Serialization;
using Billing.Application.Modules.Clients.Handlers;

namespace Billing.Application.Modules.Clients;

[JsonSerializable(typeof(CreateClient.Command))]
[JsonSerializable(typeof(CreateClient.Response))]
[JsonSerializable(typeof(GetClient.Query))]
[JsonSerializable(typeof(GetClient.Response))]
[JsonSerializable(typeof(ListClients.Query))]
[JsonSerializable(typeof(ListClients.Response))]
public sealed partial class ClientsJsonContext : JsonSerializerContext;

What you have so far

  • One handler that is simultaneously a use case, a DI registration, a JSON-RPC method, and an MCP tool.
  • A pipeline that applies logging and transactions to every command — declared once — with request validation attached automatically from the DTO's DataAnnotations.
  • Per-user reads that are cached and invalidated by tag, with no manual key management.

On this page