Skip to content
Blog

API Design Best Practices: REST, RPC, and Beyond

A practical guide to designing APIs that scale: REST resource modeling, HTTP semantics, gRPC and protocol buffers, and when GraphQL, JSON-RPC, or tRPC fit better, with runnable FastAPI and gRPC examples.

Published on August 12, 2026

AI Assistant

Every team eventually builds an API, and the difference between a joy to consume and a constant source of tickets is almost never the framework you pick. It is the set of conventions you follow: plural nouns instead of verbs, status codes clients can branch on, contracts that are discoverable, and a versioning story that does not break the web. This post is a code-first tour of API design best practices across the two big families — resource-oriented REST and operation-oriented RPC — plus the modern alternatives (GraphQL, JSON-RPC, tRPC) and when each genuinely wins. Key technologies covered: HTTP and status codes, OpenAPI, Protocol Buffers, gRPC, GraphQL, and tRPC.

Prerequisites

  • Basic understanding of HTTP requests and responses; the MDN HTTP docs are my reference below.
  • Python 3.10+ with fastapi, uvicorn, grpcio, and grpcio-tools for the runnable examples, plus protoc to generate gRPC code.

REST: Design Resources, Not Endpoints

REST is resource-oriented: the URL names a noun, the HTTP method is the verb, and the server returns a representation. Follow these rules and your API becomes predictable enough that developers guess the next endpoint without reading docs.

1. Nouns, Plural Collections, Shallow Nesting

# Good — nouns in URLs, methods express the action
GET    /v1/users
POST   /v1/users
GET    /v1/users/{id}
PATCH  /v1/users/{id}
DELETE /v1/users/{id}

# Bad — verbs leak into URLs
GET  /getUser?id=123
POST /createUser
POST /deleteUser?id=123

Nest only true parent-child relationships and keep it to two levels. GET /users/{id}/addresses is fine; GET /orgs/{o}/users/{u}/orders/{i}/items is a nightmare. Use kebab-case for multi-word resources (/blog-posts) and reserve query parameters for filtering, sorting, and sparse fields: GET /orders?status=pending&sort=-total&fields=id,total.

2. Status Codes Are Part of the Contract

Clients branch on status codes, so use the narrowest code that fits: 200 for reads, 201 Created when POST creates a resource (with a Location header), 204 for deletes. Errors: 400 malformed, 401 unauthenticated, 403 not allowed, 404 missing, 429 rate-limited — per the MDN status code reference. Never return 200 for an error.

3. Versioning

Version from day one. URL path versioning (/v1/users) is the safest, most transparent default; header (Accept: application/vnd.example.v1+json) and query-param versioning are alternatives but easier to get wrong. Whatever you choose, treat every version as immutable — add a /v2 rather than mutating /v1.

4. Pagination

Collections need pagination. Offset (?offset=0&limit=20) is fine for small, stable datasets; cursor pagination is right for large, frequently-changing ones. Return an opaque token so clients never guess page numbers:

{ "data": [{ "id": 9, "title": "Atomic Habits" }], "next_cursor": "caret0" }

5. HATEOAS: A Spectrum, Not a Binary

HATEOAS means responses carry links describing the next actions: {"id": 42, "status": "shipped", "links": {"cancel": "/v1/orders/42/cancel"}}. A full hypermedia engine is overkill for most teams; the pragmatic middle ground is clean resources plus an OpenAPI document for discoverability, adding links only where workflows genuinely branch.

RPC: gRPC, Protocol Buffers, and Streaming

Where REST models data, RPC models actions. gRPC is a high-performance RPC framework that runs on HTTP/2 and uses Protocol Buffers as both its IDL and wire format. Define a service once in a .proto file and protoc generates type-safe clients and servers in many languages:

syntax = "proto3";

package books;

service BookService {
  rpc GetBook (BookRequest) returns (Book);
  rpc ListBooks (Empty) returns (stream Book);
}

message BookRequest { string id = 1; }
message Empty {}
message Book {
  string id = 1;
  string title = 2;
  string author = 3;
}

