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

  • Pros: simple, widely supported, cacheable.

  • Cons: over-fetching/under-fetching, no strong typing.

Example (Gin):

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

πŸ” GraphQL

  • Pros: client chooses fields, flexible queries.

  • Cons: complex server, N+1 query problem, caching harder.

Example query:

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

πŸ”„ WebSocket

  • Pros: bidirectional, low-latency, real-time.

  • Cons: harder to scale, stateful connections.

Example (Go):

1
2
conn, _, _ := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", nil)
conn.WriteMessage(websocket.TextMessage, []byte("hello"))

⚑ gRPC

  • Pros: fast, strongly typed, streaming support, efficient over HTTP/2.

  • Cons: tooling overhead, browser requires gRPC-Web.

Example Go client:

1
resp, err := client.GetUser(ctx, &pb.UserRequest{Id: "123"})

πŸ“Š 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.