The 400-line main.go trap
You start with a single endpoint. A quick net/http server, a JSON response, done. Then you add authentication. Then pagination. Then a background worker to process uploads. Before long, your main.go holds routing logic, database initialization, request parsing, and error handling all tangled together. The file scrolls forever. Adding a new endpoint means hunting through three hundred lines to find where the routes are registered. This is the classic Go API trap. The language does not force a framework on you, which means you have to draw the boundaries yourself.
Drawing the kitchen walls
Think of a production REST API like a busy restaurant kitchen. The host stand takes orders and directs them to the right station. The line cooks focus on one task: grilling, sautΓ©ing, or plating. The pantry stores ingredients and recipes. The manager opens the doors, checks the schedule, and makes sure nothing burns. Go gives you the exact same separation without the overhead. You get a router for the host, handler functions for the cooks, model and service layers for the pantry, and a clean entry point for the manager. The beauty is that every piece is just a standard Go function or type. No magic base classes. No dependency injection containers. Just interfaces and function signatures that keep concerns apart.
Go developers follow a simple mantra for these boundaries: accept interfaces, return structs. Your handlers accept an interface for the service layer so you can swap out a real database for a mock during tests. Your services return concrete structs so callers know exactly what shape the data takes. The type system does the wiring for you.
The smallest server that works
Start with the absolute minimum. Go ships with a complete HTTP server in the standard library. You do not need third-party packages to understand the flow.
Here is the simplest working server: register one route, start the listener, and block.
package main
import (
"net/http"
)
// main starts the HTTP listener on port 8080.
func main() {
// Register the handler for the root path.
http.HandleFunc("/", handleRoot)
// Block until the process receives a termination signal.
http.ListenAndServe(":8080", nil)
}
// handleRoot writes a plain text response to the client.
func handleRoot(w http.ResponseWriter, r *http.Request) {
// Set the content type so browsers know what to expect.
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("API is running"))
}
Run this with go run main.go and visit http://localhost:8080. You get a response. The server stays alive. Under the hood, http.ListenAndServe starts a TCP listener, accepts incoming connections, and spawns a new goroutine for each request. That goroutine parses the HTTP headers, matches the path against your registered handlers, and calls your function. When your function returns, the connection closes or stays open for keep-alive. The standard library handles the heavy lifting. Keep your first server this simple until you know exactly why you need more.
Scaling to real endpoints
Real APIs do not just return static strings. They parse JSON, validate input, talk to databases, and return structured errors. You need a way to keep the HTTP plumbing separate from the business logic. The convention is to build a service layer that knows nothing about HTTP, and handlers that translate between HTTP and your service.
Define a simple model first. Public names start with a capital letter so other packages can read them. Private fields stay lowercase. Strings are cheap to pass by value, so you never need to pass a *string for a username or email.
package main
// User represents a single account in the system.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
Next, build a service that handles the actual work. Services accept context.Context as their first parameter. This is a hard convention in Go. The context carries deadlines, cancellation signals, and request-scoped values. If you skip it, your long-running calls will never know when a client disconnects. Functions that take a context should respect cancellation and deadlines before doing expensive work.
package main
import (
"context"
"fmt"
)
// UserService holds dependencies for user operations.
type UserService struct {
// In a real app, this would be a database connection.
store map[string]User
}
// GetUser retrieves a user by ID from the store.
func (s *UserService) GetUser(ctx context.Context, id string) (User, error) {
// Check if the request was cancelled before doing work.
select {
case <-ctx.Done():
return User{}, ctx.Err()
default:
}
user, ok := s.store[id]
if !ok {
return User{}, fmt.Errorf("user not found: %s", id)
}
return user, nil
}
Now write the handler. The handler receives the HTTP request, extracts the ID, calls the service, and writes the response. Notice the receiver name is s, matching the type UserService. Go convention favors short, predictable names for receivers. Never use this or self.
package main
import (
"encoding/json"
"net/http"
"strings"
)
// handleGetUser extracts the ID from the URL and returns JSON.
func (s *UserService) handleGetUser(w http.ResponseWriter, r *http.Request) {
// Strip the leading slash and split the path.
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 3 {
http.Error(w, "missing user ID", http.StatusBadRequest)
return
}
id := parts[2]
// Pass the request context so cancellation propagates.
user, err := s.GetUser(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Marshal the response and write it with the correct status.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
}
Wire it together in main. The entry point creates the dependencies, registers the routes, and starts the server. Keep main thin. It should only initialize and connect pieces. Run gofmt on save and stop arguing about indentation. Let the tool decide.
package main
import (
"log"
"net/http"
)
// main initializes dependencies and starts the HTTP server.
func main() {
// Create the service with an in-memory store.
service := &UserService{
store: map[string]User{
"1": {ID: "1", Name: "Alice", Email: "alice@example.com"},
},
}
// Bind the handler method to a route.
// The method value automatically captures the receiver.
http.HandleFunc("/users/", service.handleGetUser)
// Start the server and log any startup errors.
addr := ":8080"
log.Printf("listening on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatalf("server failed: %v", err)
}
}
Hit http://localhost:8080/users/1 and you get a clean JSON response. The structure scales because adding a new endpoint means adding a new method to the service and a new route registration. The HTTP layer never touches the database. The database layer never sees an http.ResponseWriter. Wire it together in main. Keep the entry point thin. Dependencies flow downward, never upward.
Where things break
REST APIs in Go fail in predictable ways. The compiler will catch type mismatches and missing imports. Runtime failures usually come from ignoring context, mishandling errors, or blocking the request goroutine.
If you forget to check r.Method, your handler will run for GET, POST, and DELETE requests alike. The compiler won't stop you. You will get a method not allowed panic if you try to write a response after the connection closes, or worse, you will execute write logic on a read request. Always check the method at the top of the handler.
Error handling follows a strict pattern. The community accepts if err != nil { return err } because it makes the failure path visible. Do not wrap HTTP errors in custom types unless you need to extract status codes later. The compiler rejects programs that ignore return values from functions that return multiple results, so you cannot accidentally drop an error. If you try to assign a function result to a single variable when it returns two, you get assignment mismatch: 1 variable but func returns 2 values. Use the underscore to discard values you intentionally ignore: result, _ := service.DoThing(). Use it sparingly with errors. Dropping an error silently is a fast track to production outages.
Goroutine leaks are the silent killer. If you spawn a background goroutine inside a handler to process a task, and that goroutine waits on a channel that never closes, it will live forever. The request finishes, the client disconnects, but your server holds onto memory and file descriptors. Always pass a derived context with a timeout to background work, and cancel it when the handler returns. Context is plumbing. Run it through every long-lived call site. The worst goroutine bug is the one that never logs.
Another common mistake is returning 500 Internal Server Error for bad input. Clients send malformed JSON or missing fields. Your JSON decoder will return an error. Translate that to 400 Bad Request before sending it back. The HTTP status code tells the client whether it made a mistake or your server crashed. If you pass the wrong type to a function, the compiler rejects this with cannot use x (untyped int constant) as string value in argument. Forget to import a package and you get undefined: pkg. Forget to use one and you get imported and not used. These are helpful guardrails. Listen to them.
Choosing your routing strategy
Go gives you several ways to map URLs to handlers. Pick the right one for your project size.
Use the standard http.HandleFunc when you have fewer than twenty routes and want zero dependencies. It matches exact paths and simple wildcards. It is fast, well tested, and ships with the language.
Use a third-party router like chi or gorilla/mux when you need path parameters, middleware chains, or route groups. These libraries wrap the standard http.Handler interface and add regex-style matching without changing the core request lifecycle.
Use a framework like fiber or gin when you are building a high-throughput microservice and need built-in JSON binding, validation, and benchmark-optimized routing. These trade standard library compatibility for speed and convenience. They still use net/http under the hood, but they abstract the handler signature.
Use a reverse proxy like nginx or traefik when you need TLS termination, rate limiting, or canary deployments. Go should handle application logic. Leave transport-level concerns to the infrastructure layer.
Trust the standard library until it breaks your workflow. Then reach for a router, not a framework.