CMD Guide
HomeSystem DesignMicroservices Patterns

API Gateway Pattern An Example

An API gateway is a reverse proxy that terminates the client's connection at the network edge, applies cross-cutting concerns once (TLS, authentication, rate-limiting), then forwards each request as a fresh HTTP call to one or more internal services and stitches their responses into a single reply — so the client makes one authenticated round trip instead of talking to a dozen services directly.

The word that matters is network. The gateway and the services are separate processes, usually separate machines. A book-detail request does not reach into a UserService object in the same heap; it opens a socket to catalogue-svc:8080, sends bytes, and waits. Everything interesting about the pattern — routing, edge auth, response aggregation, protocol translation, graceful degradation when one backend is down — lives in that gap between processes.

Why the naive in-process version misses the point

A gateway that holds live service references and does return userService.getUserDetails(id) is just a facade object: one method call in one JVM, dispatched by the runtime. It shows none of the pattern's reasons to exist. There is no hostname to resolve, no token to verify, no timeout when a downstream hangs, no way to fan out to three services and merge the results, and no single choke point where you'd actually put rate-limiting. If your "gateway" compiles into the same binary as the services it calls, you have not built a gateway — you've built a class. The real thing is defined by the HTTP boundary it sits on.

diagram
diagram

One request, traced end to end

A mobile app renders a book-detail screen. It sends one call: GET /api/books/9780134685991 with header Authorization: Bearer eyJhbGci…. The screen needs three facts that live in three different services — the title, whether copies are in stock, and the average rating. Here is what the gateway actually does with real values:

StepWhat the gateway doesConcrete result~Time
1Terminate TLS, match route /api/books/{isbn}isbn = 9780134685991~0.2 ms
2Verify JWT signature + exp, extract claimsuserID=u_5521, scope=read:catalog → valid~0.4 ms
3Rate-limit: token bucket for u_552198/100 left → allow~0.1 ms
4aGET catalogue-svc/books/9780134685991200 {"title":"Effective Java"}22 ms
4bGET inventory-svc/stock/9780134685991 (parallel)200 {"available":3}15 ms
4cGET reviews-svc/rating/9780134685991 (parallel)200 {"rating":4.7}31 ms
5Merge into one payload, return 200{"isbn":…,"title":"Effective Java","available":3,"rating":4.7}~0.3 ms

Because 4a–4c run concurrently, the fan-out costs max(22,15,31)=31 ms, not the 68 ms sum. That ~32 ms is the gateway's internal, backend-facing work; true client-perceived latency must add the WAN round trip the client pays to reach the edge (often ~100 ms on cellular). The gateway's latency win is not erasing the WAN leg — it is collapsing three TLS handshakes, three auth checks, and three separate cellular round trips into one.

The counterfactual (no gateway). The mobile app would open three separate HTTPS connections over cellular — each a fresh TLS handshake plus its own ~100 ms round trip. Even issued in parallel, the device pays three connection taxes and must orchestrate the merge. Serialized, that is ≈ 300 ms+. It would also have to know three hostnames, embed the auth token three times, and re-implement retry/timeout logic in the client. The gateway collapses that to one authenticated round trip and moves the merge work server-side onto a fast internal network.

The gateway handler (correct version)

Note what makes this a gateway and not a facade: http.NewRequestWithContext + DefaultClient.Do — real network calls to remote services — plus edge auth, a shared timeout via context, concurrent fan-out, and graceful degradation (catalogue is required; stock and rating are best-effort).

// Go 1.22. TokenVerifier / RateLimiter are interfaces implemented elsewhere.
func fetchJSON(ctx context.Context, url, token string, out any) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+token) // gateway's own service credential
    resp, err := http.DefaultClient.Do(req)           // <- a real network hop
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("upstream %s -> %s", url, resp.Status)
    }
    return json.NewDecoder(resp.Body).Decode(out)
}

