Remote Procedure Invocation
Context
Section titled “Context”The Order service needs the buyer’s shipping address, which lives in the Customer service. The most natural thing in the world is to ask for it and wait for the answer, exactly as the old monolith called a getAddress method. The caller wants a single, immediate response so it can keep building the order.
Problem
Section titled “Problem”You need one service to invoke an operation on another and receive a result, with as little ceremony as possible. The team already thinks in terms of methods that take arguments and return values, and the operation is genuinely a question that needs an answer before the caller can continue. How do you make a network call feel like the function call it replaced, while still being explicit about the things a function call never had to worry about — serialization, timeouts, and a contract that both sides agree on?
Solution
Section titled “Solution”Remote Procedure Invocation (RPI) is the synchronous, one-to-one style. The client sends a request to a single service instance and blocks until the response arrives. A proxy or client library hides the mechanics: it serializes the arguments, sends them over the wire, waits, and deserializes the result back into an ordinary return value.
Two transports dominate:
- REST over HTTP — resources addressed by URL, operations expressed with HTTP verbs, payloads usually in JSON. Human-readable, ubiquitous, easy to test with a browser or
curl. - gRPC — a contract defined in a
.protofile, binary Protobuf payloads over HTTP/2, with generated client and server stubs. More compact and faster, with a strict, versioned schema and first-class streaming.
Whatever the transport, the interaction is the same shape: one request, one response, the caller waiting in between.
sequenceDiagram
participant C as Client (Order Service)
participant S as Customer Service
C->>S: GET /customers/42/address
activate S
S->>S: load customer 42
S-->>C: 200 OK { address }
deactivate S
Note over C: caller was blocked until the response arrived Example
Section titled “Example”A typed client for fetching a customer’s shipping address. The client wraps the transport so the caller writes something close to an ordinary method call, but a timeout makes the network’s existence explicit. Each example is self-contained and idiomatic to its language.
type Address = { line1: string; city: string; postalCode: string };
class CustomerClient { constructor(private baseUrl: string) {}
async getAddress(customerId: string): Promise<Address> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 2000); try { const res = await fetch(`${this.baseUrl}/customers/${customerId}/address`, { signal: controller.signal, }); if (!res.ok) { throw new Error(`customer service returned ${res.status}`); } return (await res.json()) as Address; } finally { clearTimeout(timer); } }}
const customers = new CustomerClient('http://customer-service');const address = await customers.getAddress('42');from dataclasses import dataclassimport httpx
@dataclassclass Address: line1: str city: str postal_code: str
class CustomerClient: def __init__(self, base_url: str) -> None: self._base_url = base_url
async def get_address(self, customer_id: str) -> Address: async with httpx.AsyncClient(timeout=2.0) as client: res = await client.get(f"{self._base_url}/customers/{customer_id}/address") res.raise_for_status() body = res.json() return Address(body["line1"], body["city"], body["postalCode"])
customers = CustomerClient("http://customer-service")address = await customers.get_address("42")type Address struct { Line1 string `json:"line1"` City string `json:"city"` PostalCode string `json:"postalCode"`}
type CustomerClient struct { baseURL string http *http.Client}
func NewCustomerClient(baseURL string) *CustomerClient { return &CustomerClient{baseURL: baseURL, http: &http.Client{Timeout: 2 * time.Second}}}
func (c *CustomerClient) GetAddress(ctx context.Context, customerID string) (*Address, error) { url := fmt.Sprintf("%s/customers/%s/address", c.baseURL, customerID) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) res, err := c.http.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("customer service returned %d", res.StatusCode) } var addr Address if err := json.NewDecoder(res.Body).Decode(&addr); err != nil { return nil, err } return &addr, nil}use std::time::Duration;use serde::Deserialize;
#[derive(Deserialize)]struct Address { line1: String, city: String, #[serde(rename = "postalCode")] postal_code: String,}
struct CustomerClient { base_url: String, http: reqwest::Client,}
impl CustomerClient { fn new(base_url: impl Into<String>) -> Self { let http = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build() .expect("client"); Self { base_url: base_url.into(), http } }
async fn get_address(&self, customer_id: &str) -> Result<Address, reqwest::Error> { let url = format!("{}/customers/{customer_id}/address", self.base_url); self.http.get(url).send().await?.error_for_status()?.json().await }}Resulting context
Section titled “Resulting context”What you gain:
- Simplicity. The model is the one every developer already knows: call, wait, get a result. There is no broker to operate and no intermediate state to track.
- A clear contract. Especially with gRPC, the request and response types are defined once and enforced for both sides, so a mismatch is caught at build time.
What it costs you:
- Temporal coupling. Because the caller blocks, both services must be available at the same instant. If the callee is down or deploying, the caller’s request fails right now — there is no buffer in between.
- Cascading failures. A slow callee makes the caller slow too; the caller’s threads or connections pile up waiting, and the slowness propagates upstream until an entire chain of services is stuck. A single struggling service can take down everything that depends on it.
- Reduced availability. The overall availability of a synchronous call chain is the product of each link’s availability, so the more hops a request makes, the more fragile it becomes.
Because of these failure modes, never make a raw synchronous call without protecting it. Wrap it with timeouts, retries, and a Circuit Breaker so a failing dependency fails fast instead of dragging the caller down with it. Where the interaction does not truly need an immediate answer, prefer Messaging instead.
Related patterns
Section titled “Related patterns”- Messaging — the asynchronous alternative that removes temporal coupling.
- Service Discovery — how the client finds the address it sends the request to.
- API Gateway — often the front door through which external RPI calls arrive.