Elarion

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.

ClientsModule.cs
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:

HookRegisters
AddHandlersThe module's handlers (and their decorator pipeline).
AddServicesThe module's [Service] registrations — see services.
AddValidatorsThe module's generated request-validation resolver — the DataAnnotations metadata for its handlers' request types.
AddScheduledJobsThe module's scheduled jobs.
AddEventConsumersThe module's event consumers.
AddModuleApiThe 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:

appsettings.json
{
  "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 ConfigureServices is never called;
  • its MapEndpoints routes 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:

CoreModule.cs
[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:

HookPurpose
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) → IEndpointRouteBuilderReturn a route group (prefix, policy, conventions) onto which the module's endpoints are mapped.
GetJsonTypeInfoResolver() → IJsonTypeInfoResolverContribute 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.

On this page