Remote Boundaries
This guide explains how to split a Pragmatic application across multiple hosts using [RemoteBoundary<T>], how the HTTP transport works, and how to configure it.
Overview
Section titled “Overview”In a monolithic deployment, all modules run in the same process. [Include<TModule, TDatabase>] wires a module locally with its database.
In a distributed deployment, some modules run on separate hosts. [RemoteBoundary<TModule>] replaces the local module with HTTP-based action invokers. The calling code does not change — it still depends on IDomainActionInvoker<TAction, TReturn> — but the implementation sends HTTP requests instead of executing in-process.
Monolith vs Distributed Topology
Section titled “Monolith vs Distributed Topology”Monolith (All Local)
Section titled “Monolith (All Local)”// All modules in the same process[Module][Include<AccountsModule, AppDatabase>][Include<BillingModule, FinancialDatabase>][Include<BookingModule, AppDatabase>][Include<CatalogModule, AppDatabase>]public sealed class ShowcaseHostModule;Distributed (Billing Remote)
Section titled “Distributed (Billing Remote)”// Main host: Billing runs on a separate service[Module][Include<AccountsModule, AppDatabase>][Include<BookingModule, AppDatabase>][Include<CatalogModule, AppDatabase>][RemoteBoundary<BillingModule>]public sealed class ShowcaseDistributedModule;// Billing host: standalone service for Billing only[Module][Include<BillingModule, BillingFinancialDatabase>]public sealed class BillingHostModule;The key change: [Include<BillingModule, FinancialDatabase>] becomes [RemoteBoundary<BillingModule>]. The SG skips local DI registration (boundary extensions, repositories, database) for that module and generates HTTP invokers instead.
How It Works
Section titled “How It Works”Source Generator Output
Section titled “Source Generator Output”For each public action in the remote module, the SG generates an HttpActionInvoker<TAction, TReturn> (or HttpVoidActionInvoker<TAction> for void actions) that:
- Serializes the action to JSON
- POSTs to
/_pragmatic/invokeon the remote host - Deserializes the response back to
Result<TReturn, IError>orVoidResult<IError>
The generated invoker is registered as the IDomainActionInvoker<TAction, TReturn> implementation for that action, replacing the local invoker.
Wire Protocol
Section titled “Wire Protocol”Request (PragmaticInvokeRequest):
{ "ActionType": "Showcase.Billing.Actions.CreateInvoiceAction", "Payload": { /* serialized action properties */ }}Response (PragmaticInvokeResponse):
On success:
{ "IsSuccess": true, "Value": { /* serialized return value */ }, "Error": null}On failure:
{ "IsSuccess": false, "Value": null, "Error": { "status": 404, "title": "Invoice not found", "detail": "No invoice with ID 'abc123'", "extensions": { "code": "INVOICE_NOT_FOUND" } }}Errors are returned as ProblemDetails (RFC 9457) and converted to RemoteError on the client side.
Server-Side Dispatch
Section titled “Server-Side Dispatch”The remote host exposes the /_pragmatic/invoke endpoint (generated by the SG). Incoming requests are dispatched by PragmaticInvokeDispatcher, which:
- Reads the
ActionTypediscriminator - Deserializes the
Payloadto the concrete action type - Resolves the local
IDomainActionInvokerfrom DI - Invokes the action
- Serializes the result back to
PragmaticInvokeResponse
Configuration
Section titled “Configuration”Remote Host URL
Section titled “Remote Host URL”Configure the base URL for each remote boundary in appsettings.json:
{ "Pragmatic": { "RemoteBoundaries": { "Billing": { "BaseUrl": "https://billing-service:5001" } } }}The configuration key follows the pattern: Pragmatic:RemoteBoundaries:{ModuleName}:BaseUrl.
The module name is derived from the module class name (e.g., BillingModule becomes Billing).
Compile-Time URL Override
Section titled “Compile-Time URL Override”You can also specify the URL at compile time via the attribute:
[RemoteBoundary<BillingModule>(BaseUrl = "https://billing:5001")]public sealed class MyHostModule;When BaseUrl is null (default), the URL is resolved from configuration at runtime.
Named HttpClient
Section titled “Named HttpClient”Each remote boundary gets a named HttpClient registered via IHttpClientFactory. The client name follows the pattern: Pragmatic.Remote.{ModuleName}.
You can customize the HttpClient in your startup step:
// In IStartupStep.ConfigureServicesservices.AddHttpClient("Pragmatic.Remote.Billing", client =>{ client.Timeout = TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Add("X-Api-Key", "...");});Runtime Types
Section titled “Runtime Types”All remote boundary runtime types live in Pragmatic.Composition.Host/Remote/:
HttpActionInvoker<TAction, TReturn>
Section titled “HttpActionInvoker<TAction, TReturn>”Sends a DomainAction<TReturn> via HTTP and returns Result<TReturn, IError>:
public class HttpActionInvoker<TAction, TReturn>( IHttpClientFactory httpClientFactory, string httpClientName) : IDomainActionInvoker<TAction, TReturn> where TAction : DomainAction<TReturn>{ public async Task<Result<TReturn, IError>> InvokeAsync(TAction action, CancellationToken ct = default);}HttpVoidActionInvoker<TAction>
Section titled “HttpVoidActionInvoker<TAction>”Same pattern for void actions, returns VoidResult<IError>:
public class HttpVoidActionInvoker<TAction>( IHttpClientFactory httpClientFactory, string httpClientName) : IVoidDomainActionInvoker<TAction> where TAction : IVoidExecutable{ public async Task<VoidResult<IError>> InvokeAsync(TAction action, CancellationToken ct = default);}RemoteError
Section titled “RemoteError”Error type returned when a remote invocation fails:
public sealed record RemoteError : IError{ public required string Code { get; init; } public required int StatusCode { get; init; } public required string Title { get; init; } public string? Description { get; init; } public string? RemoteHost { get; init; } // For diagnostics}Error codes:
| Code | Meaning |
|---|---|
REMOTE_INVOKE_FAILED | Could not deserialize response |
REMOTE_DESERIALIZE_FAILED | Response received but return value could not be deserialized |
REMOTE_UNKNOWN_ERROR | Error response with no ProblemDetails |
REMOTE_ERROR | Error response with ProblemDetails (code extracted from extensions) |
PragmaticInvokeDispatcher
Section titled “PragmaticInvokeDispatcher”Static helper used by the generated /_pragmatic/invoke endpoint on the server side:
public static class PragmaticInvokeDispatcher{ public static async Task<IResult> InvokeActionAsync<TAction, TReturn>( TAction action, IDomainActionInvoker<TAction, TReturn> invoker, CancellationToken ct);
public static async Task<IResult> InvokeVoidActionAsync<TAction>( TAction action, IVoidDomainActionInvoker<TAction> invoker, CancellationToken ct);}Complete Example
Section titled “Complete Example”Project Structure
Section titled “Project Structure”MyApp.Billing/ # Domain module (shared by both hosts) BillingModule.cs Actions/ CreateInvoiceAction.cs Entities/ Invoice.cs
MyApp.Billing.Host/ # Standalone billing service BillingHostModule.cs Program.cs # PragmaticApp.RunAsync(args)
MyApp.Host/ # Main app (billing is remote) MainModule.cs # [RemoteBoundary<BillingModule>] Program.csBilling Host (Server)
Section titled “Billing Host (Server)”[Module][Include<BillingModule, BillingDatabase>]public sealed class BillingHostModule;await PragmaticApp.RunAsync(args).ConfigureAwait(false);The SG generates the /_pragmatic/invoke endpoint that dispatches incoming action requests to local invokers.
Main Host (Client)
Section titled “Main Host (Client)”[Module][Include<BookingModule, AppDatabase>][RemoteBoundary<BillingModule>]public sealed class MainModule;{ "Pragmatic": { "RemoteBoundaries": { "Billing": { "BaseUrl": "https://localhost:5001" } } }}Calling Code (Unchanged)
Section titled “Calling Code (Unchanged)”The caller does not know whether the action runs locally or remotely:
public class OrderCompletionHandler( IDomainActionInvoker<CreateInvoiceAction, InvoiceDto> createInvoice){ public async Task HandleAsync(OrderCompletedEvent evt, CancellationToken ct) { var action = new CreateInvoiceAction { OrderId = evt.OrderId, Amount = evt.Total }; var result = await createInvoice.InvokeAsync(action, ct);
result.Match( invoice => /* success */, error => /* handle error */); }}In the monolith, createInvoice is the local CreateInvoiceAction.Invoker. In the distributed deployment, it is the generated HttpActionInvoker<CreateInvoiceAction, InvoiceDto>. The calling code is identical.
Current Limitations
Section titled “Current Limitations”The remote boundary MVP supports:
- Domain actions (
DomainAction<T>) and void actions (IVoidExecutable) - JSON serialization via
System.Text.Json
Not yet supported:
- Mutations (entity-bound operations)
- Domain event propagation across boundaries
- Service discovery (URLs are configuration-based)
- gRPC transport
- Retry and circuit breaker policies (use
HttpClientconfiguration for now)
Diagnostics
Section titled “Diagnostics”| ID | Severity | Description |
|---|---|---|
PRAG1685 | Error | Remote boundary overlaps with a local [Include] |
PRAG1686 | Warning | Remote boundary module has no public actions |
PRAG1687 | Error | No base URL configured for remote boundary |