Elarion

Project structure

How to lay out an Elarion solution and the conventions the source generators depend on.

Elarion discovers application code by namespace containment and type conventions. Getting the layout right is what lets the generators emit registration code without a central list.

A typical solution

MyApp.sln
├─ src/
│  ├─ MyApp.Domain/            # entities (plain classes) + value objects + enums — no marker, no Elarion ref
│  ├─ MyApp.Application/       # modules (behavior); Persistence (config + DbContext + migrations); [GenerateDbSets]  ← Elarion, Elarion.EntityFrameworkCore
│  ├─ MyApp.Infrastructure/    # intent-only mechanism adapters: mail, blob storage, external services
│  └─ MyApp.Api/               # ASP.NET Core host (UseNpgsql + connection)  ← Elarion, Elarion.JsonRpc, Elarion.AspNetCore
└─ tests/

The boundary that matters: the application project declares intent; the host wires platform capabilities. See Dependency rules below for where each kind of code belongs. The whole layout can also collapse into a single project — the bootstrapper discovers handlers in the host compilation too, so Program, the modules, and the trigger can share one csproj (samples/LiveQuotes is that shape); the split above is simply the layout that grows well.

MyApp.Domain above is shown as a separate project, but for a single bounded context a shared-kernel namespace inside MyApp.Application (e.g. MyApp.Application.Domain) is usually enough — a separate assembly only earns its keep when multiple host projects share the code. Either way, entities belong to the shared kernel, not inside a module. Solution structure explains why and when to graduate to a separate assembly.

The source generators ship inside their runtime packages, and NuGet analyzer assets are not transitive — so every assembly that declares generator-driven types must reference the package directly. For EF Core that is the application project: it holds the persistence layer (the [EntityConfiguration] configurations, the [GenerateDbSets] concrete DbContext, and migrations — the database is application logic, not an infrastructure detail), so its generator emits the DbSets and ConfigureEntities in place. Reference Elarion.EntityFrameworkCore there. Entities themselves stay plain classes with no marker, so a pure domain/entity project needs no reference. (If you split the entities/configurations into a separate assembly from the DbContext, each needs the reference, and the context reads the configurations from the other assembly's emitted manifest.) Without the reference, the context resolves zero configurations and generates no DbSets.

Module layout

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")] + ConfigureServices / JSON resolver
├─ ClientsJsonContext.cs       # [JsonSerializable(...)] per request/response type
├─ Handlers/
│  ├─ GetClient.cs             # [Handler] IHandler<Query, Result<Response>>
│  └─ CreateClient.cs          # handler; its Command carries the DataAnnotations constraints
└─ Services/
   └─ ClientNumberGenerator.cs # [Service] implementation

For each [AppModule], the generators emit a {Module}ElarionModuleServices.ConfigureDefaultServices(IServiceCollection) method that auto-wires the module's handlers, services, validation metadata, scheduled jobs, and event consumers. The host bootstrapper calls it for every enabled module — you do not hand-write any Add{Module}…() calls. The optional ConfigureServices method on the module is reserved for non-generated registrations. See Modules for the full model.

Conventions the generators rely on

Because Elarion prefers convention over configuration, a handful of naming and placement rules are load-bearing:

ConventionWhy it matters
Types live under a module namespaceNamespace containment decides which module owns a type and which generated Add{Module}…() method registers it.
Handlers implement IHandler<TRequest, TResponse>The handler generator matches on this interface, and reads the request/response shape from it — the types may be nested in the handler or top-level.
Services are annotated with [Service]Marks the type for registration and contract resolution.
Request DTOs carry DataAnnotations attributesThe generators attach the validation decorator and emit the module's validation metadata from them — see Validation.
Hosted services use singleton scopeThe generator rejects scoped/transient IHostedService with a diagnostic.

If a type is in the wrong namespace or breaks a convention, the result is a build-time diagnostic or a missing generated method — not a silent runtime failure. See Conventions for the full convention tables and diagnostic ids, and Troubleshooting for the common cases.

Core vs. feature modules

  • Feature modules are enabled by default and can be switched off with Modules:{Name}:Enabled=false in configuration.
  • Core modules declare Kind = AppModuleKind.Core, are always enabled, ignore the Enabled flag, and initialize before feature modules.
[AppModule("Core", Kind = AppModuleKind.Core)]
public static partial class CoreModule { /* ... */ }

Do not add DependsOn = "Core" just to see core services — core availability is implicit. Use DependsOn only for explicit ordering between feature modules. See Modules for the full lifecycle.

Dependency rules

Use this table when deciding where code belongs:

CodeLocation
Generic handler / result / module / pipeline / RPC primitivesElarion (the framework)
Feature module composition and business handlersApplication project
Concrete database / blob / mail / external service implementationsInfrastructure, registered by the host as a platform capability
Middleware, authentication, telemetry exporters, app lifetimeAPI host
Application-specific domain typesDomain / application projects

Application modules may depend on abstractions — DI, configuration, IEndpointRouteBuilder, and System.Text.Json metadata. They should not depend on the API host, WebApplicationBuilder, concrete infrastructure classes, or deployment-specific packages.

Creating a new module

  1. Create a module namespace, e.g. MyApp.Application.Modules.Billing.
  2. Add [AppModule("Billing")] to a static partial BillingModule class.
  3. Add a source-generated BillingJsonContext.
  4. Add services under the namespace and annotate them with [Service].
  5. Add handlers implementing IHandler<TRequest, Result<TResponse>>, with DataAnnotations constraints on their request DTOs where the input has a shape to enforce.
  6. Mark handlers for a transport, e.g. [Handler("billing.someAction")] or [HttpEndpoint("billing")].
  7. Build. The generators emit the module's ConfigureDefaultServices, which the host bootstrapper calls automatically — there is no registration list to maintain.

On this page