The tangled main.go problem
You finish a weekend project. It works. You add a database connection, a cache client, and a background worker. Suddenly main.go is three hundred lines long. Every function imports everything. You change one route and break the configuration loader. This happens because Go compiles packages independently. When everything lives in one place, the compiler sees one giant dependency graph. You need boundaries.
Packages as walls, not boxes
Go does not have classes or modules in the traditional sense. It has packages. A package is a directory of .go files that share a namespace. The compiler treats each package as a separate unit. You import a package, you get its exported names. You cannot reach into its private variables. This forces you to design interfaces before you design implementations.
Think of a microservice like a restaurant kitchen. The front of house takes orders. The pass cooks the food. The storage room keeps ingredients. They talk through a single window. They do not walk into each other's spaces. In Go, that window is a function signature or an interface. The kitchen layout is your directory structure.
The community standard for microservices follows a layered approach. Configuration lives at the edge. Handlers translate HTTP requests into domain calls. Services contain the business rules. Repositories or clients talk to external systems. Each layer only knows about the layer below it. The internal/ directory enforces this rule at the module level. Any package placed under internal/ cannot be imported by code outside the module root. This prevents other teams from accidentally depending on your implementation details.
Keep the layers thin. A handler should not query a database. A service should not parse JSON.
The minimal three-package layout
Here is the simplest structure that scales. It separates configuration, routing, and business logic into distinct packages. Everything lives under internal/ so other projects cannot import your private code.
// main.go wires the layers together and starts the server.
package main
import (
"log"
"net/http"
"your-service/internal/config"
"your-service/internal/handler"
)
func main() {
// Load environment variables into a typed struct.
cfg := config.Load()
// Build the router and attach handlers.
mux := handler.New(cfg)
// Block until the server returns an error.
log.Fatal(http.ListenAndServe(cfg.Addr, mux))
}
The config package reads environment variables and returns a struct. The handler package creates an http.ServeMux and attaches route functions. The service package holds the actual work. main.go does not contain business logic. It only assembles the pieces.
main.go should look like a wiring diagram, not a novel.
How the compiler and runtime connect the dots
When you run go build, the compiler resolves imports by walking the module tree. It checks that internal/config exports a Load function. It verifies that handler.New returns a type that implements http.Handler. If the signatures do not match, compilation stops. Go does not use reflection to wire dependencies. You construct the graph by hand. That means you see exactly what gets initialized and in what order.
At runtime, main executes sequentially. config.Load reads os.Getenv or parses a file. The returned struct holds ports, database URLs, and feature flags. handler.New receives that struct and closes it over the route functions. http.ListenAndServe starts a blocking loop that accepts TCP connections. The program stays alive until the server crashes or receives a termination signal.
This linear startup sequence is intentional. If the database URL is missing, you fail fast before accepting a single request. You can wrap the startup sequence in a main function that returns an error, then call log.Fatal(main()) to keep the exit code clean. The community accepts this pattern because it separates initialization logic from the entry point.
Fail fast at startup. A broken configuration should never reach production traffic.
Adding context, errors, and a service layer
Real services need more than routing. They need request-scoped lifecycles, structured logging, and database access. Here is how the service layer fits in.
// service.go contains the business rules and external calls.
package service
import (
"context"
"errors"
)
// Service holds dependencies for domain operations.
type Service struct {
dbURL string
}
// New creates a service instance with the provided configuration.
func New(dbURL string) *Service {
return &Service{dbURL: dbURL}
}
// FetchItem retrieves a record by ID from the database.
func (s *Service) FetchItem(ctx context.Context, id string) (string, error) {
// Context carries cancellation and deadlines across layers.
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
// Validate input before hitting external systems.
if id == "" {
return "", errors.New("missing item id")
}
return "item-data", nil
}
The receiver name s follows Go convention. One or two letters matching the type name. The FetchItem method takes context.Context as its first parameter. This is not optional. Every long-running or blocking call must accept a context so the caller can cancel it. The select block checks for cancellation before doing work.
The handler layer now bridges HTTP and the service.
// handler.go translates HTTP requests into service calls.
package handler
import (
"context"
"net/http"
"your-service/internal/service"
)
// Handler holds the router and service dependencies.
type Handler struct {
mux *http.ServeMux
svc *service.Service
}
// New registers routes and returns the configured handler.
func New(cfg Config, svc *service.Service) *Handler {
h := &Handler{
mux: http.NewServeMux(),
svc: svc,
}
// Attach the route to the mux.
h.mux.HandleFunc("/items/{id}", h.getItem)
return h
}
// ServeHTTP satisfies the http.Handler interface.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
// getItem handles the /items/{id} endpoint.
func (h *Handler) getItem(w http.ResponseWriter, r *http.Request) {
// Extract the path parameter from the request.
id := r.PathValue("id")
// Pass the request context down to the service layer.
item, err := h.svc.FetchItem(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write([]byte(item))
}
The Handler struct implements http.Handler by providing a ServeHTTP method. The getItem function extracts the path parameter, calls the service, and writes the response. Error handling uses the standard if err != nil pattern. The community accepts this boilerplate because it makes failure paths explicit. You cannot accidentally swallow an error behind a silent variable assignment. When you need to add stack traces or metadata, wrap the error with fmt.Errorf("failed to fetch: %w", err) instead of creating custom error types.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and compiler traps
Structuring a service introduces specific failure modes. The compiler catches some. Runtime panics hide others.
Circular imports break the build immediately. If handler imports service and service imports handler, the compiler rejects the program with import cycle not allowed. Go resolves packages in a strict topological order. You fix this by extracting a shared interface into a third package, usually internal/domain or internal/api. The handler imports the interface. The service implements it. The cycle disappears.
Forgetting to export a name causes a different error. If you name a function loadConfig instead of LoadConfig, the compiler complains with undefined: config.loadConfig. Go uses capitalization for visibility. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords.
Passing pointers to small values creates unnecessary allocations. If you define a config struct and pass *Config everywhere, you are adding indirection for no reason. Strings and small structs are cheap to pass by value. The compiler will not stop you, but the profiler will show extra pointer chasing.
Goroutine leaks happen when a handler spawns a background task that waits on a channel nobody closes. The request finishes, the response sends, but the goroutine stays alive. The worst goroutine bug is the one that never logs. Always attach a context with a timeout to background work, or use a worker pool with a bounded queue.
Trust the compiler on imports. Trust your tests on goroutines.
Choosing a layout for your service
Go does not enforce a single architecture. You pick the shape that matches your problem size.
Use a flat cmd/ and pkg/ layout when you are building a small CLI tool or a single-file prototype. Use a layered internal/ structure when you have HTTP handlers, business rules, and database calls that need clear boundaries. Use a domain-driven layout with internal/domain, internal/infrastructure, and internal/application when your service coordinates multiple external APIs and complex state machines. Use a monorepo with multiple cmd/ directories when you are shipping several related services that share internal packages.
The decision matrix is simple. Start with the layered approach. It scales from ten routes to a hundred without restructuring. Add domain packages only when business rules outgrow a single file. Keep internal/ private so other teams cannot accidentally depend on your implementation details.
Structure follows complexity. Do not over-engineer a weather app. Do not under-engineer a payment processor.