
π Introduction
APIs are the backbone of modern distributed systems. Over time, API design evolved from ad-hoc HTTP endpoints to strongly typed contracts with dedicated tooling.
Example (Go, the βrawβ approach):
|
|
ποΈ API-First Approach
Before development even starts, the business and product teams should model the contract β not just developers.
This is called the API-First approach:
Define your OpenAPI or Protobuf contract up front.
Use it as the source of truth across teams (backend, frontend, QA).
Generate server stubs, clients, and mocks directly from the contract.
π Why it matters:
Aligns business expectations with implementation.
Reduces misunderstandings and integration surprises.
Enables parallel development (frontend builds against mocks while backend is still in progress).
π OpenAPI & Swagger
OpenAPI (OAS): machine-readable spec for REST APIs.
Swagger: ecosystem of tooling (UI, codegen, validators).
β Benefits:
Contracts as a single source of truth.
Auto-generate docs, clients, and server stubs.
Example OpenAPI snippet (YAML):
|
|
π Swagger UI: renders the spec into interactive docs.
π Try it online: Swagger Editor β design and validate REST APIs in OpenAPI format.
π¦ Protobuf Contracts
Protobuf defines schemas for gRPC services.
Provides strong typing, binary serialization, and evolution with backward compatibility.
Example .proto:
|
|
π Try it online: Buf Schema Registry β design, lint, and validate gRPC/Protobuf APIs in the browser.
π REST
REST (Representational State Transfer) is the most widely used architectural style for building APIs. It relies on standard HTTP methods (GET, POST, PUT, DELETE) and typically exchanges data in JSON format, making it human-readable and easy to debug.
β Pros
Simplicity β easy to implement and consume with any HTTP client.
Widely supported β nearly every language, framework, and browser works with REST out of the box.
Cacheable β HTTP caching mechanisms (ETag, Cache-Control) improve performance.
Good for public APIs β intuitive and accessible for developers.
β οΈ Cons
Over-fetching/under-fetching β clients may receive too much or too little data (can require multiple requests).
No strong typing β JSON payloads are flexible but can lack strict schema enforcement.
Less efficient β repeated HTTP requests, verbose JSON payloads, and HTTP/1.1 overhead.
Limited real-time support β requires polling, long-polling, or add-ons like WebSocket for live updates.
π Example in Go (Gin)
|
|
β‘οΈ This REST endpoint returns a userβs ID and name. Itβs simple and readable, but compared to GraphQL or gRPC, it may require additional requests for related data (e.g., userβs posts).
π GraphQL
GraphQL is a query language and runtime for APIs, designed to give clients exactly the data they need and nothing more. Unlike REST (which often returns fixed payloads), GraphQL lets the client define the shape of the response.
β Pros
Client-driven queries β consumers choose the fields they want, reducing over-fetching and under-fetching.
Single endpoint β no need for multiple REST endpoints; everything is served through one /graphql endpoint.
Strong typing β schema defines all available queries, mutations, and types, which improves tooling and auto-documentation.
Great for frontend teams β they can evolve independently by querying what they need without waiting for backend changes.
β οΈ Cons
Complex server logic β resolvers can be tricky to implement and optimize.
N+1 query problem β naive resolvers may hit the database excessively (can be mitigated with DataLoader or batching).
Caching challenges β harder compared to REST where responses can be cached by URL; requires custom caching strategies.
Security considerations β introspection and deeply nested queries can cause performance or exposure issues if not limited.
Example query:
|
|
β‘οΈ The above query retrieves a user with their id, name, and the title + creation date of their posts β all in a single round trip. In REST, this might require multiple endpoints (/users/123, /users/123/posts).
π WebSocket
WebSocket is a communication protocol that provides full-duplex, bidirectional channels over a single TCP connection. Unlike HTTP, which is request/response-based, WebSockets keep the connection open, making them ideal for real-time applications like chat, gaming, IoT, and live dashboards.
β Pros
Bidirectional β both client and server can send messages anytime.
Low-latency β avoids overhead of repeated HTTP requests.
Real-time capable β great for live updates, streaming, notifications, and collaborative apps.
Lightweight messaging β efficient once the connection is established.
β οΈ Cons
Harder to scale β requires sticky sessions or specialized infrastructure to manage persistent connections.
Stateful connections β unlike stateless HTTP, connections consume server resources continuously.
Less tooling/observability β harder to debug and monitor compared to REST/GraphQL.
Security considerations β need proper authentication and throttling to prevent abuse.
π Example in Go
|
|
β‘οΈ Here, a Go client establishes a WebSocket connection and sends a “hello” message. Unlike REST or gRPC, the connection stays alive and can be reused for sending/receiving multiple messages in real time.
β‘ gRPC
gRPC
is a high-performance, open-source RPC (Remote Procedure Call) framework originally developed at Google. It uses Protocol Buffers (Protobuf) for data serialization and runs over HTTP/2, making it highly efficient for service-to-service communication in distributed systems.
β Pros
High performance β compact Protobuf messages and HTTP/2 multiplexing reduce latency and bandwidth.
Strongly typed contracts β Protobuf schemas act as a single source of truth, enabling auto-generated client/server code in multiple languages.
Streaming support β supports unary (request/response), server-streaming, client-streaming, and bidirectional streaming.
Great for microservices β ideal for internal communication between services in cloud-native environments.
β οΈ Cons
Tooling overhead β requires schema compilation and generated code, which adds build complexity.
Browser limitations β native support is limited; web clients need gRPC-Web or REST/gRPC gateways.
Debugging β binary Protobuf payloads are harder to inspect compared to JSON in REST/GraphQL.
π Example Go Client
|
|
β‘οΈ This example calls a GetUser RPC defined in a Protobuf contract. The client sends a request with Id: “123” and receives a strongly typed response
π Visual Comparison
flowchart TB
REST["REST\n+ Simple\n+ Cacheable\n- Over/Under fetching"]
GraphQL["GraphQL\n+ Flexible queries\n- Complex server"]
WebSocket["WebSocket\n+ Real-time\n- Scaling issues"]
gRPC["gRPC\n+ Fast, typed\n+ Streaming\n- Tooling overhead"]
REST --- GraphQL --- WebSocket --- gRPC
π§ͺ Testing Your APIs
- Robust APIs deserve robust tests. Here are practical workflows:
π§° Toolbox
Contracts: OpenAPI/Swagger (REST), Protobuf (gRPC)
Manual: IntelliJ HTTP Client, IDEA gRPC Client
CLI: curl, grpcurl
GUI: Postman (supports REST, GraphQL, WebSocket, gRPC)
Fail-Fast Pattern
When testing, adopt a fail-fast mindset:
Detect invalid inputs and misconfigurations as early as possible.
Fail the request with clear error messages (e.g., 422 Unprocessable Entity) instead of letting bad data flow deeper.
In CI, stop the pipeline immediately when contract tests or smoke tests fail.
This pattern prevents small mistakes (like missing fields, wrong types, expired tokens) from becoming production outages.
Mocking External Dependencies
Your service rarely lives in isolation. It depends on:
Databases
External APIs (e.g., payment gateways, identity providers)
Message queues / event buses
For reliability, these dependencies should be:
Mocked: fake servers responding to API requests (e.g., Prism for REST, fake gRPC server).
Stubbed: minimal hardcoded responses (fast for unit tests).
Real: integration environment with actual dependencies (used sparingly).
π Testing pyramid for APIs:
Unit tests β use stubs.
Integration tests β use mocks.
E2E tests β hit real dependencies (but keep scope small).
Dependency Graph
flowchart LR
subgraph Service["Your API Service"]
A["Controller / Handler"]
B["Application Logic"]
end
subgraph Dependencies
DB[("Database")]
EXT1["External API\n(Payment Service)"]
EXT2["External API\n(Identity Provider)"]
MQ["Message Queue\n(Kafka/NATS)"]
end
subgraph TestDoubles["Test Doubles"]
M["Mocks\n(simulated servers)"]
S["Stubs\n(fixed responses)"]
end
A --> B
B --> DB
B --> EXT1
B --> EXT2
B --> MQ
%% Test mapping
B -.-> M
B -.-> S
β Use dependency injection in Go (pass interfaces, not concrete clients) β swap between real, mock, and stub implementations easily.
Example (Go interface for payment gateway):
|
|
During tests:
|
|
π Dependency Inversion Principle (DIP) in Practice
DIP:
High-level modules (use cases, services) must not depend on low-level modules (DB clients, HTTP SDKs).
Both should depend on abstractions (interfaces).
Abstractions shouldnβt depend on details; details depend on abstractions.
In Go, that means you define interfaces close to the domain/use case (core), and make adapters (DB, external APIs) implement them. Your wiring (composition root) injects the concrete implementations.
DIP Diagram (who depends on whom)
flowchart LR
subgraph Core["Core (Domain + Application)"]
S["Service / Use Case"]
P["Port Interface\n(e.g., PaymentGateway, UserRepo)"]
end
subgraph Adapters["Adapters (Infrastructure)"]
A1["PostgresUserRepo\nimplements UserRepo"]
A2["StripePayment\nimplements PaymentGateway"]
end
subgraph Drivers["Drivers (Transport)"]
H["HTTP/gRPC Handlers"]
end
%% Dependencies point TOWARD abstractions
H --> S
S --> P
A1 --> P
A2 --> P
Handlers depend on services (core).
Services depend on ports (interfaces).
Adapters implement ports and therefore depend on the interfaces (not the other way around).
Minimal Go example (Ports in core, Adapters implement)
File internal/core/payment/port.go
(β abstraction lives in the core)
|
|
File: internal/core/checkout/service.go
(high-level depends on abstraction)
|
|
File: internal/adapters/stripe/gateway.go
(detail depends on abstraction)
|
|
File: cmd/api/main.go
(composition root: wire details into core)
|
|
Testing with mocks/stubs (DIP makes this trivial)
File: internal/core/payment/stub.go
|
|
File: internal/core/checkout/service_test.go
|
|
DIP Checklist
Define ports (interfaces) in core (domain/application), not in infra.
Adapters implement ports; they import core, not vice versa.
Keep the composition root (wiring) at the edge (e.g., cmd/api/main.go).
In tests, replace adapters with stubs/mocks by injecting port implementations.
Enforce with var _ Port = (*Adapter)(nil) compile-time checks.
Avoid handlers or services importing vendor SDKsβthatβs a DIP smell.
This approach aligns perfectly with API-First and your mock/stub strategy: the contract (OpenAPI/Protobuf) defines shapes at the boundary, while DIP ensures your core stays independent from transport and vendor details.
π§ͺ When to Use Mock vs Stub
Use stubs for deterministic data, mocks for interaction/behavior verification.
Test level | Primary goal | Prefer Stub when⦠| Prefer Mock when⦠|
---|---|---|---|
Unit | Pure logic correctness | You just need canned data (happy/unhappy cases) | You must assert a call happened (args, order, count) |
Integration | Adapter correctness (DB/API/Queue) | You want fast, local tests without spinning dependencies | You need to simulate network errors, timeouts, retries |
E2E / Contract | End-to-end flows & schema compatibility | β | Use mock servers from OpenAPI/Protobuf for contract fit |
Stub (deterministic data for unit tests):
|
|
Mock (verify interaction):
|
|
Tips
Start with stubs for 80% of unit tests (fast, simple).
Use mocks where behavior matters (retry, circuit breaker, idempotency, audit).
In integration tests, you can run a mock server (Prism for REST, test gRPC server) to simulate realistic failures: 429/503, timeouts, malformed payloads.
π§ Code to an Interface, Not an Implementation
- Keep your core independent from frameworks and vendors. Depend on ports (interfaces) you own; inject adapters (details) at the edge.
Principles
Define interfaces in the core (domain/application).
Adapters implement those interfaces and import the core (not vice versa).
Wire everything in a composition root (cmd/*/main.go).
Prefer narrow, behavior-based interfaces (only what the use case needs).
Enforce compile-time checks: var _ Port = (*Adapter)(nil).
Bad (leaky) β core depends on vendor:
|
|
Good (DIP + interface) β core depends on abstraction:
|
|
Adapter implements the port:
|
|
Composition root wires details:
|
|
Interface design checklist
β Define in core (where itβs used), not in infra (where itβs implemented).
β Keep it minimal (YAGNI): expose only methods your use case truly needs.
β Return domain errors or wrap vendor errors at the boundary.
β Make it mock/stub-friendly (simple method signatures, context first arg).
β Avoid leaking transport types (no *http.Request or *sql.DB in ports).
IntelliJ IDEA HTTP Client
Create file
api_tests.http
:1 2 3 4 5 6 7 8 9 10 11 12 13
### Get user GET http://localhost:8080/v1/users/123 Authorization: Bearer {{TOKEN}} ### Create user POST http://localhost:8080/v1/users Content-Type: application/json Authorization: Bearer {{TOKEN}} { "name": "Alice", "email": "alice@example.com" }
GraphQL request:
1 2 3 4 5 6 7
POST http://localhost:8080/graphql Content-Type: application/json { "query": "query($id: ID!) { user(id: $id) { id name posts { title } } }", "variables": { "id": "123" } }
WebSocket request:
1 2 3 4 5
WEBSOCKET ws://localhost:8080/ws Sec-WebSocket-Protocol: chat < { "type": "hello", "payload": "Hi" } < { "type": "subscribe", "channel": "notifications" }
gRPC request:
1 2 3 4 5 6
GRPC localhost:50051 com.example.user.UserService/GetUser Content-Type: application/json { "id": "123" }
curl
&grpcurl
REST:
1 2
curl -sS -H "Authorization: Bearer $TOKEN" \ http://localhost:8080/v1/users/123 | jq
GraphQL:
1 2 3
curl -sS -H "Content-Type: application/json" \ -d '{"query":"{ user(id:\"123\"){ id name } }"}' \ http://localhost:8080/graphql | jq
gRPC:
1 2
grpcurl -plaintext -d '{"id":"123"}' \ localhost:50051 com.example.user.UserService.GetUser
Postman
Import OpenAPI β REST collections.
Import .proto β gRPC methods.
Use GraphQL tab for queries.
Use WebSocket request for real-time testing.
Contract-First Workflows
OpenAPI (REST): generate Go stubs with oapi-codegen.
Protobuf (gRPC): generate Go code with protoc.
Use mock servers (Prism, grpcurl) to test against the contract.
Add contract tests in CI to catch drift early.
Testing Patterns
Environments: manage tokens/URLs in IDEA http-client.env.json or Postman environments.
Negative tests: invalid payloads, auth errors, rate limiting.
Automation: run .http files or Postman collections in CI pipelines.
Quick Decision Matrix
Style Manual Tool CLI Contract Best For REST IDEA HTTP, Postman curl OpenAPI/Swagger β CRUD, β public APIs, β caching GraphQL IDEA HTTP, Postman curl SDL (schema) β Complex clients, β flexible reads, β οΈ caching harder WebSocket IDEA HTTP, Postman websocat (none) β Real-time, β chat, β dashboards, β οΈ scaling challenges gRPC IDEA gRPC, Postman grpcurl Protobuf β Service-to-service, β high perf, β streaming, β οΈ tooling overhead
π― When to Use Which?
REST β CRUD, public APIs, cache-friendly.
GraphQL β client-driven queries, mobile apps.
WebSocket β chat, dashboards, real-time collab.
gRPC β service-to-service, high-performance internal APIs.
Conclusion
First APIs were manual and error-prone.
OpenAPI/Swagger made REST self-documented.
Protobuf powers gRPC with strong contracts.
No silver bullet β choose API style based on domain, scale, and performance needs.
π Follow me on norbix.dev for more insights on Go, Python, AI, system design, and engineering wisdom.