The single door to your services
Your frontend app wants to fetch user profiles and order history. You have two separate Go services running on different ports. Exposing both directly to the browser creates a mess: the client needs to know about internal ports, handle cross-origin restrictions twice, and manage authentication for every service. You need a single entry point that speaks to the outside world and routes traffic to the right internal service. That entry point is the API gateway.
Concept in plain words
An API gateway acts like a hotel concierge. Guests don't walk directly into the kitchen or the laundry room. They tell the concierge what they need. The concierge knows which department handles room service and which handles maintenance, sends the request there, and brings the result back to the guest.
In Go, the gateway is just an HTTP server that listens for requests, inspects the URL path, and proxies the traffic to the appropriate backend service. You handle authentication, logging, and rate limiting in one place, then pass the clean request downstream. The backend services never see the client directly. They only talk to the gateway. This keeps your internal network secure and simplifies the client code.
The gateway is a thin layer. Don't let it become a monolith.
Minimal example
Here's the skeleton of a gateway: define routes, create a client with a timeout, and wire each path to a proxy handler.
package main
import (
"net/http"
"time"
)
func main() {
// Create a router to match URL paths to handlers.
mux := http.NewServeMux()
// Client with timeout prevents hanging on slow backends.
client := &http.Client{Timeout: 5 * time.Second}
// Route /users to the user service backend.
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
proxyRequest(w, r, "http://localhost:8081", client)
})
// Route /orders to the order service backend.
mux.HandleFunc("/orders/", func(w http.ResponseWriter, r *http.Request) {
proxyRequest(w, r, "http://localhost:8082", client)
})
// Start the gateway server on port 8080.
http.ListenAndServe(":8080", mux)
}
func proxyRequest(w http.ResponseWriter, r *http.Request, target string, client *http.Client) {
// This placeholder returns 501 to show the routing structure.
// Real implementation requires URL rewriting and body forwarding.
w.WriteHeader(http.StatusNotImplemented)
}
Routes define the map. The proxy does the walking.
Walk through what happens
When the program starts, http.NewServeMux allocates a routing table. Each call to HandleFunc registers a pattern and a closure. The pattern /users/ matches any path starting with /users/. The trailing slash matters. /users without the slash would only match the exact path, not /users/123. The mux uses a longest-prefix match algorithm. If you register /users/ and /users/profile, a request to /users/profile hits the more specific handler.
http.ListenAndServe starts a TCP listener and enters a loop waiting for connections. When a request arrives, the mux checks the URL path against registered patterns. It picks the longest match and calls the associated handler. The handler receives the response writer and request object, then delegates to the proxy logic.
The http.Client manages connections. It keeps a pool of TCP connections to backends. Reusing the client across requests avoids the overhead of TLS handshakes and TCP three-way handshakes. Create one client and share it. Don't create a new client per request. The client is safe for concurrent use by multiple goroutines.
Trust gofmt. The indentation and spacing are decided for you. Most editors run it on save.
Realistic example
Here's a proxy that rewrites the URL, forwards headers, streams the body, and handles errors with context propagation.
package main
import (
"io"
"net/http"
"time"
)
func proxyRequest(w http.ResponseWriter, r *http.Request, target string, client *http.Client) {
// Clone the request to modify URL without affecting the original.
req := r.Clone(r.Context())
// Rewrite the URL to point to the backend service.
req.URL.Host = target
req.URL.Scheme = "http"
// Remove hop-by-hop headers that shouldn't be forwarded.
req.Header.Del("Host")
// Execute the request to the backend.
resp, err := client.Do(req)
if err != nil {
// Backend unreachable or timeout.
http.Error(w, "backend error", http.StatusBadGateway)
return
}
// Ensure the response body closes to prevent leaks.
defer resp.Body.Close()
// Copy response headers to the client response.
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
// Write the status code before the body.
w.WriteHeader(resp.StatusCode)
// Stream the body from backend to client.
io.Copy(w, resp.Body)
}
Context is plumbing. Run it through every long-lived call site.
The http.ResponseWriter interface defines the contract for sending data back to the client. It has Header(), Write(), and WriteHeader(). You can modify headers at any time before the body is written. Once you call WriteHeader or Write, the headers are flushed to the network. Calling WriteHeader again has no effect. If you never call WriteHeader, the server sends a 200 OK status implicitly. This design prevents accidental double-writes and keeps the response lifecycle predictable.
The handler signature follows the Go mantra: accept interfaces, return structs. http.ResponseWriter is an interface, giving you flexibility. *http.Request is a struct, giving you a concrete shape.
Pitfalls and compiler errors
Manual proxies have traps. Forgetting to copy headers leads to missing auth tokens. Forgetting to close the response body leaks file descriptors. The compiler won't catch a missing Close. You get a resource leak that kills the server under load. The worst goroutine bug is the one that never logs.
If you register routes inside a loop, you risk capturing the loop variable. In Go 1.22+, the compiler rejects this with loop variable i captured by func literal. Before that, all handlers would share the final value of the variable. Always pass the variable as an argument to the closure or declare a new variable inside the loop.
// BAD: Loop variable capture.
routes := []string{"/users", "/orders"}
for _, path := range routes {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
// path is captured by reference. All handlers use the last value.
log.Printf("routing to %s", path)
})
}
If the backend hangs, the client waits forever unless you set a timeout. The error propagates as context deadline exceeded. The gateway must respect deadlines. If the client cancels the request, the gateway should cancel the backend call. Use context.WithTimeout or context.WithCancel to bound the work.
The community accepts verbose error handling because it makes the unhappy path visible. if err != nil { return err } is boilerplate by design. It forces you to acknowledge every failure point. Don't hide errors behind silent returns.
Decision: when to use this vs alternatives
Use manual proxying when you need fine-grained control over URL rewriting and header filtering. Use httputil.ReverseProxy when you want a standard proxy that handles streaming and connection management automatically. Use a framework like Gin or Chi when your gateway requires complex middleware stacks, parameter extraction, or JSON validation. Use a dedicated infrastructure proxy when you need TLS termination, rate limiting, and circuit breaking without adding logic to your Go binary.
Pick the tool that matches your complexity. Simple routing needs simple code.