How to Design Microservices in Go

Web
Design Go microservices by building independent binaries with HTTP handlers, context-aware cancellation, and graceful shutdown logic.

When the monolith slows down

You're running a web app. The image upload feature is slow. Every time someone uploads a 50MB photo, the checkout page hangs. The upload job blocks the thread, the queue backs up, and users abandon their carts. You need to split the upload logic out so the checkout stays fast.

The solution isn't to optimize the image code. The solution is to move image processing to a separate process. That process can crash, scale, or lag without freezing the checkout. In Go, this pattern is trivial. You write a small program, compile it to a single binary, and run it. That's a microservice.

What a microservice actually is

A microservice in Go is just a program. It listens on a port. It handles requests. It talks to other programs via HTTP or gRPC. It shares no memory with them. The "micro" part is a discipline, not a language feature. Go makes the discipline easy because every program is a standalone binary. You don't need a heavy framework to start a server. The standard library handles the heavy lifting.

Think of a restaurant kitchen. The grill, the salad station, and the drinks are separate. If the grill burns, the drinks station still works. They communicate via orders. In software, the orders are HTTP requests or gRPC calls. The stations are your services.

The "shared nothing" principle is key. Services don't share databases. They don't share files. They don't share caches. Each service owns its data and exposes it through an API. This isolation prevents one service from taking down another. It also allows each service to evolve independently. You can change the database schema of the upload service without touching the checkout service.

The minimal service skeleton

Here's the skeleton of a Go microservice. It starts an HTTP server, listens for shutdown signals, and stops gracefully. This pattern ensures your service doesn't drop requests when you deploy a new version.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    // health check endpoint for load balancers
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // run server in a goroutine so main can listen for signals
    go func() {
        log.Println("Server starting on :8080")
        // ListenAndServe blocks until shutdown or error
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("ListenAndServe: %v", err)
        }
    }()

    // buffered channel prevents losing a signal if main is busy
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    // block until OS sends a termination signal
    <-quit

    log.Println("Shutting down server...")
    // context limits shutdown time so the process doesn't hang forever
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // stop accepting new requests and wait for active ones to finish
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    log.Println("Server exited")
}

Go code follows strict formatting conventions. Run gofmt on your files. Most editors do this automatically on save. Don't argue about indentation; let the tool decide.

How it works at runtime

When you run this program, the http.Server starts listening on port 8080. The ListenAndServe call blocks the goroutine. The main goroutine waits on the quit channel. Nothing happens until the OS sends a signal.

When you stop the service, the OS sends SIGTERM. The signal.Notify call routes the signal to the quit channel. The main goroutine unblocks. It creates a context with a 5-second timeout. It calls server.Shutdown.

The server stops accepting new connections. Existing requests continue processing. If a request finishes within 5 seconds, it completes. If it takes longer, the context expires and the request gets cancelled. The process exits cleanly. This is crucial for load balancers. They mark the service as healthy, send traffic to the new version, and wait for the old version to drain.

The difference between Shutdown and Close matters. Close stops the server immediately and drops all active connections. Shutdown waits for connections to finish. Always use Shutdown for graceful termination. Use Close only when you need to abort immediately.

Compile the code with go build -o service main.go. The output is a single static binary. No virtual machine. No runtime dependencies. This binary runs on any machine with the same OS and architecture. Copy the file and run it. This simplicity makes deployment fast and reliable. Docker images become tiny because you just need the binary and the OS libraries.

Handling requests with context

A real service does work. It handles requests, calls databases, and respects cancellation. Here's a handler that processes a request and checks the context.

package main

import (
    "context"
    "net/http"
    "time"
)

// Service holds dependencies for the handler
type Service struct{}

// Handle processes the request and respects context cancellation
func (s *Service) Handle(w http.ResponseWriter, r *http.Request) {
    // context from request allows the client to cancel the operation
    ctx := r.Context()

    // simulate long-running work
    select {
    case <-time.After(2 * time.Second):
        w.Write([]byte("done"))
    case <-ctx.Done():
        // client disconnected or deadline exceeded
        http.Error(w, "request cancelled", http.StatusServiceUnavailable)
    }
}

The receiver name s matches the type Service. This is the Go convention. Use one or two letters. Never use this or self.

The context.Context comes from the request. It carries the deadline and cancellation signal. If the client closes the connection, the context gets cancelled. The select statement checks ctx.Done(). If the context is done, the handler stops work and returns an error. This prevents wasting resources on requests that no one cares about.

Don't pass *string to functions. Strings are cheap to pass by value. Passing a pointer adds indirection without saving memory. Pass the string directly.

Accept interfaces, return structs. This is the most common Go style mantra. Your service methods should return concrete types. Functions that consume the service should accept interfaces. This keeps your code flexible and testable.

Configuration and logging

Microservices need configuration. Go uses environment variables for this. Read them with os.Getenv. Use the flag package for local development overrides.

port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}

This pattern keeps configuration external to the code. You can change the port without recompiling. Production systems inject environment variables via deployment manifests.

Logging is equally important. Use log/slog for structured logging. It outputs JSON by default, which parsers can ingest. Log errors with context. Include request IDs for tracing.

import "log/slog"

slog.Info("request completed", "duration", time.Since(start))

Structured logs make debugging easier. You can filter by field values. You can aggregate logs across services. Don't log sensitive data. Mask passwords and tokens.

Pitfalls and errors

Goroutine leaks are the most common bug. If you spawn a goroutine to do work and it waits on a channel that never closes, the goroutine stays alive. The process never exits. Always provide a cancellation path. Use a context or a done channel.

Forgetting to pass context is another trap. If you start a goroutine without a context, you can't cancel it. The compiler won't stop you. The runtime will leak. The compiler rejects code with undefined: ctx if you forget to define the variable. It complains with imported and not used if you import a package but don't use it. Fix these immediately.

Panics in handlers cause http: panic serving errors. The server recovers and continues, but the client gets a 500 error. Use a middleware to recover from panics and log the stack trace. Never let a panic escape to the top level.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't try to hide errors. Return them explicitly.

Context is plumbing. Run it through every long-lived call site.

When to use a microservice

Use a microservice when you need independent scaling for a specific workload. Use a microservice when a team needs to deploy without coordinating with other teams. Use a microservice when a component has different reliability requirements than the rest of the system. Use a monolith when the system is small and the team is small. Use a library when multiple services share the same logic and you want to avoid code duplication. Use gRPC when services communicate over a fast internal network and you want strong typing. Use HTTP when you need broad compatibility with external clients or browsers.

Goroutines are cheap. Channels are not magic.

Where to go next