Elarion

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 / KotlinElarion
a packagea 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 itthe 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 the Reporting scope (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;
DiagnosticMeaning
ELAPI001[GenerateModuleApi] interface must be partial.
ELAPI002[GenerateModuleApi] interface must be top-level (not nested).
ELAPI003Interface namespace is under no [AppModule]; left empty (warning).
ELAPI004Two handlers map to one method name on the facade.
ELMOD002A 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.)

On this page