REST vs GraphQL vs WebSocket vs gRPC

πŸ“Œ 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):

1
2
3
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello World")
})

πŸ—οΈ 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
paths:
  /users/{id}:
    get:
      summary: Get a user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

πŸ“ 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
syntax = "proto3";

service UserService {
  rpc GetUser (UserRequest) returns (UserResponse);
}

message UserRequest {
  string id = 1;
}

message UserResponse {
  string id = 1;
  string name = 2;
}

πŸ‘‰ 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)

1
2
3
4
5
6
r.GET("/users/:id", func(c *gin.Context) {
    c.JSON(200, gin.H{
        "id":   c.Param("id"),
        "name": "Alice",
    })
})

➑️ 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:

1
2
3
4
5
6
7
query {
  user(id: "123") {
    id
    name
    posts { title }
  }
}

➑️ 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import (
    "log"
    "github.com/gorilla/websocket"
)

func main() {
    conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", nil)
    if err != nil {
        log.Fatal("dial error:", err)
    }
    defer conn.Close()

    conn.WriteMessage(websocket.TextMessage, []byte("hello"))
}

➑️ 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

1
2
3
4
5
resp, err := client.GetUser(ctx, &pb.UserRequest{Id: "123"})
if err != nil {
    log.Fatalf("could not fetch user: %v", err)
}
fmt.Println("User:", resp.Name)

➑️ 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):

1
2
3
type PaymentGateway interface {
    Charge(ctx context.Context, userID string, amount int) error
}

During tests:

1
2
3
4
5
type StubPayment struct{}

func (s StubPayment) Charge(ctx context.Context, userID string, amount int) error {
    return nil // always succeed
}

πŸ” 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)

1
2
3
4
5
6
7
package payment

import "context"

type Gateway interface {
    Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error)
}

File: internal/core/checkout/service.go (high-level depends on abstraction)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package checkout

import (
    "context"
    "fmt"

    "yourapp/internal/core/payment"
)

type Service struct {
    pay payment.Gateway
}

func NewService(pg payment.Gateway) *Service {
    return &Service{pay: pg}
}

func (s *Service) Purchase(ctx context.Context, userID string, cents int64) (string, error) {
    if cents <= 0 {
        return "", fmt.Errorf("invalid amount")
    }
    return s.pay.Charge(ctx, userID, cents, "USD")
}

File: internal/adapters/stripe/gateway.go (detail depends on abstraction)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package stripe

import (
    "context"
    "yourapp/internal/core/payment"
)

type Client struct {
    apiKey string
}

func New(apiKey string) *Client { return &Client{apiKey: apiKey} }

// Ensure it implements the port
var _ payment.Gateway = (*Client)(nil)

func (c *Client) Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error) {
    // call Stripe SDK/HTTP here...
    return "ch_123", nil
}

File: cmd/api/main.go (composition root: wire details into core)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package main

import (
    "log"

    "yourapp/internal/adapters/stripe"
    "yourapp/internal/core/checkout"
)

func main() {
    stripeGW := stripe.New("sk_test_...")          // detail
    svc      := checkout.NewService(stripeGW)      // inject into core

    // pass svc into HTTP handlers (not shown)
    log.Println("api up")
}

Testing with mocks/stubs (DIP makes this trivial)

File: internal/core/payment/stub.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package payment

import "context"

type StubGateway struct {
    ChargeFn func(ctx context.Context, userID string, amountCents int64, currency string) (string, error)
}

func (s StubGateway) Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error) {
    if s.ChargeFn != nil { return s.ChargeFn(ctx, userID, amountCents, currency) }
    return "stub_tx", nil
}

File: internal/core/checkout/service_test.go

1
2
3
4
5
6
svc := checkout.NewService(payment.StubGateway{
    ChargeFn: func(_ context.Context, _ string, amount int64, _ string) (string, error) {
        if amount > 100_00 { return "", fmt.Errorf("limit") }
        return "ok", nil
    },
})

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 levelPrimary goalPrefer Stub when…Prefer Mock when…
UnitPure logic correctnessYou just need canned data (happy/unhappy cases)You must assert a call happened (args, order, count)
IntegrationAdapter correctness (DB/API/Queue)You want fast, local tests without spinning dependenciesYou need to simulate network errors, timeouts, retries
E2E / ContractEnd-to-end flows & schema compatibilityβ€”Use mock servers from OpenAPI/Protobuf for contract fit

Stub (deterministic data for unit tests):

1
2
3
4
5
6
7
8
9
type StubPayment struct {
    ResultTx string
    Err      error
}

func (s StubPayment) Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error) {
    if s.Err != nil { return "", s.Err }
    return s.ResultTx, nil
}

Mock (verify interaction):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type MockPayment struct {
    Calls []struct {
        UserID string
        Amount int64
        Curr   string
    }
    ReturnTx string
    ReturnErr error
}

func (m *MockPayment) Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error) {
    m.Calls = append(m.Calls, struct {
        UserID string; Amount int64; Curr string
    }{userID, amountCents, currency})
    return m.ReturnTx, m.ReturnErr
}

// test
mp := &MockPayment{ReturnTx: "tx_ok"}
svc := checkout.NewService(mp)
_, _ = svc.Purchase(ctx, "u1", 499)

require.Len(t, mp.Calls, 1)
require.Equal(t, int64(499), mp.Calls[0].Amount)
require.Equal(t, "USD", mp.Calls[0].Curr)

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:

1
2
3
4
// core/checkout/service.go
type Service struct {
    stripe *stripe.Client // ❌ vendor in core
}

Good (DIP + interface) – core depends on abstraction:

1
2
3
4
5
6
7
8
9
// core/payment/port.go
type Gateway interface {
    Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error)
}

// core/checkout/service.go
type Service struct{ pay payment.Gateway }

func NewService(gw payment.Gateway) *Service { return &Service{pay: gw} }

Adapter implements the port:

1
2
3
4
5
6
7
8
9
// adapters/stripe/gateway.go
var _ payment.Gateway = (*Client)(nil)

type Client struct { apiKey string }

func (c *Client) Charge(ctx context.Context, userID string, amountCents int64, currency string) (string, error) {
    // call Stripe SDK / HTTP
    return "ch_123", nil
}

Composition root wires details:

1
2
3
4
// cmd/api/main.go
stripeGW := stripe.New(os.Getenv("STRIPE_KEY"))
svc      := checkout.NewService(stripeGW)
router   := http.NewRouter(svc)

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).


  1. 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"
    }
    
  2. 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
    
  3. Postman

    • Import OpenAPI β†’ REST collections.

    • Import .proto β†’ gRPC methods.

    • Use GraphQL tab for queries.

    • Use WebSocket request for real-time testing.

  4. 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.

  5. 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.

  6. Quick Decision Matrix

    StyleManual ToolCLIContractBest For
    RESTIDEA HTTP, PostmancurlOpenAPI/Swaggerβœ… CRUD, βœ… public APIs, βœ… caching
    GraphQLIDEA HTTP, PostmancurlSDL (schema)βœ… Complex clients, βœ… flexible reads, ⚠️ caching harder
    WebSocketIDEA HTTP, Postmanwebsocat(none)βœ… Real-time, βœ… chat, βœ… dashboards, ⚠️ scaling challenges
    gRPCIDEA gRPC, PostmangrpcurlProtobufβœ… 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.