How to Structure a Go Microservice Repository

Structure a Go microservice by placing the entry point in cmd/<service> and private logic in internal packages.

The flat file trap

You clone a Go repository. The root directory contains main.go, handlers.go, models.go, and utils.go. It runs fine on your machine. You add a second endpoint. You copy a function from handlers.go into models.go because you need it there. Suddenly the build fails with a circular dependency error. You move the function back. You add a third service. The file list grows to twenty. Tests start interfering with each other because every package imports everything. You are looking at the most common Go project anti-pattern: the flat layout.

Go does not force you into a rigid framework structure. The language gives you a module system and a compiler that treats directory names as semantic boundaries. The standard layout emerges from how the toolchain resolves imports and enforces visibility. When you align your folders with the compiler rules, the project scales. When you fight them, you spend hours untangling import cycles.

Why Go cares about directory names

In many languages, folder structure is purely organizational. The compiler or runtime ignores it. Go treats directories as package boundaries. Every folder that contains .go files is a package. The package declaration at the top of each file must match the folder name. The compiler uses the directory tree to resolve import paths and enforce access control.

This design choice removes the need for access modifiers like public or private. Go uses capitalization instead. Names starting with a capital letter are exported. Names starting with a lowercase letter are visible only within the same package. Combine that with the internal directory convention, and you get a layout where the compiler physically prevents other modules from importing your private logic.

The internal folder is not magic. It is a compiler rule. Any package inside a directory named internal cannot be imported by code outside the parent module. This guarantees that your business logic, database queries, and configuration parsers stay inside your service. External libraries can depend on your public API without accidentally coupling to your implementation details.

The minimal layout

A clean microservice starts with three directories: cmd, internal, and a go.mod file at the root. The cmd directory holds entry points. Each service or CLI tool gets its own subfolder inside cmd. The internal directory holds the actual code. The go.mod file defines the module path and tracks dependencies.

Here is the simplest working structure:

// cmd/my-service/main.go
package main

import (
	"log"
	"my-service/internal/config"
	"my-service/internal/handler"
)

// main initializes dependencies and starts the HTTP server.
func main() {
	// Load configuration from environment variables or files.
	cfg := config.Load()
	// Create the handler with the loaded configuration.
	h := handler.New(cfg)
	// Start the server and log fatal errors if it crashes.
	log.Fatal(h.Serve())
}

The cmd/my-service/main.go file does one job: wire dependencies and start the process. It does not contain business logic. It does not parse request bodies. It imports packages from internal, constructs the objects, and hands control to the runtime. This separation keeps the entry point readable and makes it trivial to add a second binary, like a background worker or a CLI tool, in cmd/my-worker/main.go.

How the compiler enforces boundaries

When you run go run ./cmd/my-service, the toolchain reads go.mod to find the module path. It then resolves my-service/internal/config by walking the directory tree. If you accidentally name the folder internal/configuration, the import path breaks. The compiler rejects the build with no required module provides package my-service/internal/config. Path consistency matters.

The compiler also checks visibility. If internal/handler tries to access a lowercase field from internal/config, the build fails with cannot refer to unexported name. This error saves you from runtime panics caused by missing data. You fix the capitalization or add a public getter method. The language forces explicit data flow.

Go also enforces that every imported package must be used. If you add fmt to the import block and forget to call it, the compiler stops with imported and not used. This rule keeps dependency graphs clean. You do not accumulate dead imports over months of development.

Trust gofmt to handle indentation and formatting. Argue about logic, not whitespace. Most editors run it on save, and the Go team considers formatting debates a solved problem.

Wiring it together in practice

Real services need more than a single handler. They need database connections, logging, and context propagation. The wiring stays in cmd. The internal packages expose constructors that accept dependencies. This follows the Go convention of accepting interfaces and returning structs.

Here is a realistic handler constructor that respects context and error handling:

// internal/handler/handler.go
package handler

import (
	"context"
	"net/http"
)

// Config holds the settings required to start the server.
type Config struct {
	Port string
}

// Handler manages HTTP routes and server lifecycle.
type Handler struct {
	port string
}

// New creates a configured Handler instance.
func New(cfg Config) *Handler {
	// Return a pointer so the caller can modify shared state if needed.
	return &Handler{port: cfg.Port}
}

// Serve starts the HTTP listener and blocks until interrupted.
func (h *Handler) Serve() error {
	// Context carries cancellation signals across the request lifecycle.
	ctx := context.Background()
	// Bind the server to the configured port.
	addr := ":" + h.port
	// Return an error if the listener fails to start.
	return http.ListenAndServe(addr, nil)
}

The constructor takes a concrete struct. The method receiver uses a short name matching the type. The Serve method returns an error instead of calling log.Fatal directly. Returning errors lets the main function decide how to handle failures. This pattern keeps packages testable. You can inject a mock server or a different port during unit tests without changing the handler code.

Context always travels as the first parameter in Go functions. It carries deadlines, cancellation signals, and request-scoped values. Functions that accept a context must respect cancellation. If the parent context expires, the function should stop work and return early. This convention prevents goroutine leaks and keeps long-running services responsive.

The if err != nil { return err } pattern looks verbose compared to try-catch blocks. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an error by forgetting a catch clause. Every failure point requires an explicit decision.

Common layout mistakes

Developers coming from other ecosystems often try to force familiar patterns into Go. The compiler usually catches these mistakes, but the error messages can be confusing if you do not know what to expect.

Putting business logic in main.go is the fastest way to make a project untestable. The main package cannot be imported by other packages. If your core logic lives there, you cannot write unit tests for it. Move the logic to internal and keep main as a thin wiring layer.

Circular imports happen when package A imports package B, and package B imports package A. The compiler rejects this immediately with import cycle not allowed. The fix is almost always the same: extract the shared interface or data structure into a third package, or invert the dependency so the higher-level package passes a function or interface to the lower-level one.

Overusing pkg/ is another common trap. Some teams copy the pkg/ convention from large open-source libraries. Microservices rarely need it. pkg/ implies the code is designed for external consumption. If your service is a standalone binary, internal/ is safer. It prevents accidental coupling when you split the monolith later.

Forgetting to run go mod tidy leaves the module graph out of sync. The compiler may complain about missing packages even though the files exist locally. Running go mod tidy updates go.mod and go.sum to match your actual imports. Make it part of your pre-commit hook or CI pipeline.

Do not pass a *string to functions. Strings are already cheap to pass by value. The pointer adds allocation overhead without providing mutability benefits. Use string unless you are dealing with massive payloads that exceed the stack limit.

Picking your structure

Layout choices depend on your service size and team workflow. Follow these rules to keep the project maintainable.

Use a flat cmd/service layout when you are building a single binary with fewer than five packages. Use an internal directory when you need to hide implementation details from external consumers. Use a pkg directory only when you are publishing a library for other teams to import. Use separate cmd folders for each binary when your repository contains both an API server and a background worker. Use manual dependency wiring in main.go when you want zero framework overhead and full control over initialization order. Use a dependency injection framework when your service has more than twenty interdependent components and manual wiring becomes unreadable.

Keep the entry point under fifty lines. Move complexity into internal. Let the compiler enforce boundaries instead of relying on team conventions.

Where to go next