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
        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 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.

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.)

On this page