RPC: Operation-Oriented Contracts
RPC contracts are lists of operations — UserService.GetUser, InventoryService.ReserveInventory — rather than resources with methods. When the domain is a set of commands between services, that is clearer than bending them into nouns; it costs caching, discoverability and verb discipline.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
A contract of operations
RPC — remote procedure call — makes the contract a list of named operations with typed inputs and outputs. InventoryService.ReserveInventory(sku, quantity, reservation_id) → Reservation says exactly what it does, what it needs and what it returns. There is no resource to locate, no method to choose, no URL to construct; the client calls a function. For service-to-service traffic where the caller knows precisely which operation it wants, that directness is the whole appeal.
The style fits domains that are genuinely operation-shaped. Pricing a cart, running a fraud check, reserving stock, emitting a payment result are commands with arguments, and modeling them as nouns produces contortions (POST /price-computations) that a resource-oriented style tolerates but nobody enjoys (The "REST Purity" Anti-Pattern). RPC also fits when both sides share a build pipeline: the operation list becomes an interface definition, clients are generated, and a mismatch fails at compile time instead of in production (gRPC: Schema, Codegen and Streams, Schema-First vs Code-First).
What an RPC contract must say explicitly is everything HTTP methods said implicitly. Is ReserveInventory safe to retry? Does GetUser have side effects? Which operations are idempotent, which need a key (Idempotency Keys: The Mechanism)? In REST the method carries that promise; in RPC the operation's documentation and naming must, and a contract that omits it has silently become worse than the REST it replaced.
service InventoryService {
// Safe, idempotent, cacheable for 5s by the caller.
GetStockLevel(GetStockLevelRequest) → StockLevel
// NOT idempotent by default; pass reservation_id to make retries safe.
// Errors: OUT_OF_STOCK (final), SKU_UNKNOWN (final), UNAVAILABLE (retry w/ backoff)
ReserveInventory(ReserveInventoryRequest) → Reservation
// Idempotent: releasing a released reservation is a no-op success.
ReleaseReservation(ReleaseReservationRequest) → Empty
}What the operation shape costs
Caching goes first. HTTP caches key on method and URL; an RPC call is a POST to one endpoint with the operation in the body, invisible to every cache between client and server (Caching as a Contract Clause). Read-heavy public traffic that a CDN would have absorbed hits the service every time. Internal traffic rarely needed that cache, which is why RPC's home is service-to-service and not the public edge.
Discoverability goes next. A resource-oriented API is browsable: GET /orders leads to /orders/{id} leads to /orders/{id}/cancellations. An RPC surface is a phrasebook — GetOrder, ListOrders, CancelOrder, GetOrderCancellation — with no structure beyond naming discipline. Without it, verbs sprawl: GetUser, FetchUserProfile, LoadUserWithOrders accumulate as each caller asks for its own variant, and the operation list becomes the chatty, inconsistent surface REST's uniformity was designed to prevent (One Vocabulary: Naming and Consistency).
Observability is neutral-to-good with real RPC frameworks (per-method metrics come free) and terrible with ad-hoc RPC-over-HTTP where everything is POST /api returning 200 (API Metrics: Rate, Errors, Duration, Sizes). Browser and partner friendliness is the last cost: no curl-able URLs, generated clients required, binary framing in gRPC's case — the public-friendliness column from Which API Style Should I Use?.
| Axis | Resource-oriented (REST) | Operation-oriented (RPC) |
|---|---|---|
| Fits when | Consumers navigate and act on things; unknown callers | Consumers invoke known commands; both ends share tooling |
| Retry/side-effect promise | Carried by the method | Must be stated per operation |
| HTTP caching | Native for GET | None; caller-side caching only |
| Discoverability | Browsable via links | A phrasebook; needs naming discipline |
| Type contract | By docs unless OpenAPI wired in | Typically schema-first with codegen |
| Verb sprawl risk | Low — uniform interface | High — one operation per caller wish |
| Public/browser use | Natural | Needs a gateway or transcoding |
Keeping an RPC surface honest
Three disciplines make RPC surfaces age well. Name operations from a small verb vocabulary (Get, List, Create, Update, Delete, plus domain commands) applied uniformly, so ListOrders and ListInvoices behave alike — including their pagination contract (Pagination: Choosing How Lists End), which RPC does not get for free either. State idempotency and error sets per operation in the definition file, where reviewers and generators see them (An Error Taxonomy Clients Can Branch On). And resist the per-caller variant: when a caller needs a different shape, prefer optional field masks or a purpose-built aggregation at the boundary (Backend for Frontend) over GetUserForCheckout.
Where RPC lives — behind the edge, between owned services — a gateway can still present the public face as resources. Transcoding GetOrder to GET /orders/{id} restores caching and curl for external consumers while internal callers keep typed operations (The Gateway as Policy Boundary). That split, RPC inside and resources outside, is the common mature shape rather than a compromise.
- Fixed verb vocabulary applied uniformly across services.
- Idempotency and error set stated on every operation in the definition.
- Field masks or a BFF instead of one operation per caller preference.
- Transcode at the edge so external consumers get resources and caching.
Key points
- RPC makes the contract a list of typed operations; it is clearest when the domain is command-shaped and both ends share tooling.
- Everything an HTTP method promised — safety, idempotency, cacheability — must now be stated per operation.
- The costs are lost HTTP caching, weaker discoverability, verb sprawl and public/browser friction.
- Naming discipline and per-operation contracts are what keep an RPC surface from becoming a phrasebook.
- RPC inside, resources at the edge via transcoding is the common mature split.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → contract: exposes
UserService.GetUserto partners asPOST /apiwith the method name in the body. - 2Partners → integration: cannot curl, cannot cache, cannot find a client library; support tickets ask for REST.
- 3Callers → service: each requests its own variant;
GetUser,GetUserLite,GetUserWithOrdersaccumulate over a year. - 4Client → retry:
ReserveInventorytimes out and is retried; nothing in the contract said it was not idempotent; stock is reserved twice. - 5Operations → dashboard: one endpoint, all 200s; the outage in
ReserveInventoryis invisible until the warehouse calls.
- Retries double side effects because idempotency was never stated per operation.
- Read traffic that caches would have absorbed hits the service on every call.
- The operation list grows a variant per caller and becomes the inconsistent, chatty surface REST avoids.
- External consumers are locked out or forced onto bespoke clients.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Choose RPC for command-shaped, service-to-service domains where both ends share a build pipeline — not for the public edge.
- • State safety, idempotency, retry guidance and the error set on every operation in the interface definition.
- • Enforce a small verb vocabulary and uniform list/pagination semantics across services.
- • Use field masks or boundary aggregation instead of per-caller operation variants.
- • Present a resource-oriented, cacheable face at the edge via transcoding when external consumers exist.
- • Operation counts growing faster than domain concepts signal per-caller variant sprawl.
- • Duplicate side effects after timeouts indicate operations whose idempotency was never declared.
- • Origin read load with zero cache participation on data that rarely changes shows RPC where a cacheable GET belonged.
- • Per-method latency and error metrics absent from dashboards mean the RPC layer is ad hoc rather than a real framework.
- • Operations evolve by adding optional request/response fields; renaming or removing an operation is a breaking change handled like any other ([[backward-compatibility]]).
- • Adopting gRPC from ad-hoc RPC-over-HTTP mostly preserves the operation list while adding schema, codegen and streaming.
- • A public REST facade can be added over an RPC core without changing internal callers.
- • Explicit per-operation promises are more documentation than method semantics required — and are skipped under deadline pressure.
- • Naming discipline needs governance across teams; without an owner the vocabulary drifts.
- • Transcoding gateways are one more component to run and keep in sync with the interface definition.