Fundamental #
Almost all modern software systems communicate through APIs — mobile apps call backends, microservices call other services, frontends request data from servers, internal systems integrate different tools. But many engineers use APIs every day without truly understanding why they’re designed that way, why there are so many types, and how to choose the right one for different contexts. This article builds understanding from the ground up: what an API is, how it evolved, what principles make it good, and when to use REST, GraphQL, gRPC, JSON-RPC, or tRPC.
What Is an API? #
An API, short for Application Programming Interface, is a communication contract between systems. It defines what can be accessed, how to access it, the data format sent and received, and the limits of the interaction.
The most important keywords are contract and interface — not implementation. The implementation may change behind the scenes; the contract doesn’t (or changes as rarely as possible). API consumers don’t need to know how data is stored, how business logic runs, or which libraries are used — they only need to know the contract.
An illustration of the API’s position in a system architecture:
flowchart TD
Client["Client / Consumer\n(Browser, Mobile, Other Service)"]
API["API Layer\n(Contract & Boundary)"]
BL["Business Logic\n(Service / Domain)"]
DS["Data Source\n(DB / Cache / Message Queue)"]
Client -->|"Request\n(HTTP / RPC / Query)"| API
API -->|"Call / Command"| BL
BL -->|"Read / Write"| DS
DS -->|"Data"| BL
BL -->|"Result"| API
API -->|"Response\n(Data / Error)"| Client
style API fill:#4A90D9,color:#fff,stroke:#2C6FACOne important thing from this diagram: the client doesn’t interact directly with business logic or the database. The API acts as a boundary — it protects the internal system from the outside world, and protects consumers from internal changes they don’t need to know about.
The History of API Evolution #
Understanding API history helps understand why the various API types that exist today were each designed to solve different problems — not because newer is always better, but because different contexts need different solutions.
timeline
title API Evolution
1960 : Library & Function Call
: Communication within one process
: C standard library
1980 : RPC & Distributed Systems
: Machine-to-machine communication
: CORBA, DCOM
: Tightly coupled, complex
2000 : HTTP APIs & REST
: Stateless, web-scale
: High interoperability
: Roy Fielding, 2000
2012 : GraphQL
: Facebook internal, 2012
: Open-sourced 2015
: Solves REST over-fetching
2015 : gRPC
: Google, HTTP/2 + Protobuf
: High performance
: Service-to-service
2020 : tRPC
: TypeScript ecosystem
: End-to-end type safety
: Fullstack monorepoThe Early Era: Libraries and Function Calls (1960–1980) #
The first APIs weren’t running over networks — they were function calls within a single system. The C standard library is an API: developers call printf() without needing to know how output is rendered to the terminal. The concept is exactly the same as modern APIs: hide the implementation, expose the interface.
The problem: these APIs only worked within one environment, one process, one machine.
The Distributed Systems and RPC Era (1980–1990) #
The need for machine-to-machine communication gave birth to RPC (Remote Procedure Call) — allowing code on machine A to call functions running on machine B as if local. CORBA and DCOM were the popular implementations of this era.
The problems: tightly coupled (consumers and providers had to use the same framework), complex, hard to debug, and didn’t work well over unreliable networks.
The Web and HTTP API Era (2000–present) #
HTTP, originally designed for web documents, turned out to be a perfect protocol for APIs: stateless, text-based, easy to debug, and working on any network. Roy Fielding formalized these principles as REST in his 2000 dissertation.
The impact was transformative: anyone could build APIs consumable by anyone, in any programming language, from anywhere in the world.
The Modern Era: Mobile, Microservices, and Realtime (2010–present) #
Larger scale brought new problems. Mobile apps with limited bandwidth dislike over-fetching data. Hundreds of communicating microservices need a more efficient protocol than HTTP/JSON. Fullstack TypeScript teams want end-to-end type safety without schema duplication.
The result: GraphQL, gRPC, and tRPC — each emerging to answer specific problems REST didn’t solve well.
Five Fundamental Principles of Good APIs #
1. Contract First #
An API is an agreement. Before writing a single line of implementation, the contract must be defined first: which endpoints exist, what parameters they accept, what responses they return, and what errors may occur.
// ANTI-PATTERN: Implementation first
Writing code first, then API documentation afterwards
→ The API reflects implementation details, not consumer needs
→ The contract changes every time the implementation changes
→ Consumers have no stable foundation
// CORRECT: Contract first
Define the API contract first (OpenAPI spec, Protobuf schema, GraphQL SDL)
→ Consumers and providers can work in parallel based on the contract
→ Implementation is free to change as long as the contract is met
→ Breaking changes are detected earlier
2. Abstraction #
An API hides internal details — database structure, libraries used, data storage methods, internal architecture. Consumers only need to know what can be done, not how it’s done.
// ANTI-PATTERN: Leaking internal details
GET /api/mysql_users_table?id=123
Response: { "mysql_id": 123, "created_timestamp_unix": 1706352000 }
→ Consumers know we use MySQL
→ If we migrate to PostgreSQL, field names must change → breaking change
// CORRECT: Clean abstraction
GET /api/users/123
Response: { "id": "usr_123", "created_at": "2024-01-27T10:00:00Z" }
→ Consumers don't know (and don't need to know) which database is used
→ Internals can change without breaking changes
3. Consistency #
A good API feels predictable — if one endpoint behaves a certain way, other endpoints should follow the same pattern. Inconsistency forces consumers to learn every endpoint individually.
// ANTI-PATTERN: Naming and behavior inconsistency
GET /api/getUser/123 → get a user
POST /api/users/create → create a new user
DELETE /api/remove-user/123 → delete a user
Error response 1: { "error": "not found" }
Error response 2: { "message": "User does not exist", "code": 404 }
Error response 3: { "status": "error", "detail": "..." }
// CORRECT: Predictable consistency
GET /api/users/123 → get a user
POST /api/users → create a new user
DELETE /api/users/123 → delete a user
All errors: { "error": { "code": "USER_NOT_FOUND", "message": "..." } }
4. Backward Compatibility #
A mature API tries as hard as possible not to break existing consumers. Every breaking change requires coordination with all parties using the API — and in the context of public APIs, that can mean thousands of consumers.
Strategies for maintaining backward compatibility:
Adding response fields → SAFE (consumers that don't understand new fields can ignore them)
Removing response fields → BREAKING CHANGE
Changing field types → BREAKING CHANGE
Adding required parameters → BREAKING CHANGE
Changing endpoint behavior → DEPENDS on the existing contract
If a breaking change is unavoidable → versioning:
/api/v1/users → old behavior, still supported
/api/v2/users → new behavior
5. Explicit Over Implicit #
A good API prefers verbose-but-clear over concise-but-ambiguous. Endpoint names, parameters, and fields must be self-explanatory — consumers shouldn’t have to read source code or long documentation to understand what’s meant.
// ANTI-PATTERN: Implicit and ambiguous
POST /api/process
Body: { "type": 1, "data": "..." }
→ "process" what? What does "type 1" mean?
// CORRECT: Explicit and clear
POST /api/orders/checkout
Body: { "payment_method": "credit_card", "items": [...] }
→ Purpose is clear, content is clear
API Classification #
By Accessibility #
flowchart LR
subgraph Public["Public API"]
P["Open to everyone\nPublic documentation\nExamples: Twitter API, Stripe API"]
end
subgraph Partner["Partner API"]
PA["Restricted to business partners\nSpecial access agreements\nExamples: B2B integrations"]
end
subgraph Private["Private / Internal API"]
PR["Organization-internal only\nNot exposed outward\nExamples: service-to-service"]
end
Public --> |"Stricter\nrate limiting"| Partner
Partner --> |"More relaxed\nno auth overhead"| Private
style Public fill:#27AE60,color:#fff,stroke:#1E8449
style Partner fill:#F39C12,color:#fff,stroke:#D68910
style Private fill:#2C3E50,color:#fff,stroke:#1A252FThis classification matters because it affects design decisions: public APIs need far more complete documentation, stricter versioning, rate limiting, and more carefully guarded backward compatibility than internal APIs.
By Communication Style #
flowchart TD
subgraph Resource["Resource-based"]
R["Operations on resources\nGET /users/123\nREST"]
end
subgraph Query["Query-based"]
Q["Consumers define the data\nquery { user(id: 123) { name } }\nGraphQL"]
end
subgraph Procedure["Procedure-based"]
P["Call specific procedures\ngetUser(id: 123)\ngRPC, JSON-RPC"]
end
subgraph Contract["Contract-based (Type-safe)"]
C["Type inference across layers\nwithout manual schemas\ntRPC"]
endAPI Implementation Types #
REST #
REST (Representational State Transfer) was introduced by Roy Fielding in his 2000 dissertation. It isn’t a protocol — it’s an architectural style leveraging HTTP semantics: URLs represent resources, HTTP methods represent operations.
REST characteristics:
→ Stateless: every request carries all needed information
→ Resource-oriented: URLs are nouns, not verbs
→ HTTP verbs as operations: GET (read), POST (create), PUT/PATCH (update), DELETE (delete)
→ Flexible response formats, JSON most common
Correct examples:
GET /users → list all users
GET /users/123 → get user with id 123
POST /users → create a new user
PATCH /users/123 → partially update user 123
DELETE /users/123 → delete user 123
Incorrect examples (not REST):
GET /getUsers → verb in the URL
POST /users/create → verb in the URL
POST /deleteUser/123 → delete operation via POST
// CORRECT: Proper HTTP status codes
200 OK → request succeeded, data present
201 Created → new resource successfully created
204 No Content → succeeded, no data returned
400 Bad Request → invalid input
401 Unauthorized→ not authenticated
403 Forbidden → authenticated, but no access
404 Not Found → resource not found
422 Unprocessable → business validation failed
500 Internal Error → unexpected server error
// ANTI-PATTERN: Always returning 200
HTTP 200 OK
Body: { "success": false, "error": "User not found" }
→ Consumers must parse the body to know success or failure
→ Can't leverage HTTP infrastructure (cache, proxy, monitoring)
When to use REST: public APIs, mobile backends, web backends, third-party integrations, systems needing cacheability. REST is the safe default choice for the majority of use cases.
Limitations: over-fetching (responses contain data not all needed) and under-fetching (needing multiple requests to get related data).
GraphQL #
GraphQL was developed internally at Facebook in 2012 to solve a real problem they faced: a news feed needing data from many different sources, with mobile apps that have limited bandwidth and don’t want to receive unnecessary data.
sequenceDiagram
participant C as Client
participant G as GraphQL API
participant S as Services/DB
Note over C,G: The consumer defines the needed data
C->>G: query { user(id: "123") { name, email, orders { total } } }
G->>S: Fetch user + orders
S-->>G: Data
G-->>C: { user: { name: "Budi", email: "...", orders: [...] } }
Note over C,G: Only the requested data is returnedGraphQL characteristics:
→ Single endpoint: POST /graphql (all operations go through here)
→ Query-driven: consumers define the response shape
→ Strongly typed: the schema defines all available types
→ Introspectable: consumers can query the API schema itself
Example query:
query {
user(id: "123") {
name
email
recentOrders(limit: 5) {
id
total
status
}
}
}
→ The client only gets name, email, and the 5 most recent orders
→ Not the entire user profile plus the full order history
When to use GraphQL: frontend-heavy applications with diverse data needs across components, mobile apps with limited bandwidth, products with many different client types (web, iOS, Android) with different data needs.
Limitations: server-side complexity increases (resolvers, N+1 query problems, schema management), caching is harder because everything goes through POST, overkill for simple APIs.
gRPC #
gRPC was developed by Google and designed from the start for one specific use case: high-performance service-to-service communication. It uses Protocol Buffers (Protobuf) as its serialization format — binary, not text — and HTTP/2 as the transport.
sequenceDiagram
participant A as Service A
participant B as Service B
Note over A,B: Binary communication via HTTP/2
A->>B: GetUser(UserRequest{id: "123"}) [binary]
B-->>A: UserResponse{name: "Budi", ...} [binary]
Note over A,B: Far smaller and faster than JSON/HTTPExample .proto file (the gRPC contract):
syntax = "proto3";
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc ListUsers (ListUsersRequest) returns (stream UserResponse);
rpc CreateUser (CreateUserRequest) returns (UserResponse);
}
message GetUserRequest {
string id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
gRPC vs REST advantages (performance):
Payload size: Protobuf ~3-10x smaller than JSON
Serialization: Binary ~5-7x faster than JSON parsing
Connection: HTTP/2 multiplexing, one connection for many requests
Streaming: Native support for server-streaming, client-streaming, bidirectional
When to use gRPC: service-to-service communication in microservices, systems with very high throughput, use cases needing streaming (log streaming, real-time data feeds), internal backends that don’t need direct browser access.
Limitations: not human-readable (binary), less suited for public APIs, limited browser support (needs a gRPC-Web proxy), more complex debugging tooling.
gRPC isn’t a replacement for REST — it’s a tool for different use cases. Use REST for APIs consumed by browsers or external parties, and gRPC for internal service-to-service communication needing high performance.
JSON-RPC #
JSON-RPC is an evolution of classic RPC using JSON as the data format. Unlike REST’s resource orientation, JSON-RPC is procedure-oriented: you call functions with explicit names, rather than operating on resources.
JSON-RPC request format:
{
"jsonrpc": "2.0",
"method": "user.getById",
"params": { "id": "123" },
"id": 1
}
JSON-RPC response format (success):
{
"jsonrpc": "2.0",
"result": { "id": "123", "name": "Budi" },
"id": 1
}
JSON-RPC response format (error):
{
"jsonrpc": "2.0",
"error": { "code": -32601, "message": "Method not found" },
"id": 1
}
When to use JSON-RPC: legacy system integration, simple internal tooling, use cases where a procedure-call model is more natural than a resource model (for example: blockchain.sendTransaction, calculator.compute). JSON-RPC is widely used in Web3 protocols and the Ethereum JSON-RPC API.
Limitations: doesn’t leverage HTTP semantics (no meaningful status codes, not cache-friendly), no standard URL structure, less suited for modern public APIs.
tRPC #
tRPC emerged from a specific need in the TypeScript ecosystem: fullstack developers wanting end-to-end type safety between backend and frontend without manually defining schemas twice.
The problem tRPC solves:
Without tRPC (REST + TypeScript):
Backend: defines interface User { id: string; name: string }
API documentation: defines it again in an OpenAPI spec
Frontend: defines type User { id: string; name: string } again
→ Three places for the same definition → easy to go out of sync
With tRPC:
The backend defines a router in TypeScript
The frontend directly uses the same types via type inference
→ Zero schema duplication, end-to-end type safety
// Backend (server)
package main
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// getUser validates the input, fetches the user, and returns it.
// the returned struct doubles as the shared type definition
func getUser(id string) (User, error) {
if id == "" {
return User{}, fmt.Errorf("id cannot be empty")
}
return db.FindUser(id)
}
When to use tRPC: fullstack TypeScript applications (Next.js + Node.js), monorepos where frontend and backend live in one codebase, teams wanting a very smooth developer experience without schema management overhead.
Limitations: TypeScript ecosystem only (language-agnostic isn’t possible), not suited for public APIs (consumers from other languages can’t use it), vendor lock-in to the tRPC framework.
Comparison and Selection Guide #
flowchart TD
Start("[I need to build an API]") --> Q1{"Consumed\nby whom?"}
Q1 -->|External /\npublic parties| Q2{"Need flexible\nqueries?"}
Q1 -->|Internal service\nto service| Q3{"Need performance\nand streaming?"}
Q1 -->|TypeScript frontend\nin one repo| tRPC[tRPC]
Q2 -->|Yes, frontend\nwith many variations| GraphQL[GraphQL]
Q2 -->|No, queries\nrelatively standard| REST[REST]
Q3 -->|Yes, high\nthroughput| gRPC[gRPC]
Q3 -->|No, tool/\nlegacy integration| JSONRPC[JSON-RPC]
style REST fill:#27AE60,color:#fff,stroke:#1E8449
style GraphQL fill:#E91E8C,color:#fff,stroke:#C2185B
style gRPC fill:#4285F4,color:#fff,stroke:#2C6FAC
style JSONRPC fill:#FF9800,color:#fff,stroke:#F57C00
style tRPC fill:#2596BE,color:#fff,stroke:#1A7A9E| Criterion | REST | GraphQL | gRPC | JSON-RPC | tRPC |
|---|---|---|---|---|---|
| Best for | Public APIs, Web/Mobile backends | Frontend-heavy, Multi-client | Internal microservices | Legacy, Internal tools | Fullstack TypeScript |
| Data format | JSON (usually) | JSON | Protobuf (binary) | JSON | JSON |
| Protocol | HTTP/1.1+ | HTTP/1.1+ | HTTP/2 | HTTP | HTTP |
| Type safety | Manual / OpenAPI | Schema-based | Protobuf schema | None | Automatic end-to-end |
| Caching | Easy (HTTP cache) | Hard (all POST) | Hard | Hard | Medium |
| Browser support | Native | Native | Needs proxy | Native | Native |
| Learning curve | Low | Medium | High | Low | Low (if TypeScript) |
| Documentation | OpenAPI/Swagger | Introspection | .proto file | Manual | Type inference |
API Anti-Patterns to Avoid #
// ✗ Anti-pattern 1: Verbs in URLs (REST)
POST /api/createUser
POST /api/deleteUser/123
GET /api/getUserById?id=123
// ✓ Solution: Noun-based URLs, HTTP methods as verbs
POST /api/users
DELETE /api/users/123
GET /api/users/123
// ✗ Anti-pattern 2: Leaking internal details
GET /api/mysql_users?table=users&limit=10
Response: { "mysql_row_id": 123, "db_created_at": 1706352000 }
// ✓ Solution: Clean abstraction
GET /api/users?limit=10
Response: { "id": "usr_123", "created_at": "2024-01-27T10:00:00Z" }
// ✗ Anti-pattern 3: Always returning HTTP 200
HTTP 200 OK
{ "success": false, "error": "Unauthorized", "code": 401 }
// ✓ Solution: Semantically correct HTTP status codes
HTTP 401 Unauthorized
{ "error": { "code": "UNAUTHORIZED", "message": "Invalid token" } }
// ✗ Anti-pattern 4: Breaking changes without versioning
Changing the "user_name" field to "name" directly in v1
→ All consumers using "user_name" break immediately
// ✓ Solution: Versioning or deprecation periods
/api/v1/users → still returns "user_name" (deprecated)
/api/v2/users → returns "name" (new)
Provide a deprecation notice and a sufficient migration period
// ✗ Anti-pattern 5: No contract documentation
"Just look at the code to know the format"
// ✓ Solution: Explicit, documented contracts
REST: OpenAPI / Swagger spec
GraphQL: SDL (Schema Definition Language) that can be introspected
gRPC: .proto files committed to the repository
Healthy API Checklist #
CONTRACT DESIGN:
□ API contract defined before implementation (contract-first)
□ Endpoint names, parameters, and fields explicit and self-explanatory
□ Consistency maintained: naming conventions, error format, response format
□ Internal details don't leak into the contract
VERSIONING AND COMPATIBILITY:
□ Versioning strategy defined before the API is released
□ Breaking changes never done without versioning
□ A sufficient deprecation period given before an endpoint is removed
□ Changelog updated with every change
DOCUMENTATION:
□ Documentation synced with implementation (ideally auto-generated)
□ Every endpoint has a description, parameters, and response examples
□ Error codes and their meanings documented
□ Authentication and authorization requirements clear
SECURITY:
□ Authentication applied to all endpoints that need it
□ Authorization checked at the endpoint level (not just in middleware)
□ Input validated before processing
□ Rate limiting applied (especially for public APIs)
□ Sensitive data not returned in unnecessary responses
API TYPE SELECTION:
□ REST for public APIs and the majority of use cases
□ GraphQL only when there's a real need for query flexibility
□ gRPC only for internal service-to-service with performance needs
□ tRPC only for fullstack TypeScript within one codebase
□ Choices based on needs, not hype
Summary #
- An API is a contract, not an implementation — implementations may change; contracts don’t (or change as rarely as possible). Always design APIs from the consumer’s perspective, not from the internal implementation’s perspective.
- Contract-first is the most important principle — define the contract before writing code. This lets consumers and providers work in parallel and catches breaking changes earlier.
- Abstraction protects both parties — consumers don’t need to know internal details, and providers are free to change implementations without breaking consumers.
- Consistency is an investment — consistent APIs drastically lower consumer cognitive load. Naming, error formats, and behavior must be predictable.
- Backward compatibility is a responsibility — every breaking change requires coordination. Versioning is a tool, not a magic solution; prevention beats versioning.
- REST isn’t the only choice, but is often the best — for the majority of use cases (public APIs, web/mobile backends), REST is the easiest to understand, has the most tooling, and is the most cache-friendly.
- GraphQL for query flexibility, not for every API — GraphQL adds significant server complexity. Use it only when there’s a real need for flexible queries from multiple different clients.
- gRPC for service-to-service performance — the binary protocol and HTTP/2 give significant performance advantages, but with trade-offs in tooling, debugging, and browser support.
- tRPC for fullstack TypeScript developer experience — end-to-end type safety without schema duplication is a real advantage, but only relevant within the TypeScript monorepo ecosystem.
- Choose by needs, not hype — the right API technology is the one best matching the context: who the consumers are, what they need, and what the constraints are.
#
Next: REST- An API is a contract, not an implementation — implementations may change; contracts don’t (or change as rarely as possible). Always design APIs from the consumer’s perspective, not from the internal implementation’s perspective.
- Contract-first is the most important principle — define the contract before writing code. This lets consumers and providers work in parallel and catches breaking changes earlier.
- Abstraction protects both parties — consumers don’t need to know internal details, and providers are free to change implementations without breaking consumers.
- Consistency is an investment — consistent APIs drastically lower consumer cognitive load. Naming, error formats, and behavior must be predictable.
- Backward compatibility is a responsibility — every breaking change requires coordination. Versioning is a tool, not a magic solution; prevention beats versioning.
- REST isn’t the only choice, but is often the best — for the majority of use cases (public APIs, web/mobile backends), REST is the easiest to understand, has the most tooling, and is the most cache-friendly.
- GraphQL for query flexibility, not for every API — GraphQL adds significant server complexity. Use it only when there’s a real need for flexible queries from multiple different clients.
- gRPC for service-to-service performance — the binary protocol and HTTP/2 give significant performance advantages, but with trade-offs in tooling, debugging, and browser support.
- tRPC for fullstack TypeScript developer experience — end-to-end type safety without schema duplication is a real advantage, but only relevant within the TypeScript monorepo ecosystem.
- Choose by needs, not hype — the right API technology is the one best matching the context: who the consumers are, what they need, and what the constraints are.