Lesson 27 of 30 intermediate 4 min read

Before this:Packages & modules

Structuring a Go project

Key takeaways Real Go repositories follow a few conventions. Each executable gets a tiny main package under cmd/. Private implementation packages live under internal/, which the compiler forbids outside code from importing. Each package owns one responsibility and is named for it. GopherTrunk’s own tree is a textbook example — cmd/gophertrunk plus internal/dsp, internal/scanner, internal/sdr, and more.

Where does code go? Go has strong conventions, and following them makes any project navigable. This lesson maps them onto GopherTrunk’s real layout, extending Packages & modules.

cmd/ — one directory per binary

Every executable is a package main with a main() function, and each lives in its own directory under cmd/. GopherTrunk ships several:

cmd/
  gophertrunk/      # the main scanner binary
  voice-calibrate/  # a calibration helper
  encodetest/       # a codec test harness

Each main stays thin — parse flags, wire things up, call into the real packages. go build ./cmd/gophertrunk builds just that one.

internal/ — enforced-private packages

The bulk of the code lives under internal/, and the toolchain gives that name a superpower: nothing outside the module can import it. That lets the project expose a deliberate surface while keeping implementation free to change:

internal/
  dsp/       # signal processing — filters, down-converters
  scanner/   # control-channel decode, trunking follow
  sdr/       # radio hardware drivers
  config/    # loading and validating configuration
  api/       # the HTTP/JSON surface
module root cmd/ internal/ gophertrunk/ voice-calibrate/ dsp/ scanner/ sdr/ config/ api/
The conventional Go tree: thin binaries under cmd/, private implementation packages under internal/, each owning one concern.

One package, one responsibility

Notice the pattern in those names: each package is a single concern, named for what it does — dsp does signal processing, sdr talks to hardware, config handles configuration. This keeps dependencies flowing one way (a command imports scanner, scanner imports dsp and sdr) and makes the whole tree readable.

Directory Holds Importable by
cmd/<name>/ One main per binary (it’s the top; imports others)
internal/<pkg>/ Private implementation Only this module
pkg/ (if present) Deliberately public API Anyone
module root go.mod, go.sum, top-level docs

Applying it

When you start a project, resist one giant package. Put each binary under cmd/, put shared logic in small internal/ packages named by responsibility, and let the internal boundary keep your public surface honest. Reading a codebase laid out this way — which is exactly what the Reading a real Go codebase lesson does with GopherTrunk — becomes a matter of following the imports.

Quick check: what's special about a package under internal/?

Recap

  • Each binary is a thin package main under cmd/<name>/.
  • internal/ packages are private — only the same module can import them.
  • Each package owns one responsibility and is named for it.
  • GopherTrunk’s cmd/ + internal/dsp, scanner, sdr, config tree is the model.

Next up: the conventions that make Go code idiomatic — from naming to error handling.

Frequently asked questions

What is the internal directory for in a Go project?

internal is a special directory the Go toolchain enforces: packages under internal/ can only be imported by code inside the same module. It’s how a project keeps its implementation packages private, exposing a deliberate surface while forbidding outside code from depending on internals it might break.

Why put each command under cmd/?

A repository often ships more than one binary — the main app, a calibration tool, a test harness. Giving each its own package under cmd/ keeps every main() small and separate, while the real logic lives in importable packages under internal/ that all the commands can share.