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] implementationFor 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:
| Convention | Why it matters |
|---|---|
| Types live under a module namespace | Namespace 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 attributes | The generators attach the validation decorator and emit the module's validation metadata from them — see Validation. |
| Hosted services use singleton scope | The 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=falsein configuration. - Core modules declare
Kind = AppModuleKind.Core, are always enabled, ignore theEnabledflag, 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:
| Code | Location |
|---|---|
| Generic handler / result / module / pipeline / RPC primitives | Elarion (the framework) |
| Feature module composition and business handlers | Application project |
| Concrete database / blob / mail / external service implementations | Infrastructure, registered by the host as a platform capability |
| Middleware, authentication, telemetry exporters, app lifetime | API host |
| Application-specific domain types | Domain / 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
- Create a module namespace, e.g.
MyApp.Application.Modules.Billing. - Add
[AppModule("Billing")]to a static partialBillingModuleclass. - Add a source-generated
BillingJsonContext. - Add services under the namespace and annotate them with
[Service]. - Add handlers implementing
IHandler<TRequest, Result<TResponse>>, with DataAnnotations constraints on their request DTOs where the input has a shape to enforce. - Mark handlers for a transport, e.g.
[Handler("billing.someAction")]or[HttpEndpoint("billing")]. - Build. The generators emit the module's
ConfigureDefaultServices, which the host bootstrapper calls automatically — there is no registration list to maintain.