How to Understand a Go Codebase: cmd, internal and Wiring in main

Go codebases are explicit by design, which makes them readable if you follow the right order: module, binaries, main, packages, interfaces and concurrency.

By The OpenVisio Team · · 12 min read

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.mod gives 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 its main.go is 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 gopls or a search for compile-time assertions, not an implements keyword.
  • 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 vet and go test -race answer 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:

DirectoryMeaning
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:

  1. 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.
  2. 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 ..

  1. 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.Go so errors propagate and the group's context cancels siblings.
  • Long-running loops that select on ctx.Done() alongside their work channel.
  • A clear owner that closes each channel, usually the sender.
  • context.Context passed 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

  1. Read go.mod (and go.work if present).
  2. Run go list ./... and count packages.
  3. List binaries under cmd/ and check which ones are deployed.
  4. Read each main and draw the construction graph.
  5. Read go doc output for the core domain packages.
  6. Find init() functions.
  7. Use gopls to find implementations of the key interfaces.
  8. List goroutines and answer start, stop and error for each.
  9. Trace one request from router to SQL.
  10. Run go build, go vet and go 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.

GoGolangcode readingconcurrency

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading