Skip to content

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.

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.

// All modules in the same process
[Module]
[Include<AccountsModule, AppDatabase>]
[Include<BillingModule, FinancialDatabase>]
[Include<BookingModule, AppDatabase>]
[Include<CatalogModule, AppDatabase>]
public sealed class ShowcaseHostModule;
// 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.

For each public action in the remote module, the SG generates an HttpActionInvoker<TAction, TReturn> (or HttpVoidActionInvoker<TAction> for void actions) that:

  1. Serializes the action to JSON
  2. POSTs to /_pragmatic/invoke on the remote host
  3. Deserializes the response back to Result<TReturn, IError> or VoidResult<IError>

The generated invoker is registered as the IDomainActionInvoker<TAction, TReturn> implementation for that action, replacing the local invoker.

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.

The remote host exposes the /_pragmatic/invoke endpoint (generated by the SG). Incoming requests are dispatched by PragmaticInvokeDispatcher, which:

  1. Reads the ActionType discriminator
  2. Deserializes the Payload to the concrete action type
  3. Resolves the local IDomainActionInvoker from DI
  4. Invokes the action
  5. Serializes the result back to PragmaticInvokeResponse

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

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.

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.ConfigureServices
services.AddHttpClient("Pragmatic.Remote.Billing", client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("X-Api-Key", "...");
});

All remote boundary runtime types live in Pragmatic.Composition.Host/Remote/:

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);
}

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);
}

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:

CodeMeaning
REMOTE_INVOKE_FAILEDCould not deserialize response
REMOTE_DESERIALIZE_FAILEDResponse received but return value could not be deserialized
REMOTE_UNKNOWN_ERRORError response with no ProblemDetails
REMOTE_ERRORError response with ProblemDetails (code extracted from extensions)

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);
}
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.cs
BillingHostModule.cs
[Module]
[Include<BillingModule, BillingDatabase>]
public sealed class BillingHostModule;
Program.cs
await PragmaticApp.RunAsync(args).ConfigureAwait(false);

The SG generates the /_pragmatic/invoke endpoint that dispatches incoming action requests to local invokers.

MainModule.cs
[Module]
[Include<BookingModule, AppDatabase>]
[RemoteBoundary<BillingModule>]
public sealed class MainModule;
appsettings.json
{
"Pragmatic": {
"RemoteBoundaries": {
"Billing": {
"BaseUrl": "https://localhost:5001"
}
}
}
}

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.

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 HttpClient configuration for now)
IDSeverityDescription
PRAG1685ErrorRemote boundary overlaps with a local [Include]
PRAG1686WarningRemote boundary module has no public actions
PRAG1687ErrorNo base URL configured for remote boundary