Cross-module communication
Direct, synchronous module-to-module calls go through a published [ModuleContract]; an analyzer keeps modules honest, and an optional generated typed in-process API lets a module call its own handlers by name.
Modules collaborate asynchronously through the event planes. For direct, synchronous module-to-module calls — the in-process analog of a gRPC call — Elarion's answer is a published contract: a module exposes an interface, keeps the implementation internal, and other modules depend on the contract, never on the module's internals.
The framework owns the convention ([ModuleContract]) and an analyzer that enforces it. Mapping
between a contract's DTOs and a module's handler DTOs is the module's concern — write it by hand
or with any mapper. There is no generated forwarder for the contract and no mapper dependency.
The contract
Mark a module's published surface with [ModuleContract] and keep the implementation internal:
using Elarion.Abstractions;
using Elarion.Abstractions.Modules;
namespace Sales;
// Module A — the published contract (stable, public).
[ModuleContract]
public interface ICustomerLookup {
ValueTask<Result<Customer>> GetAsync(CustomerId id, CancellationToken ct = default);
}namespace Orders;
// Module B — depends only on the contract.
[Service]
internal sealed class OrderPricer(ICustomerLookup customers) {
// ...
}The boundary analyzer (ELMOD002, a warning) is purely location-based: everything under an
[AppModule] is module-internal, and everything outside every module is shareable. So injecting another
module's internal type — an entity, DTO, [Service], handler, or [EntityConfiguration] placed inside it —
is flagged, while a [ModuleContract] is allowed. It inspects only the dependency surface (constructor
parameters, fields, properties). Resolve a flag one of three ways:
- A
[ModuleContract]— for a genuine cross-module domain call (e.g. a foundation module publishing an account-standing/credit policy another module consults before invoicing). Use it sparingly and deliberately; it is a published surface, not a default reach. It is not for cross-cutting concerns the framework already solves (auditing via[Auditable], validation, authorization) — those attach in the handler pipeline, with no contract to publish. - A platform-capability port — an intent-only abstraction (sending email, storing a blob) that lives outside the modules with its adapter in infrastructure (the port/adapter pattern). Shared by location, not by contract. (A write-only external sink — a webhook, a log shipper — belongs here; a decision the app's own domain owns, like the credit policy above, is the contract instead.)
- The shared kernel — data and value types (entities,
Money) live outside every module and are freely referenceable. A shared-kernel entity is shareable because of where it lives, not because entities are special: place an entity inside a module and it becomes module-owned and flagged.
The recommended layout keeps each entity's [EntityConfiguration] in the shared Persistence layer (under
no [AppModule]), so it is shareable too; an [EntityConfiguration] is flagged only when deliberately
placed inside a module.
Foundation (Core) modules are no exception — the analyzer reads only the module name, never its
Kind, so a core module exports through a [ModuleContract] (or a platform port outside the modules) just
like any other. Making "what a module exports to the outside world" explicit is the point, core included.
Mental model: module-private namespaces
If you know Java or Kotlin, ELMOD002 is essentially package-private for C#. A module owns a namespace
the way a Java package owns its types, and the analyzer enforces the same visibility split:
| Java / Kotlin | Elarion |
|---|---|
| a package | a module's namespace ([AppModule] + everything under it) |
public (or a JPMS exports) | a [ModuleContract] — the exported surface |
| package-private (the default) | everything else in the module — module-internal |
| the compiler enforces it | the analyzer (ELMOD002) enforces it |
Elarion has to synthesize this because C#'s internal is assembly-scoped, not namespace-scoped — within
one assembly the language has no "visible only inside this module." The analyzer fills that gap on top of
namespaces.
Two deliberate differences from Java's version: it checks only the dependency surface (constructor parameters, fields, properties) rather than every reference, and data is a shared carve-out — entities live outside modules on purpose (feature, not data, separation), so it is "module-private for code, shared kernel for data."
It also has a hard upgrade: when a module graduates to its own assembly (a
bounded context), real C# internal
takes over and the boundary becomes compiler-enforced. So the progression is exactly package-private
(soft, co-located) → a real module boundary (hard, separate assembly) — the analyzer is the in-assembly
tier C# lacks, and the assembly split is the upgrade.
Implementing a contract with the typed module API
A [ModuleContract] implementation is a small, hand-written adapter: it forwards to the module's
handlers and maps to/from the contract's DTOs. To call handlers by name instead of resolving verbose
IHandler<,> types, opt the module into a generated typed in-process API with [GenerateModuleApi]:
namespace Sales;
// Generated: one method per handler in this module, dispatched typed-direct to IHandler<,>.
[GenerateModuleApi]
public partial interface ISalesApi;
// The contract implementation — internal, auto-registered and module-gated via [Service].
[Service]
internal sealed class CustomerLookup(ISalesApi api) : ICustomerLookup {
public async ValueTask<Result<Customer>> GetAsync(CustomerId id, CancellationToken ct = default) {
var result = await api.GetCustomer(new GetCustomer.Query(id.Value), ct); // full handler pipeline
return result.Map(r => new Customer(r.Id, r.Name)); // module-owned mapping
}
}The typed module API is not a transport. It dispatches typed-direct to the decorated
IHandler<,> (so the full decorator pipeline runs), crosses no serialization boundary, and is
absent from the JSON-RPC/MCP schema. Because its methods expose handler DTOs it is module-internal —
never inject it across a module boundary.
Handlers are resolved lazily, per call, from the service provider — a default facade can span the whole module, so the forwarder never builds a handler's pipeline until you invoke that method.
Choosing which handlers appear
Membership is opt-out and uses the same scope vocabulary as [EntityConfiguration]/[GenerateDbSets]:
- A default
[GenerateModuleApi]facade includes every handler in the owning module. [ModuleApi(Exclude = true)]on a handler removes it from every facade.[ModuleApi("Reporting")]tags a handler into theReportingscope (additively — it stays in the default facade). A[GenerateModuleApi("Reporting")]facade then includes only handlers whose tags intersect, which is the ISP-friendly way to expose a narrow surface to one collaborator.
[ModuleApi("Reporting")]
public sealed class GetRevenue : IHandler<GetRevenue.Query, Result<GetRevenue.Response>> { /* ... */ }
[GenerateModuleApi("Reporting")] // only [ModuleApi("Reporting")] handlers
public partial interface IReportingApi;| Diagnostic | Meaning |
|---|---|
ELAPI001 | [GenerateModuleApi] interface must be partial. |
ELAPI002 | [GenerateModuleApi] interface must be top-level (not nested). |
ELAPI003 | Interface namespace is under no [AppModule]; left empty (warning). |
ELAPI004 | Two handlers map to one method name on the facade. |
ELMOD002 | A type depends on another module's internal type instead of a [ModuleContract] (warning). |
Error handling on the contract surface
A contract method's error channel is part of its public shape, so choose it deliberately. Prefer
returning Result<T> — it matches the handler layer (the implementation is a pure map, no try/catch),
makes the success/error union explicit and total in the signature, and carries the transport-neutral
AppError/ErrorKind taxonomy that survives a later move out of process.
[ModuleContract]
public interface ICustomerLookup {
ValueTask<Result<Customer>> GetAsync(CustomerId id, CancellationToken ct = default); // preferred
}You can instead publish an exception-based contract — return the bare value and throw on failure — to
keep the interface free of the Result<T> dependency. Result then leaves the interface and the
in-process implementation maps the handler's Result to value-or-throw:
[ModuleContract]
public interface ICustomerLookup {
ValueTask<Customer> GetAsync(CustomerId id, CancellationToken ct = default); // throws on failure
}
[Service]
internal sealed class CustomerLookup(ISalesApi api) : ICustomerLookup {
public async ValueTask<Customer> GetAsync(CustomerId id, CancellationToken ct = default) {
var result = await api.GetCustomer(new GetCustomer.Query(id.Value), ct);
if (!result.IsSuccess) throw MapToException(result.Error); // module-owned mapping
var r = result.Value;
return new Customer(r.Id, r.Name);
}
}Either way the interface stays stable across a later gRPC extraction — only the implementation's
mapping changes. A Result<T> contract maps elegantly to a protobuf oneof response (success or
error, the on-the-wire Result); an exception contract maps to the
Google Rich Error Model (google.rpc.Status), with ErrorKind
mapping onto google.rpc.Code. Prefer Result; only drop it when a concrete need calls for it.
Why not just call the handler (IHandlerSender/IHandler)?
A typed in-process call to another module's handler (by type)
couples you to that module's request/response types — which the boundary analyzer flags (ELMOD002) when they
are module-internal — and it does not survive extraction. A [ModuleContract] is the stable seam that does: to
split a module out of process later, keep the interface and swap the in-process implementation for a generated
client — consumers never change. (The event bus is no help here either: it is pub/sub-only, see
events.)
Variable substitution
Spring-style ${key:-default} placeholders resolved from a pluggable source — a general building block reused across Elarion subsystems, not tied to any one feature.
Actors
Decide whether a live in-memory consistency unit deserves an actor, then choose the runtime, durability, placement, and integration model.