Why Elarion
The reasoning behind Elarion's opinions, how it differs from idiomatic ASP.NET Core, and whether the trade-offs fit your project.
Elarion is opinionated on purpose. This page collects the reasoning behind those opinions in one place, so you can decide whether the trade-offs fit your project before adopting the framework. Each principle links to the page where it is applied.
The guiding principle
Auto-detect application patterns, explicitly wire platform capabilities.
Your code already states its intent: a handler says "I handle this request", an annotated request DTO
says "this is what a valid command looks like", a [Service] class says "this is a module service".
Repeating those facts in
a separate registration list (Program.cs, hand-written AddXyz() methods) creates a parallel
model that drifts from the code it describes. Elarion declares intent next to the type and generates
the wiring. The host stays a thin composition and transport shell.
Everything below follows from that one idea.
Compile-time generation over runtime reflection
Discovery happens at build time through Roslyn generators, not at startup through assembly scanning.
Why: startup is deterministic and AOT-friendly; the generated code is ordinary DI registration you can read and step through; and mistakes — a missing trigger, an invalid contract, a scoped hosted service — become build errors instead of runtime surprises. Registration drift is caught by the compiler.
→ Source generation · Why auto-detection is the default
Handlers are the use-case boundary
The unit of application logic is an IHandler<TRequest, TResponse>, not a controller, a route lambda,
or a service method.
Why: a handler is a single named use case with one entry point. It is transport-agnostic — it does not know whether it was called over JSON-RPC, HTTP, a scheduled job, or a test — which makes it trivial to test and to reuse across transports. Cross-cutting behavior lives in decorators wrapped around it in a deterministic order, so the handler body stays focused on business orchestration.
→ Handlers
Cross-cutting gates are declarative and transport-neutral
Authorization and feature gating are class-level attributes on the handler, not transport
middleware. [RequirePermission]/[RequireRole]/[RequirePolicy] and [FeatureGate] describe the
same kind of thing — a precondition the request must satisfy before the handler body runs — so they are
expressed the same way and enforced the same way: a generated decorator in the
pipeline, authorization outermost and the feature gate just inside it.
Why: a gate that lives in the pipeline runs identically under JSON-RPC, MCP, and HTTP, because
all three call the same handler. There is no per-transport policy to keep in sync, and the gate is
visible right next to the use case it guards. A denied authorization fails closed with
AppError.Unauthorized/Forbidden (401/403); a closed feature gate short-circuits to
AppError.NotFound (404) — a gated-off operation is indistinguishable from one that doesn't exist, so
the feature name is never leaked (MS-MVC-style hide-the-roadmap). Module flags decide whether a feature
ships at all (compose-time — the module disappears); feature gates decide who sees it now (runtime —
gradual rollouts, targeting, kill switches without a redeploy), and the flag backend is any OpenFeature
provider behind one seam.
→ Authorization · Feature flags
Results over exceptions for expected failures
Handlers return Result<T> carrying either a value or a transport-agnostic AppError, rather than
throwing for expected outcomes like "not found" or "validation failed".
Why: expected failures are normal return values, so they are explicit in the type and cheap to
produce. AppError.Kind describes what kind of failure occurred, not how a protocol should report
it — the host maps it to a JSON-RPC code or an HTTP status once, centrally. The same handler
behaves correctly behind any transport.
Modules own their surface
A module is a namespace plus an [AppModule] marker. Adding a handler, service, or JSON
context under that namespace is enough to publish it.
Why: ownership follows structure. Moving a type between modules changes its ownership through namespace containment; deleting a type deletes its registration on the next build. The host never needs to know a module's internals — it composes modules and maps transports. Feature flags decide which modules are exposed.
Composition over inheritance
You compose framework capabilities onto your own types; you do not inherit framework base classes.
Why: inheritance forces your type into the framework's hierarchy, and only one base class can win. Composition keeps your type yours and lets capabilities stack. It is a through-line, not a slogan:
- Your
DbContextis a plainDbContext. Entities are composed in through[EntityConfiguration]and[GenerateDbSets]— with no repository orIAppDbContextin front of it — not by deriving a base context. - ASP.NET Core Identity is composed in with
[GenerateElarionIdentity<…>]: the generator emits the IdentityDbSets and model configuration onto your context instead of inheritingIdentityDbContext. - Cross-cutting behavior wraps a handler through the decorator pipeline, not a handler base class.
- Modules are composed by namespace +
[AppModule], not a module base type.
The common shape is the one the framework uses everywhere: a marker attribute states intent, a generator emits
the wiring onto your partial type, and nothing forces a base class. That is also what makes
authorization provider-neutral — handlers depend on the ICurrentUser claims
abstraction, so the authentication provider composes underneath without the handler inheriting anything.
→ Authorization · Entity Framework Core
The database is application logic, not an abstraction
Handlers work directly against the concrete DbContext — its DbSets, LINQ, raw SQL, and
provider-specific functions. There is no repository layer, and no context interface in front of it.
Why: the rule for what to hide behind an abstraction is whether you depend on a dependency's
intent or its specifics. Email is intent-only — you say "send this", and SMTP vs SES is
interchangeable — so it belongs behind a port (IEmailSender). The database is the opposite: a unique
constraint is a business rule, an index exists for a specific query, and the SQL you write is
provider-specific. You depend on its specifics, so wrapping it (a repository, or an IAppDbContext
interface) only hides the model you are actually programming against — and forces a leaky escape hatch
the moment you need raw SQL. So the database is your data access: handlers use the concrete DbContext.
The framework wires the connection (UseNpgsql and the connection string are the host's job); what
lives in the database is application logic and yours.
This line is intent-vs-specifics, not database-vs-no-database — so infrastructure may still own its own
persisted state and stay infrastructure. A scheduler's tables, or Elarion's EF Core outbox table, are
mechanism state: your domain code reaches them only through a port (IJobScheduler, IIntegrationEventBus)
and never queries them as domain. Your domain model is application logic; a mechanism's private
bookkeeping is infrastructure, even when it shares the same database.
→ Entity Framework Core · Handlers → Accessing data
JSON-RPC for internal APIs
When you need a network transport, JSON-RPC is offered as a first-class (but optional) one because it exposes operations, not resources — a method maps one-to-one to a handler.
Why: it removes an entire category of REST design work (paths, verbs, status-code mapping, resource modeling). The generated, typed client makes a frontend call feel like invoking the handler directly, so the iteration loop is just add handler → regenerate client → call it. That fits internal, first-party APIs where one team controls both ends. It is deliberately optional and outside the core package, and modules can expose Minimal API endpoints instead when REST semantics are what you need.
Observability without lock-in
The runtime emits OpenTelemetry-compatible traces and metrics through System.Diagnostics, but
depends on no OpenTelemetry SDK.
Why: the framework instruments its own boundaries (JSON-RPC, scheduling, caching, resilience) while letting the host choose exporters and which sources to collect. You get observability for free without the framework dictating your telemetry stack.
A small reusable contract; the platform stays in the host
The framework packages stay domain-neutral and small. Authentication, middleware, database providers, telemetry exporters, and concrete capability implementations are the host's job.
Why: keeping platform mechanics out of the reusable packages is what lets modules move between hosts unchanged and keeps the framework's public surface stable. Explicit registration is preserved exactly where explicitness is the point.
→ Packages
The trade-off
The cost of all this is that you accept conventions: handler names, the IHandler<,>
request/response shape, namespace containment, and pipeline attributes matter because the generators use
them. In exchange you get less host boilerplate, inherent modularity, fewer runtime scans and faster,
more predictable startup, and a clear line between application policy and host mechanics.
How Elarion differs from ASP.NET Core
Elarion is intentionally not a thin wrapper around the default ASP.NET Core style. It chooses a more application-centric architecture where modules and handlers are the source of truth, while the ASP.NET Core host becomes a composition and transport shell. The differences below are the same principles above, viewed against idiomatic ASP.NET Core.
| Topic | Idiomatic ASP.NET Core | Elarion |
|---|---|---|
| Registration | Explicit services.AddScoped<…>(), endpoint maps, and feature wiring in Program.cs. | Source-generated auto-registration from handler, module, and RPC attributes. Explicit registration remains for infrastructure. |
| Application shape | Controllers, Minimal API lambdas, or endpoint classes are the use-case boundary. | IHandler<TRequest, TResponse> is the use-case boundary; transport adapters call handlers. |
| Modularity | Folder/project organization plus manually maintained AddXyz() methods. | First-class modules with [AppModule]: feature flags, ordering, handler/service aggregation, endpoint hooks, JSON metadata hooks. |
| Cross-cutting behavior | Middleware, endpoint filters, MVC filters, MediatR behaviors, or nested services. | Generated decorator pipelines wrap handlers in a deterministic order chosen by assembly, module, or handler. |
| Feature gating | MVC [FeatureGate] / middleware — HTTP-only, and a closed gate typically 404s only on MVC actions. | [FeatureGate] as a generated pipeline decorator — transport-neutral (identical under JSON-RPC/MCP/HTTP), closed gate returns 404 over every transport, any OpenFeature provider behind one seam. |
| Validation | [ApiController] model validation or a validator library — enforced at HTTP binding, invisible to other transports and to exported contracts. | DataAnnotations on the request DTO, enforced by a generated pipeline decorator under every transport and exported to the JSON-RPC schema, OpenAPI, MCP tool schemas, and the Zod client; business rules in the handler. |
| Discovery | Runtime reflection scanning for handlers, controllers, endpoint modules. | Compile-time source generation for deterministic startup, AOT friendliness, and inspectable code. |
| Host responsibilities | The host often knows every feature and registers most feature services. | The host owns platform capabilities, middleware, auth, telemetry, database setup, and transports; modules own composition. |
| Serialization metadata | Global JSON options plus reflection fallback. | Modules contribute JsonSerializerContext resolvers following the module boundary. |
| EF Core model wiring | Hand-written DbSet<T> and ApplyConfigurationsFromAssembly(…). | Optional generator: [GenerateDbSets] on the concrete DbContext, explicit [EntityConfiguration] (single source of truth for DbSet + schema), optional scopes, and direct ApplyConfiguration<T> calls. Handlers use the concrete context — no interface. |
| Background work | Individual BackgroundService loops own their timers, overlap, logging, and cancellation. | Source-generated scheduled jobs share one scheduler with typed invocation, explicit overlap, TimeProvider, and telemetry. |
| Error model | Exceptions, ProblemDetails, action results, or ad-hoc DTOs. | Handlers return Result<T> with transport-agnostic AppError; the host maps errors per transport. |
| Transport | HTTP REST is the default API. | HTTP, JSON-RPC, and MCP are parallel first-class optional transports over the same handlers; JSON-RPC and MCP live outside the core package. |
| Client contracts | Frontends hand-write DTOs or ad-hoc REST helpers. | JSON-RPC clients consume generated TypeScript/Zod artifacts and a portable fetch client from the schema. |
Is Elarion for me?
Elarion pays for its conventions when your application's shape is the thing you want to keep stable, and it gets in the way when each endpoint needs to be bespoke.
A good fit if you are building:
- a modular application or service where features should register, gate, and move as units;
- a first-party, internal API where one team controls both ends and a typed .NET↔TypeScript contract removes hand-written DTOs;
- something with AOT or trimming goals, where compile-time generation and a reflection-free startup matter;
- an app with in-process background work, domain/integration events, or cross-cutting concerns you want expressed as decorator pipelines instead of scattered middleware.
Probably not a good fit if you need:
- maximum freedom in how each endpoint is shaped — bespoke routes, verbs, content negotiation, and status-code mapping per operation;
- a public or third-party REST API where resource modeling and stable URLs are part of the contract;
- to stay on the idiomatic ASP.NET Core path with controllers/Minimal APIs as the use-case boundary and explicit registration everywhere.
In those cases idiomatic ASP.NET Core fits better. Elarion does not try to be a universal web framework — it is opinionated where the opinions buy you modularity and a thin host.
Influences
The model will feel familiar if you have used annotation-driven frameworks such as Spring Boot — components live below a namespace boundary, attributes declare their role, defaults cover the common path, and explicit configuration remains for the exceptional case. Elarion implements that philosophy with .NET source generation rather than runtime classpath scanning, which is what makes it deterministic and AOT-friendly.