Modules
A module is an application boundary marked with [AppModule]. Its handlers, services, validation metadata, scheduled jobs, and event consumers are discovered and registered automatically, and feature-gated as one unit.
A module is an application boundary: a static partial class marked with [AppModule], sitting at the
root of a namespace that contains the module's handlers, services, scheduled jobs, and
event consumers. The module decides what it exposes; the host only composes modules.
You do not wire a module's own building blocks by hand. Everything a module declares is discovered
by namespace and registered for you, gated as a single unit by the module's feature flag. A module's
optional ConfigureServices is reserved for the few registrations that source generation can't see —
options binding, third-party libraries, manual decorators.
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 {
// Optional: only for registrations the generators can't discover.
public static void ConfigureServices(IServiceCollection services, IConfiguration configuration) {
services.Configure<ClientsOptions>(configuration.GetSection("Clients"));
}
public static IJsonTypeInfoResolver GetJsonTypeInfoResolver() =>
ClientsJsonContext.Default;
}Generated default services
For every [AppModule], the generators emit a sibling partial class named by appending
ElarionModuleServices to the module's type name — for ClientsModule that is
ClientsModuleElarionModuleServices — with a single
ConfigureDefaultServices(IServiceCollection) method. Its fully-qualified name is exactly the
module's type name plus that suffix, so the host bootstrapper can always find and call it without any
extra manifest metadata.
ConfigureDefaultServices aggregates the module's discovered building blocks through six
static partial void hooks, invoked in this deterministic order:
| Hook | Registers |
|---|---|
AddHandlers | The module's handlers (and their decorator pipeline). |
AddServices | The module's [Service] registrations — see services. |
AddValidators | The module's generated request-validation resolver — the DataAnnotations metadata for its handlers' request types. |
AddScheduledJobs | The module's scheduled jobs. |
AddEventConsumers | The module's event consumers. |
AddModuleApi | The module's generated module API facades. |
Each category generator contributes the filler partial that implements its hook (calling the existing
per-module Add{Module}… registration method). A hook for a category the module doesn't use elides to
a no-op, so a module that declares, say, only handlers and services costs nothing for the rest. The
skeleton is emitted unconditionally for every module, so the host call always compiles.
There is no hand-written ConfigureServices that calls AddClientsHandlers() /
AddClientsServices() anymore. Those per-module methods still exist, but
the generated ConfigureDefaultServices is what calls them — you should not.
Looking at an older example? Pre-generator Elarion (and external references such as the
swimmesberger/Swerp repository, which vendors an older copy) wired each module by hand-calling
services.AddXHandlers() / AddXServices() inside the module's ConfigureServices. Copying that
pattern onto current packages double-registers everything, because ConfigureDefaultServices
already calls those methods. On current Elarion a module class is minimal — just [AppModule] (plus an
optional GetJsonTypeInfoResolver and a ConfigureServices reserved for non-generated registrations).
For an up-to-date end-to-end reference, see the compiled sample under
samples/.
How the host invokes it
When the host opts into [GenerateModuleBootstrapper], the generated bootstrapper's
AddElarion calls each module's ConfigureDefaultServices(services) — gated by the
module's feature flag — and then, only if the module declares one, its hand-written
ConfigureServices(services, configuration). The generated defaults always run before your custom
registrations. See Hosting for the host-side wiring.
Feature gating
Feature modules are enabled by default and disabled with configuration:
{
"Modules": {
"Clients": {
"Enabled": false
}
}
}The generated bootstrapper resolves this through a single IsModuleEnabled(configuration, name)
predicate that reads Modules:{Name}:Enabled, defaulting to true. Every surface is gated by that one
predicate, so a disabled feature module disappears completely and consistently:
- its generated default services (handlers,
[Service]registrations, validation metadata, scheduled jobs, event consumers, module API) are never registered; - its hand-written
ConfigureServicesis never called; - its
MapEndpointsroutes and its generated[HttpEndpoint]routes are never mapped; - its
[Handler]JSON-RPC operations are never registered; - its
[Handler]MCP tools (and their metadata) are never registered; - its
GetJsonTypeInfoResolver()contribution is left out.
This is the canonical statement of the gating contract: a module is enabled or disabled as one unit, across services, endpoints, and every transport. Handlers whose namespace falls under no module are reported with a diagnostic and mapped ungated so they are never silently dropped — see Module-aware transport gating for the per-surface diagnostic ids.
Core modules
A core module is an ordinary module in every way except two: it is always enabled (it ignores
Modules:{Name}:Enabled) and it initializes before feature modules. Declare it with
Kind = AppModuleKind.Core:
[AppModule("Core", Kind = AppModuleKind.Core)]
public static partial class CoreModule {
}A host that conceptually has "no modules" still declares one core [AppModule], since
[GenerateModuleBootstrapper] is the single transport-wiring path and core modules map unconditionally.
What belongs in a core module
A core module is for a foundation capability with behavior that other modules assume is present — an always-on domain service such as audit recording or current-user context enrichment. Its only reason to exist is that can't-be-disabled + initializes-first guarantee; otherwise it is the same kind of thing as a feature module. It is not "where shared code lives."
A core module is not a junk drawer for everything cross-cutting. Apply the active-vs-passive test: cross-cutting data belongs in the shared kernel, a cross-cutting mechanism belongs in a port, and only cross-cutting behavior that must always be present belongs in a core module — kept as small as possible.
Core module vs. the platform
Both a core module and the platform (the host/runtime) are "always there," so they are easy to conflate. The difference is domain-awareness:
- The platform provides domain-blind mechanisms — the DI container, the transports, the database
connection, and the adapters behind intent-only ports (SMTP for
IEmailSender). It never knows your domain, and the same platform serves any application. It is the ground your application stands on. - A core module is domain-aware application behavior you wrote — your application's load-bearing
foundation. A core module consumes platform capabilities (audit injects
ICurrentUser); application always builds on the platform, never the reverse.
So a domain-blind mechanism is a platform capability (a port); domain-aware always-on behavior is a foundation capability (a core module) — never call the latter a "platform capability."
Ordering with DependsOn
[AppModule] accepts an optional comma-separated DependsOn list of module names. The generator
topologically sorts modules so dependencies initialize first; within that order, core modules precede
feature modules.
[AppModule("Billing", DependsOn = "Catalog,Pricing")]
public static partial class BillingModule {
}Do not add DependsOn = "Core" just to make a feature module see core services — core availability
is implicit from the module kind. Use DependsOn only for explicit ordering between feature modules
or between multiple core modules.
Endpoints in modules
A module's handlers expose themselves over a transport with [Handler]
(JSON-RPC and MCP) or
[HttpEndpoint] (HTTP). A single [Handler] covers
both dispatcher transports; choose which ones with [Handler(Transports = ...)] (default: both
JSON-RPC and MCP). The generated bootstrapper associates each handler with a module by namespace and
maps its operation/tool only when the module is enabled — the same feature gate that governs the
module's services.
Hand-written endpoints
A module can also declare Minimal API endpoints directly with the real ASP.NET Core abstractions, as an escape hatch when it needs full control — custom authorization, conventions, or a hand-written route:
[AppModule("Chat")]
public static partial class ChatModule {
public static void MapEndpoints(IEndpointRouteBuilder endpoints) {
endpoints
.MapPost("/chat/stream", ChatEndpoint.HandleAsync)
.RequireAuthorization();
}
}Use real endpoint conventions, filters, typed results, and route handler source generation — Elarion
does not introduce a reduced endpoint facade. Keep host lifecycle concerns out of modules: a module
should never call builder.Build(), app.UseAuthentication(), app.MapElarionJsonRpc(), or configure
concrete infrastructure providers. Those belong to the host.
Owning the route group
A module can own the route group, authorization policy, and conventions for all of its mapped
endpoints — both its generated [HttpEndpoint] routes and its hand-written MapEndpoints routes — by
declaring an optional ConfigureEndpointGroup hook:
[AppModule("Billing")]
public static partial class BillingModule {
public static IEndpointRouteBuilder ConfigureEndpointGroup(IEndpointRouteBuilder endpoints) =>
endpoints.MapGroup("/billing").RequireAuthorization("billing");
}When present, the bootstrapper maps the module's endpoints onto the builder it returns; absent the
hook, they map onto the root builder. There are no per-module URL prefixes by default — each
[HttpEndpoint] carries its full route template and modules share a flat URL space. A conventions-only
group (MapGroup("")) adds policy without a prefix, while MapGroup("/billing") opts the whole module
into a prefix. The generator never reads [Authorize]/[AllowAnonymous] from handlers; per-endpoint
authorization is the host's or the module's job.
Declaring the hooks outside the module: [ModuleEndpoints]
Both endpoint hooks take IEndpointRouteBuilder, a shared-framework type — which a deliberately
web-free module assembly (no Microsoft.AspNetCore.App reference) cannot declare. Instead of
re-importing the framework or hand-mapping routes in the host (and hand-duplicating the module's feature
gate), declare the hooks on behalf of the module with [ModuleEndpoints] from Elarion.AspNetCore,
typically in the host:
[ModuleEndpoints("ImportExport")]
internal static class ImportExportEndpoints {
public static IEndpointRouteBuilder ConfigureEndpointGroup(IEndpointRouteBuilder endpoints) =>
endpoints.MapGroup("").RequireAuthorization();
public static void MapEndpoints(IEndpointRouteBuilder endpoints) {
// hand-written routes for the ImportExport module
}
}The class declares the same convention hooks a module type may declare (either or both — a class with
neither warns ELMOD005). The bootstrapper calls them inside the named module's feature gate,
composed with the module's own hooks: group hooks chain (the module's first, then contributors in stable
type-name order), then every MapEndpoints, then the module's generated [HttpEndpoint] routes — all
onto the builder the group chain returns. Disabling the module drops the contributed endpoints with it,
with no hand-written IsModuleEnabled re-check. A contributor naming a module no discovery produced
warns ELMOD004 and is skipped.
Contributors are discovered in the host compilation and — through the per-assembly Elarion manifest — in referenced assemblies, so a web companion assembly beside a web-free module assembly works the same way. See ADR-0040.
Convention-based hooks
Every module hook is optional and discovered structurally by signature:
| Hook | Purpose |
|---|---|
ConfigureServices(IServiceCollection, IConfiguration) | Register additional, non-generated services (options, third-party libraries, manual decorators). Runs after the generated defaults. |
MapEndpoints(IEndpointRouteBuilder) | Declare module-owned Minimal API endpoints. |
ConfigureEndpointGroup(IEndpointRouteBuilder) → IEndpointRouteBuilder | Return a route group (prefix, policy, conventions) onto which the module's endpoints are mapped. |
GetJsonTypeInfoResolver() → IJsonTypeInfoResolver | Contribute the module's source-generated System.Text.Json metadata. |
The two endpoint hooks may also be declared outside the module type on a
[ModuleEndpoints] contributor class, for
module assemblies that stay web-free.
What stays in the host
Concrete infrastructure is a platform capability, not a second module system. Feature flags decide which module handlers, endpoints, JSON metadata, scheduled jobs, and event consumers are exposed; the platform registers capability providers up front, and unused providers stay dormant until a feature resolves the corresponding application port.
Hosting
How the host composes modules and calls the generated bootstrapper.
Conventions
Naming, discovery, and gating convention tables and diagnostic ids.
Project structure
The full dependency rules between modules, host, and infrastructure.
Source generation
How discovery and the per-module registration methods are emitted.
Results & errors
Result<T> is a lightweight success-or-failure return type, and AppError is the transport-agnostic failure model.
Services
Annotate a class with [Service] and the generator registers it in DI — with conventional contract and lifetime resolution — through the module's ConfigureDefaultServices.