// GET /api/books/{isbn}
func (g *Gateway) GetBook(w http.ResponseWriter, r *http.Request) {
    // (1) Cross-cutting concerns, enforced once at the edge.
    claims, err := g.auth.Verify(r.Header.Get("Authorization"))
    if err != nil {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }
    if !g.limiter.Allow(claims.UserID) {
        http.Error(w, "rate limited", http.StatusTooManyRequests)
        return
    }

    isbn := r.PathValue("isbn")
    ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond)
    defer cancel() // one budget bounds ALL downstream calls

    // (2) Fan out over the network, concurrently.
    var (
        cat struct{ Title string `json:"title"` }
        inv struct{ Available int `json:"available"` }
        rev struct{ Rating float64 `json:"rating"` }
        wg  sync.WaitGroup
    )
    catErr := make(chan error, 1)
    best := func(url string, out any) { defer wg.Done(); _ = fetchJSON(ctx, url, g.svcToken, out) }

    wg.Add(3)
    go func() { defer wg.Done(); catErr <- fetchJSON(ctx, g.catalogue+"/books/"+isbn, g.svcToken, &cat) }()
    go best(g.inventory+"/stock/"+isbn, &inv)
    go best(g.reviews+"/rating/"+isbn, &rev)
    wg.Wait() // Wait establishes happens-before: cat/inv/rev are safe to read now

    // (3) Degrade gracefully: catalogue required, stock/rating optional.
    if err := <-catErr; err != nil {
        http.Error(w, "book unavailable", http.StatusBadGateway)
        return
    }
    _ = json.NewEncoder(w).Encode(BookView{
        ISBN: isbn, Title: cat.Title, Available: inv.Available, Rating: rev.Rating,
    })
}

The same shape in Java 17 with the built-in async HttpClient — the aggregation is the point, so the three calls launch together and we join on all of them:

HttpClient http = HttpClient.newHttpClient();
var catalogue = getJson(http, base + "/catalogue/books/" + isbn, svcToken);
var inventory = getJson(http, base + "/inventory/stock/" + isbn, svcToken);
var reviews   = getJson(http, base + "/reviews/rating/"  + isbn, svcToken);
CompletableFuture.allOf(catalogue, inventory, reviews).join(); // wait for all three
String merged = merge(catalogue.join(), inventory.join(), reviews.join());

static CompletableFuture<String> getJson(HttpClient c, String url, String token) {
    HttpRequest req = HttpRequest.newBuilder(URI.create(url))
        .header("Authorization", "Bearer " + token)
        .timeout(Duration.ofMillis(300))
        .GET().build();
    return c.sendAsync(req, HttpResponse.BodyHandlers.ofString())
            .thenApply(HttpResponse::body);
}

In production you rarely hand-write routing for pass-through endpoints — Spring Cloud Gateway, Kong, Envoy, or AWS API Gateway declare routes in config. But aggregation endpoints like this one are exactly where you drop into code, and increasingly that code moves into a per-client Backend-for-Frontend (see trade-offs below).

Fan-out over a list: cap concurrency, prefer a batch endpoint

The three-call example above fans out to distinct services, so unbounded goroutines are fine — there are only three. The danger is the N+1-over-the-network shape: rendering a 40-item list by looping one GET /stock/{isbn} per row. Do it serially and you pay 40 × 15 ms ≈ 600 ms; do it with 40 simultaneous goroutines and you open 40 sockets to inventory at once, and a spike of such requests can exhaust its connection pool. The fix is a hard concurrency cap — a bounded worker pool. In Go the canonical primitive is errgroup with SetLimit:

