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
if (!result.IsSuccess) return result.Error; // AppError converts to Result<T>
var r = result.Value;
return 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.
Data owned across modules
Some data belongs to records of other modules: notes or files attached to a property, a contact, a request. Keep
the reference in the database, not in a cleanup call in every deleting handler. Modules share one schema, so a
foreign key to another module's table breaks no boundary. For data that attaches to several kinds of records, use an
exclusive arc (one nullable foreign key per kind) or a shared identity table, and let ON DELETE CASCADE remove it
with its owner. Polymorphic references compares the patterns. Records in
another bounded context announce their deletion with an integration event instead.
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.