Skip to content
Kumar Chandrachooda
.NET

Commands Over gRPC, No Proto Files

FastEndpoints.Messaging.Remote executes the same ICommand on another machine over gRPC, serialized with MessagePack instead of protobuf contracts - plus an event hub mode that turns a server into a lightweight broker. Reading it explains both the magic and the trade. Part 12 of FastEndpoints in Depth.

By Kumar Chandrachooda 30 Apr 2026 4 min read
The same command type on both sides of the wire - the channel in between is gRPC

Part 11 ended on a claim that deserves suspicion: the same ICommand you dispatch in-process can execute on another machine, over gRPC, without writing a .proto file, a service class, or an HTTP client. When a library promises distributed computing with no visible contract, the source is the only place to find out what you actually signed. So I read FastEndpoints.Messaging.Remote and its .Core sibling. Here is the deal on offer.

The developer experience

Server — a host that handles commands:

var bld = WebApplication.CreateBuilder();
bld.AddHandlerServer(h => h.ListenAnyIP(6000));  // kestrel http/2 endpoint
var app = bld.Build();
app.MapHandlers(h =>
{
    h.Register<RefundOrder, RefundOrderHandler, RefundStatus>();
});
app.Run();

Client — any app that wants those commands executed remotely:

app.MapRemote("http://payments-svc:6000", c =>
{
    c.Register<RefundOrder, RefundStatus>();
});

// ...anywhere in the client, unchanged from the in-process version:
var status = await new RefundOrder { OrderId = id }.ExecuteAsync(ct);

That last line is the point. Call sites don't know the handler moved to another machine — ExecuteAsync consults a static remote-connection map (command type → connection) before falling back to the local handler registry. Extracting a heavy handler into its own service is a deployment refactor, not a code refactor: move the handler class, add two registration lines, done. That is the concrete microservice-extraction story most frameworks only promise.

How there is no .proto

gRPC normally needs protobuf contracts because the two ends share nothing but the wire. FastEndpoints' remote messaging changes the premise: both ends share the command DTO type — the same assembly reference you would need anyway to construct the command. Given shared types, the contract is derivable. The IMethodBinder/ServiceMethodProvider machinery on the server registers gRPC methods programmatically — the method name is derived from the command's full type name, and the marshaller is MessagePack serialization of the DTO instead of protobuf. The client's RemoteConnection builds matching CallInvoker executors per registered command shape: unary for ICommand<TResult>, server-streaming for IStreamCommand<T>, client-streaming for a whole IAsyncEnumerable of items. Under it all is a standard GrpcChannel — with production-shaped defaults visible in the source:

// Src/Messaging/Messaging.Remote/Client/RemoteConnection.cs (FastEndpoints, MIT)
var socketsHandler = new SocketsHttpHandler
{
    PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
    KeepAlivePingDelay = TimeSpan.FromSeconds(60),
    KeepAlivePingTimeout = TimeSpan.FromSeconds(5),
    EnableMultipleHttp2Connections = true
};

Keep-alive pings, multiple HTTP/2 connections to dodge stream-limit stalls, infinite pooled connection lifetime — someone tuned this against real load balancers. The same file supports Unix domain sockets for same-host IPC (a custom ConnectCallback), which quietly makes this a fast sidecar transport too. You can inject your own HttpMessageHandler for mTLS or service-mesh needs, and per-call gRPC CallOptions (deadlines, auth metadata) pass through ExecuteAsync.

The trade, stated plainly: you swapped a schema contract for a shared-assembly contract. Protobuf's .proto file is annoying precisely because it is language-neutral and evolution-checked. Here, the contract is a .NET type serialized with MessagePack — so both ends must be .NET, must reference compatible DTO versions, and property renames are wire-breaking changes with no compiler or registry to warn you. Version skew during rolling deploys is your problem: add properties rather than rename, deploy servers before clients, and treat command DTOs with the same discipline as any serialized contract (a lesson my VersionKit series beats to death from the database side). For polyglot boundaries or public APIs, this is the wrong tool — it is explicitly for the all-.NET internal mesh where the shared assembly already exists.

Events across machines: the hub

Remote commands are point-to-point. For pub/sub, the package includes an event hub: a server can act as a broker — subscribers connect over the same gRPC channel and receive events as server-streams:

// broker/server side:
app.MapHandlers(h => h.RegisterEventHub<OrderPlaced>(HubMode.EventBroker));

// subscriber side:
app.MapRemote("http://orders-svc:6000", c => c.Subscribe<OrderPlaced, OrderPlacedHandler>());

// publisher (on the broker itself, or relayed to it):
new OrderPlaced { OrderId = id }.Broadcast();

Under the hood each subscriber gets a queue on the hub, events fan out to queues, and a round-robin mode (HubMode.RoundRobin) turns the same machinery into a work-distribution channel — one event consumed by exactly one of N subscribers. Subscriber connections auto-reconnect with backoff; missed-while-disconnected delivery depends on the hub's in-memory queues, so the durability caveat from Part 11 applies between machines too: this is a lightweight broker, not Kafka. The docs and source agree on the honest positioning — for guaranteed delivery you back the hub storage with a persistence provider (the same storage-provider pattern the job queues use, which is next part's subject) or you use a real broker.

Messaging.Remote.Testing rounds it out: WebApplicationFactory-based tests can wire client and server in-memory, so a remote-command round trip runs in a unit-test process — the distributed seam stays testable without docker-compose.

Where I'd use it — and where I did not

The sweet spot is the awkward middle of distributed .NET: you have two or three services that are yours, all .NET, deployed together, and the alternatives are (a) hand-rolled HTTP clients with DTO duplication, (b) full gRPC with proto toolchains, or (c) a message broker you must now operate. Remote commands beat (a) on type safety and boilerplate, beat (b) on ceremony for .NET-to-.NET, and beat (c) on operational weight — for request/response semantics. I used it for a document-generation service extraction: one afternoon, no new infrastructure, call sites untouched.

Where I declined: an integration boundary owned by another team. Shared-assembly contracts couple deploy schedules — the exact coupling schema registries exist to break. And for high-fan-out event distribution with durability requirements, we kept the broker; the event hub's in-memory queues are a convenience tier, not an SLA.

What remote commands don't give you is persistence: a command executes now, on a live connection, or fails. The moment you need “execute this eventually, survive restarts, retry on failure, tell me how it went”, you have left messaging and entered job territory — and FastEndpoints' answer there is one of its best-engineered corners, a storage-agnostic job queue built, once again, on the humble ICommand: Part 13, A Job Queue Is a Command With a Tracking ID.