In this guide
On this page ⌄
- Key takeaways
- Step 1: Read go.mod and list the packages
- Step 2: Find the binaries
- Step 3: Read main as the wiring diagram
- Step 4: Understand the package layout
- Step 5: Find interface implementations
- Step 6: Map the concurrency
- Step 7: Follow one request through the layers
- Step 8: Build, vet and test to confirm your model
- A first-day checklist
Go is one of the friendlier languages to inherit. There is no class hierarchy to unwind, no annotation-driven dependency injection in most projects, imports are explicit, and the formatter means every file looks roughly the same. A large Go service can still be hard to understand, but the difficulty is usually in the size of the package graph and in concurrency, not in hidden magic.
This guide gives you a reading order for an unfamiliar Go repository: the module file, the binaries under cmd/, the wiring in main, the package layout including internal/, interfaces and how to find what implements them, and the goroutines that keep the program alive. It leans on the go command itself, which can answer most structural questions without any extra tooling.
Key takeaways
go.modgives you the module path, Go version and dependencies. Every import path in the repo starts with that module path.- Each directory under
cmd/is usually one binary, and itsmain.gois where the program is assembled. Read it first. internal/is enforced by the compiler: packages there can only be imported from within the parent tree. It tells you what the authors consider private.- Interfaces are satisfied implicitly, so finding implementations needs
goplsor a search for compile-time assertions, not animplementskeyword. - Every goroutine has a lifecycle. Find where each one starts, what stops it, and how errors get back to
main. go list,go doc,go vetandgo test -raceanswer structural and behavioral questions faster than reading.
Step 1: Read go.mod and list the packages
Open go.mod:
module github.com/acme/billing
go 1.23
require (
github.com/jackc/pgx/v5 v5.7.1
github.com/go-chi/chi/v5 v5.1.0
go.opentelemetry.io/otel v1.31.0
golang.org/x/sync v0.8.0
)
The module line is the import prefix for every package in the repo. The go line is the minimum language version, which matters for features like range-over-func iterators (1.23) or the per-iteration loop variable semantics introduced in 1.22. The require block tells you the stack: here, Postgres via pgx, chi for HTTP routing, OpenTelemetry and errgroup from x/sync. Check for a go.work file too; if present, the repo is a multi-module workspace, and each module has its own go.mod.
Then list every package:
go list ./...
In Go, a package is a directory. That one command gives you the complete package inventory, and its length tells you how big the problem is. To see what a single package depends on inside the module:
go list -f '{{join .Imports "\n"}}' ./internal/billing | grep acme/billing
Step 2: Find the binaries
By convention, each directory under cmd/ holds a main package that builds one executable:
cmd/
api/main.go
worker/main.go
migrate/main.go
If there is no cmd/, look for package main anywhere:
go list -f '{{if eq .Name "main"}}{{.ImportPath}}{{end}}' ./...
Also check the Makefile, Dockerfile and .goreleaser.yaml, which show which binaries are actually built and shipped. A repo with five cmd/ directories may only deploy two of them. For the general technique across stacks, see finding the entry point of any repository.
Step 3: Read main as the wiring diagram
In most Go services, main constructs everything by hand: configuration, logger, database pool, clients, repositories, services, HTTP handlers, then starts servers and waits for a signal. That makes main.go (or a run() function it calls) the most informative file in the repository:
func main() {
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("connect db: %w", err)
}
defer pool.Close()
invoices := postgres.NewInvoiceStore(pool)
payments := stripe.NewClient(cfg.StripeKey)
svc := billing.NewService(invoices, payments)
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(svc)}
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return srv.ListenAndServe() })
g.Go(func() error {
<-ctx.Done()
return srv.Shutdown(context.Background())
})
return g.Wait()
}
Read this as a dependency graph. Every New* call shows which concrete type is used for which role, and the order shows what depends on what. Draw it on paper: config feeds the pool, the pool feeds the store, the store and the payment client feed the service, the service feeds the router.
Some projects use a dependency injection tool instead. With google/wire, look for wire.go files with a //go:build wireinject constraint and the generated wire_gen.go; the generated file is plain Go and shows the real construction order. With uber-go/fx, look for fx.New(...) with fx.Provide and fx.Invoke calls, and run the app with fx's logging to see the resolved graph.
Step 4: Understand the package layout
Go does not mandate a layout, but a few conventions are common:
| Directory | Meaning |
|---|---|
cmd/<name> | One binary each; thin main packages |
internal/ | Private to this module; the compiler rejects imports from outside the parent tree |
pkg/ | Code the authors intend to be importable by other modules (a convention, not enforced) |
api/, proto/ | API definitions: OpenAPI specs, protobuf files |
migrations/ | SQL migrations |
testdata/ | Fixtures; ignored by the go tool for package purposes |
Inside internal/, projects tend to organize by domain (internal/billing, internal/accounts) or by layer (internal/http, internal/store, internal/service). Domain packages usually hold types and business logic, with adapters for databases and external APIs in separate packages that depend on them. Notice which direction imports go. If internal/billing imports internal/postgres, the domain depends on storage; if it is the other way around, storage implements interfaces defined by the domain.
Package documentation is worth reading. go doc prints it from the terminal:
go doc ./internal/billing
go doc ./internal/billing Service
go doc -all ./internal/billing | less
Also look for doc.go files, which exist only to hold package comments, and init() functions, which run at import time:
grep -rn "^func init()" --include=*.go . | grep -v _test.go
init() functions register drivers, metrics and codecs. They are one of the few places Go runs code implicitly.
Step 5: Find interface implementations
Go interfaces are satisfied implicitly: any type with the right methods implements the interface, with no declaration. That is elegant and occasionally frustrating when you want to know what a Store interface really is at runtime.
Three ways to find out:
- Ask gopls. In any editor with the Go language server, "Go to Implementations" on an interface lists every concrete type that satisfies it, and on a method lists every interface it satisfies. This is the most reliable option.
- Look for compile-time assertions. Many codebases include lines like this next to the implementation:
var _ billing.InvoiceStore = (*InvoiceStore)(nil)
Search for them with grep -rn "var _ .* = " --include=*.go ..
- Read main. The wiring in Step 3 shows which concrete type is passed where the interface is expected.
A useful Go idiom to recognize: interfaces are usually defined by the consumer, close to where they are used, and are often small (one to three methods). If you see a large interface defined next to its only implementation, it probably exists for mocking. Look for generated mocks from mockgen or mockery, which are marked with a // Code generated ... DO NOT EDIT. header, and skip them while reading.
Step 6: Map the concurrency
Concurrency is where Go codebases get hard to reason about. For each goroutine, you want to know three things: where it starts, what makes it stop, and how its errors are reported.
Find them:
grep -rn "go func\|go [a-zA-Z_.]*(" --include=*.go . | grep -v _test.go
grep -rn "errgroup\|sync.WaitGroup" --include=*.go . | grep -v _test.go
For each hit, read until you can answer the three questions. Healthy patterns:
- Goroutines started with
errgroup.Group.Goso errors propagate and the group's context cancels siblings. - Long-running loops that
selectonctx.Done()alongside their work channel. - A clear owner that closes each channel, usually the sender.
context.Contextpassed as the first argument to anything that does I/O.
Warning signs: a go func() with no way to stop it, errors logged and swallowed inside a goroutine, channels closed by the receiver, or a time.Sleep loop polling shared state. These are where leaks and races live.
Verify with the race detector, which instruments memory access at runtime:
go test -race ./...
go run -race ./cmd/api
If you need to see what goroutines exist in a running process, a service that imports net/http/pprof exposes /debug/pprof/goroutine?debug=2, which dumps every goroutine's stack. That is a fast way to see the real concurrency structure of a live system.
Step 7: Follow one request through the layers
Pick an endpoint and trace it. Find the router setup (chi, gorilla/mux, echo, gin, or the standard library's http.ServeMux, which supports method and path patterns like "POST /invoices/{id}/pay" since Go 1.22). Follow the handler to the service method, the service method to the store, and the store to the SQL. Note where context is passed, where errors are wrapped with fmt.Errorf("...: %w", err), and where they are translated to HTTP status codes, usually with errors.Is or errors.As in the handler. The wrapping messages often read like a stack trace once you know the layers.
The general method for following a request is in tracing a request through a web app.
Step 8: Build, vet and test to confirm your model
Run the basics once, early:
go build ./...
go vet ./...
go test ./...
go generate ./... # only if the repo relies on generated code; check for //go:generate lines first
Failing builds and tests on a clean checkout tell you about missing environment setup, build tags or generated files. go vet catches mistakes that also tend to confuse readers, like misused format strings and copied locks. For a dependency view, go mod graph prints the module requirement graph, and go mod why <module> explains why a dependency is present.
A first-day checklist
- Read
go.mod(andgo.workif present). - Run
go list ./...and count packages. - List binaries under
cmd/and check which ones are deployed. - Read each
mainand draw the construction graph. - Read
go docoutput for the core domain packages. - Find
init()functions. - Use gopls to find implementations of the key interfaces.
- List goroutines and answer start, stop and error for each.
- Trace one request from router to SQL.
- Run
go build,go vetandgo test -race.
For call-level questions like "who calls this function" across a large Go repo, a code graph helps more than search; code knowledge graphs vs embeddings explains why. Codebase Knowledge by OpenVisio indexes Go repositories in the browser, and its MCP tools such as trace_calls and get_dependents give coding agents the same package and call structure you just built by hand.

