Stylesrpcoperationsservicescommandsinternal apis

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When is "call this operation with these arguments" a clearer contract than "act on this resource with this method" — and what does the operation-shaped style give up?
Consumers
Internal services calling each other with typed arguments — reserve inventory, price a cart, emit a payment result — where the caller knows exactly which operation it wants and the team controls both ends of the contract.
The promise
Each operation has an explicit name, a typed request, a typed response and a documented error set; callers invoke it like a function and get a result they can branch on — with the operation's idempotency and side effects stated, since the method no longer says them.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

An operation-oriented contract with the promises REST's methods would have carried
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?.

Resource-oriented vs operation-oriented, on the axes that differ
AxisResource-oriented (REST)Operation-oriented (RPC)
Fits whenConsumers navigate and act on things; unknown callersConsumers invoke known commands; both ends share tooling
Retry/side-effect promiseCarried by the methodMust be stated per operation
HTTP cachingNative for GETNone; caller-side caching only
DiscoverabilityBrowsable via linksA phrasebook; needs naming discipline
Type contractBy docs unless OpenAPI wired inTypically schema-first with codegen
Verb sprawl riskLow — uniform interfaceHigh — one operation per caller wish
Public/browser useNaturalNeeds 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.

  1. 1
    Team → contract: exposes UserService.GetUser to partners as POST /api with the method name in the body.
  2. 2
    Partners → integration: cannot curl, cannot cache, cannot find a client library; support tickets ask for REST.
  3. 3
    Callers → service: each requests its own variant; GetUser, GetUserLite, GetUserWithOrders accumulate over a year.
  4. 4
    Client → retry: ReserveInventory times out and is retried; nothing in the contract said it was not idempotent; stock is reserved twice.
  5. 5
    Operations → dashboard: one endpoint, all 200s; the outage in ReserveInventory is invisible until the warehouse calls.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“RPC is just REST with worse URLs.”
Reality
RPC is a different contract model: operations with typed arguments instead of resources with uniform methods. It is clearer for command-shaped domains and worse for browsable, cacheable, public ones.
Claim
“Because RPC uses POST, retries are impossible.”
Reality
Retry safety is a property of the operation, not the transport verb. Operations declared idempotent — or given a client-supplied key — are safely retryable; the failure is omitting the declaration.
Claim
“RPC frameworks solve API design.”
Reality
They solve serialization, codegen and transport. Naming, pagination, error sets, idempotency and evolution are still design work, and RPC surfaces sprawl faster than REST ones when it is skipped.