The stream keyword unlocks gRPC’s biggest advantage. Per the gRPC core concepts docs, there are four call types: unary, server streaming, client streaming, and bidirectional. Protobuf’s binary encoding keeps payloads small, HTTP/2 multiplexes many calls over one connection, and the strict schema means a client never silently drifts from the server. Trade-offs: browsers cannot call raw gRPC (you need gRPC-Web or a transcoding gateway), and binary payloads are harder to debug than JSON.

Modern Alternatives

  • GraphQL: a single endpoint where clients query exactly the shape they need. Great when many client teams with different data needs hit one complex domain, and introspection makes the schema self-documenting. Cost: server-side complexity and weaker HTTP caching. See the GraphQL learn section.
  • JSON-RPC 2.0: a lightweight, transport-agnostic protocol — requests are objects with method, params, and id, supporting notifications and batches. This is why MCP and many agent/tool protocols use it: human-readable, dead simple, a natural fit for action-shaped workflows. See the JSON-RPC 2.0 specification.
  • tRPC: end-to-end typesafe RPC for TypeScript: write plain functions on the server, and the client gets inferred types with zero code generation or schema duplication — the compiler is the contract. Ideal for TypeScript monorepos. Start with the tRPC quickstart.

Choosing: A Comparison Table

DimensionRESTgRPCGraphQLtRPCJSON-RPC
StyleResourcesActionsQuery languageActionsActions
Wire formatJSONProtobuf (binary)JSONJSONJSON
ContractOpenAPI (optional).proto (mandatory)Schema (SDL)TS typesNone
StreamingLimitedNative, bidirectionalSubscriptionsSubscriptionsNotifications/batches
Browser supportNativeNeeds proxyNativeNativeNative
Best forPublic, cacheable APIsInternal microservicesFlexible client queriesTS monoreposAgent/tool protocols

Putting It All Together

The most common production pattern in 2026 is hybrid: REST + OpenAPI for the public, browser-facing surface, gRPC for internal service-to-service calls. Here is a minimal FastAPI REST endpoint that generates OpenAPI docs automatically, plus a gRPC service smoke test.

FastAPI REST endpoint with OpenAPI

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Books API", version="1.0")

class Book(BaseModel):
    id: int
    title: str
    author: str

BOOKS: list[Book] = []

@app.get("/v1/books", response_model=list[Book])
def list_books():
    return BOOKS

@app.post("/v1/books", response_model=Book, status_code=201)
def create_book(book: Book):
    new_book = Book(id=len(BOOKS) + 1, **book.model_dump())
    BOOKS.append(new_book)
    return new_book

@app.get("/v1/books/{book_id}", response_model=Book)
def get_book(book_id: int):
    book = next((b for b in BOOKS if b.id == book_id), None)
    if not book:
        raise HTTPException(status_code=404, detail="Book not found")
    return book

Run it with uvicorn main:app --reload and open http://localhost:8000/docs — interactive OpenAPI documentation for free. Best practice in action: the code is the contract, always in sync.

gRPC service smoke test

Generate stubs, then run this client against a server on port 50051:

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. books.proto
# smoke_test.py
import grpc
import books_pb2
import books_pb2_grpc

channel = grpc.insecure_channel("localhost:50051")
client = books_pb2_grpc.BookServiceStub(channel)

reply = client.GetBook(books_pb2.BookRequest(id="1"))
print(f"Book: {reply.title}")

for book in client.ListBooks(books_pb2.Empty()):
    print(f"Streamed: {book.title}")

Run python smoke_test.py; it prints Book: Atomic Habits and streams Streamed: Deep Work.

Conclusion & Next Steps

There is no single best API style — there is a best fit per boundary. REST with OpenAPI remains the right default for public, cacheable APIs; gRPC wins for high-throughput, streaming, strongly-typed internal services; and GraphQL, JSON-RPC, or tRPC cover the gaps when query flexibility, tooling, or end-to-end type safety matter more. Whatever you choose, the best practices are the same: consistent naming, correct status codes, versioning from day one, pagination on every collection, and a machine-readable contract.

Next, dive into the OpenAPI Specification repository, the gRPC introduction, or the MDN guides on caching and conditional requests to make your endpoints fast and correct.

References / Sources