// go get golang.org/x/sync/errgroup
func (g *Gateway) stockFor(ctx context.Context, isbns []string) (map[string]int, error) {
    eg, ctx := errgroup.WithContext(ctx)
    eg.SetLimit(8) // never more than 8 concurrent sockets to inventory
    var mu sync.Mutex
    out := make(map[string]int, len(isbns))
    for _, isbn := range isbns {
        isbn := isbn // capture per iteration
        eg.Go(func() error {
            var v struct{ Available int `json:"available"` }
            if err := fetchJSON(ctx, g.inventory+"/stock/"+isbn, g.svcToken, &v); err != nil {
                return err
            }
            mu.Lock(); out[isbn] = v.Available; mu.Unlock()
            return nil
        })
    }
    return out, eg.Wait() // blocks until all done; first error cancels ctx
}

(A buffered channel used as a counting semaphore — sem := make(chan struct{}, 8), acquire before each call, release in a defer — is the dependency-free equivalent.) With the cap at 8, the 40 items run in ⌈40/8⌉ = 5 waves of ~15 ms ≈ 75 ms — 8× faster than serial, without the 40-socket storm.

But the real win is upstream of concurrency: ask for a batch endpoint. If inventory exposes GET /stock?isbns=…, 40 round trips collapse to one (~20 ms) — fewer sockets, one set of headers, and the downstream can answer with a single index scan. Rule of thumb: cap concurrency when you're forced to loop; design a batch endpoint when you control the downstream.

Hedged requests for the tail. When a required call has a fat tail (a slow replica, a GC pause), issue a second attempt to another backend after roughly the p95 latency and take whichever response returns first, cancelling the loser. You trade a small amount of extra load (only the ~5% that cross p95 get a twin) for a materially shorter p99 — the standard tail-tolerance technique from the Google SRE practice of tied/hedged requests.

Pitfalls

When to use it — and when not

Reach for a gateway when you have several services behind one public API, multiple clients that shouldn't each re-implement auth/TLS/rate-limiting, and screens that need data from more than one service in a single call. The concrete signals: clients are making 3+ calls to paint one view; auth/observability logic is copy-pasted into every service; you're exposing internal service topology (hostnames, ports) to the public internet.

Trade-offs and named alternatives:

ApproachYou gainIt costs
API Gateway (this page)One entry point; auth/rate-limit/TLS in one place; server-side aggregation; hides topologyExtra network hop & latency; a SPOF to scale & operate; risk of becoming a god-object; a shared component teams must coordinate on
Direct client-to-serviceLowest latency; nothing extra to operate; no shared bottleneckEvery client re-implements auth/retries; N calls per screen; public topology; painful CORS & versioning
Backend-for-Frontend (BFF)A gateway per client type — mobile/web/partner each get a tailored payload & team ownershipMore services to build & deploy; logic duplicated across BFFs
Service mesh (Istio/Linkerd sidecars)mTLS, retries, load-balancing, tracing for service-to-service traffic — no app codeSolves east-west, not north-south: no client auth, no aggregation, no public API surface

How a senior decides: Choose a single gateway when you have one dominant client and a modest service count. Prefer a BFF per client when a mobile app and a web app want very different response shapes and a shared gateway keeps sprouting if client==mobile branches. Use a service mesh alongside (not instead of) the gateway — the mesh secures and observes internal hops while the gateway owns the public edge. Skip the gateway entirely for a two-service system or an internal-only tool where the added hop and operational burden buy you nothing.

Takeaways


Re-authored and deepened for this guide. Sources: Chris Richardson, Microservices Patterns (Manning, 2018) and microservices.io (API Gateway / Backend-for-Frontend patterns); Sam Newman, Building Microservices, 2nd ed. (O'Reilly, 2021); the NGINX "Building Microservices: Using an API Gateway" article; and the Go net/http and Java java.net.http standard-library docs. Go example compiled and vetted under Go 1.25; the Java snippet targets Java 11+ HttpClient.

🤖 Don't fully get this? Learn it with Claude

Stuck on API Gateway Pattern An Example? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **API Gateway Pattern An Example** (System Design) and want to truly understand it. Explain API Gateway Pattern An Example from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **API Gateway Pattern An Example** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **API Gateway Pattern An Example** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **API Gateway Pattern An Example** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes