Elarion

Conventions & layout

The naming and namespace conventions the source generators depend on, and the project dependency rules.

Elarion discovers application code by namespace containment and type conventions — there is no central registration list. Get the layout right and the generators emit DI registration and transport maps for you, gated per module. This page consolidates every convention the generators rely on and the project dependency rules.

When a type is in the wrong namespace or breaks a convention, the result is a build-time diagnostic or a missing generated method — never a silent runtime failure. See Diagnostics for every id, and Troubleshooting for the common cases.

Solution layout

A conventional Elarion solution separates intent (the application) from platform wiring (the host):

MyApp.sln
src/
├─ MyApp.Domain/            # entities, value objects — no framework dependency
├─ MyApp.Application/       # modules, handlers, services  ← Elarion + generators
├─ MyApp.Infrastructure/    # concrete database/blob/mail/PDF implementations
└─ MyApp.Api/               # ASP.NET Core host  ← Elarion.AspNetCore (+ .JsonRpc / .Mcp)

A module is a namespace plus an [AppModule] marker; everything under that namespace belongs to the module. A common per-module layout:

MyApp.Application/Modules/Clients/
ClientsModule.cs            # [AppModule("Clients")] + optional ConfigureServices / JSON resolver
ClientsJsonContext.cs       # [JsonSerializable(...)] per request/response type
Handlers/
├─ GetClient.cs             # [Handler] IHandler<GetClientQuery, Result<ClientResponse>>
└─ CreateClient.cs          # handler; its Command carries the DataAnnotations constraints
Services/
└─ ClientNumberGenerator.cs # [Service] implementation

Naming & namespace conventions

These rules are load-bearing for the generators in Elarion.Generators.

ConventionWhat the generator doesSource
A type lives under a module namespaceDecides which [AppModule] owns it via longest-prefix containment, and therefore which gated registration runs.ModuleScanner
Handlers implement IHandler<TRequest, TResponse>The request type and the Result<T>-unwrapped success type are read straight from the interface.HandlerShape
Services are annotated with [Service]Registered with contracts inferred from implemented interfaces and the Scope lifetime.ServiceAttribute
Request DTOs carry DataAnnotations attributesThe handler generator auto-attaches the ValidationDecorator, and the resolver generator emits the module's validation metadata (when Elarion.Validation is referenced).ValidationAttribute
IHostedService/BackgroundService use ServiceScope.SingletonA scoped/transient hosted service is rejected with ELSG001.[Service]

Framework API naming

Framework registration and wiring methods follow a single prefix convention: DI registration methods are named AddElarionX (AddElarionScheduler, AddElarionOutbox<TDbContext>, AddElarionResilience, AddElarionInMemoryEventBus<TContext>, …), and EF Core model-wiring methods are UseElarionX / ApplyElarionX (UseElarionOutbox, UseElarionSettings, UseElarionBlobStorage, UseElarionStagedUploads, ApplyElarionIdempotencyKeys, ApplyElarionResourceGrants, ApplyElarionIdentity). The model-wiring methods share the same override shape — optional tableName (plus contentTableName for blobs; Identity takes a tablePrefix instead) and schema parameters and a snakeCase toggle (default true) — and each has a [GenerateElarionX] attribute counterpart for [GenerateDbSets] contexts.

Module namespace containment (longest prefix)

Every generator that groups registrations by module assigns each type to the [AppModule] whose namespace is the longest prefix of the type's namespace. Containment requires either an exact match or a .-delimited prefix (so MyApp.Billing does not swallow MyApp.BillingArchive); a module at the global namespace matches everything.

MyApp.Application.Modules.Billing            ← [AppModule("Billing")]
MyApp.Application.Modules.Billing.Invoices   → owned by Billing (prefix match)
MyApp.Application.Modules.Reporting          → not owned by Billing

A [Handler]/[HttpEndpoint] handler whose namespace falls under no module is not dropped — it is mapped ungated and a warning is raised (ELRPC001 for RPC/MCP, ELHTTP003 for HTTP). Module-scoped runtime registrations that require an owner (scheduled jobs, event consumers) are instead left unregistered with ELSG010 / ELEVT003.

Handler request/response shape

The transport generators resolve a handler's request and response from its IHandler<TRequest, TResponse> interface — argument 0 is the request, argument 1 is the response, and the success type is unwrapped from Result<T>. Request and response types may be nested or top-level; nesting and naming carry no semantic weight. (The older convention of scanning for nested Command/Query/Response members no longer applies.)

A handler with top-level request/response types
public sealed record GetClientQuery(Guid Id) : IQuery;
public sealed record ClientResponse(Guid Id, string Name);

[Handler("clients.get")]
public sealed class GetClient : IHandler<GetClientQuery, Result<ClientResponse>> {
    public ValueTask<Result<ClientResponse>> HandleAsync(GetClientQuery request, CancellationToken ct) =>
        /* ... */;
}

A handler annotated with [Handler]/[HttpEndpoint] that does not implement IHandler<,> with a Result<T> response generates no endpoint and reports ELRPC002 / ELHTTP001. For HTTP, when no explicit verb is given, the verb is inferred from the request's CQRS marker (ICommand → POST, IQuery → GET); neither marker present without an explicit verb reports ELHTTP004. A Result<ElarionFile> response maps as a file download over HTTP and as the canonical base64 envelope over JSON-RPC/MCP.

The [Handler] operation name is optional: [Handler] alone is valid. When omitted, the name is inferred by convention as {module}.{operation}, where operation is the handler type name minus a Handler/Command/Query/Request suffix, camelCased (e.g. module Clients + CreateClientclients.createClient). Supply an explicit name for stable public/wire contracts.

[Service] contract & lifetime resolution

[Service] registers a class for DI. Contracts and lifetime resolve as follows:

AspectRule
Explicit contracts[Service(typeof(IFoo), typeof(IBar))] registers exactly those types; each must be assignable from the implementation or ELSG002 is reported.
Inferred contractsWith no explicit types, the directly implemented interfaces are the contracts (base-class interfaces are not treated as explicit).
No interfacesWith no direct interfaces, the implementation registers as itself.
Multiple contractsThe first contract is the concrete registration; the rest forward to it (one shared instance per scope).
LifetimeScope selects the lifetime — ServiceScope.Scoped (default), Singleton, or Transient.
Hosted servicesAn IHostedService/BackgroundService is also registered as IHostedService (singleton) and must use Singleton scope (ELSG001).
Generic implementationsOpen-generic [Service] classes are unsupported (ELSG003).
Inferred contract, scoped lifetime
[Service]
public sealed class ClientNumberGenerator : IClientNumberGenerator { /* registered as IClientNumberGenerator */ }

[Service(typeof(IClock), Scope = ServiceScope.Singleton)]
public sealed class SystemClock : IClock { /* explicit contract, singleton */ }

Request validation

There is no validator class convention. Shape constraints are System.ComponentModel.DataAnnotations attributes on the handler's request DTO; a per-module validation resolver is generated from them (when the compilation references Elarion.Validation) and the handler generator auto-attaches the framework ValidationDecorator. Business rules live in the handler. See Validation.

Auto-wiring: ConfigureDefaultServices

Under [GenerateModuleBootstrapper], a module's discovered handlers, services, validation metadata, scheduled jobs, and event consumers are registered automatically — you do not hand-write Add{Module}…() calls.

  • ModuleDefaultServicesGenerator emits, per [AppModule], a sibling static partial class {ModuleType}ElarionModuleServices with ConfigureDefaultServices(IServiceCollection) that calls one static partial void hook per category (AddHandlers, AddServices, AddValidators, AddScheduledJobs, AddEventConsumers, AddModuleApi).
  • Each category generator contributes a filler partial implementing its hook; unimplemented hooks elide to no-ops, so a module pays nothing for categories it does not use.
  • The generated host bootstrapper calls {Module}.ConfigureDefaultServices(services) gated by IsModuleEnabled (which reads Modules:{Name}:Enabled, default true), before the module's optional hand-written ConfigureServices.

ConfigureServices is therefore reserved for additional, non-generated registrations (options binding, third-party libraries, manual decorators) — not for re-declaring the module's own handlers/services.

A module needs no manual Add{Module}… calls
[AppModule("Clients")]
public static partial class ClientsModule {
    // Optional: only for extra, non-generated wiring.
    public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) =>
        services.Configure<ClientOptions>(configuration.GetSection("Clients"));

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

The convention-based module hooks (ConfigureServices, MapEndpoints, GetJsonTypeInfoResolver, ConfigureEndpointGroup) and the core-vs-feature distinction are covered in Modules; the registration mechanics in Source generation. The two endpoint hooks may also be declared for a module from outside its assembly with [ModuleEndpoints] (unknown module name → ELMOD004; class without a recognized hook → ELMOD005).

Project dependency rules

The boundary that matters: the application declares intent; the host wires platform capabilities.

CodeBelongs in
Generic handler / result / module / pipeline / RPC primitivesThe framework packages (Elarion, Elarion.Abstractions)
Feature-module composition and business handlersApplication project
Concrete database / blob / mail / PDF / external-service implementationsInfrastructure, registered by the host as a platform capability
Runtime capability providers (Elarion.Caching, Elarion.Resilience, Elarion.FeatureFlags.*)API host (referenced and wired there)
Middleware, authentication, telemetry exporters, app lifetimeAPI host
Application-specific domain typesDomain / application projects

The application never references the host or infrastructure. Application modules may depend only on abstractions — DI, configuration, IEndpointRouteBuilder, and System.Text.Json metadata. They must not depend on the API host, WebApplicationBuilder, concrete infrastructure classes, or deployment-specific packages. A module must never call builder.Build(), app.UseAuthentication(), app.MapElarionJsonRpc(), or configure concrete providers — those belong to the host.

The same boundary applies to runtime capability packages. The [Cacheable]/[CacheInvalidate], [Resilient], and [FeatureGate]/[FeatureVariant] attributes live in Elarion.Abstractions (and request validation uses standard DataAnnotations attributes), so application modules declare intent with attributes alone. The runtime that backs them ships in separate provider packages — Elarion.Caching (AddElarionHandlerCaching()), Elarion.Resilience (AddElarionResilience()), and Elarion.FeatureFlags.OpenFeature / Elarion.FeatureFlags.FeatureManagement (AddElarionOpenFeature() / AddElarionFeatureManagement(configuration)) — which the host references and wires. The application project stays on Abstractions-only attributes (pay-for-what-you-use, ADR-0017). Request validation is the one deliberate exception: the application project references Elarion.Validation so the per-module validation resolvers are generated (ELVAL002 warns otherwise), and the host calls AddElarionValidation().

Cross-module references are location-based: depend on another module's published [ModuleContract] (used sparingly), a platform-capability port outside the modules, or the shared kernel — never a type placed inside another module (an entity, DTO, [Service], handler, or [EntityConfiguration]). A shared-kernel entity is shareable because it lives outside every module, not because entities are special. The ModuleBoundaryAnalyzer (ELMOD002) flags violations. See Cross-module communication.

On this page