A Go project is organized into a module (the unit of versioning and dependencies) made of packages (folders of related code). Get this structure right and the project stays clean as it grows.
In simple language: a module is the whole repo’s dependency bundle, a package is one folder of code.
Modules recap
We start a module with go mod init, naming it after where it lives (usually its import path).
go mod init github.com/pman47/myapp # creates go.mod
This creates two files we should understand:
- go.mod — lists our module name, Go version, and our direct dependencies with their versions.
- go.sum — cryptographic checksums of every dependency (and their deps), so builds are verifiable and tamper-proof.
Versions use semantic import versioning: v1.2.3 style tags. The big rule — if a library hits v2 or higher, its major version becomes part of the import path (github.com/foo/bar/v2). That lets v1 and v2 coexist without clashing.
go get github.com/foo/bar@v1.4.0 # pin a specific version
go mod tidy # sync go.mod/go.sum to actual imports
Vendoring is optional — go mod vendor copies all dependencies into a local vendor/ folder so builds don’t need the network. Some teams vendor for reproducible, offline-friendly builds; many skip it and rely on the module cache.
The common layout
There’s no enforced project layout, but the community converged on a convention. Here’s the typical shape:
- cmd/ — one subfolder per executable, each with a
package mainandmain.go. A repo with an API and a CLI would havecmd/api/andcmd/cli/. - internal/ — see the special rule below.
- pkg/ — library code we’re happy for outside projects to import. (Some people skip
pkg/and put packages at the root — that’s fine too.)
The special internal rule
This is the one interviewers love. internal/ isn’t just a naming convention — the compiler enforces it. Code inside an internal/ directory can only be imported by code rooted in the parent of that internal/ folder.
In simple language: internal/ means “private to this module — outsiders can’t import it.” If another repo tries to import github.com/pman47/myapp/internal/db, the build fails. It’s how we keep implementation details from leaking into other people’s code.
Package naming conventions
- Package names are short, lowercase, single word —
http,json,user. No underscores, no camelCase. - The package name should match its folder name.
- Avoid stutter. If the package is
user, name the constructoruser.New(), notuser.NewUser()— callers already typeuser., so repeating it reads asuser.NewUser. Same reason it’shttp.Server, nothttp.HTTPServer.
Avoiding circular imports
Go forbids circular imports — if package a imports b, then b cannot import a. The build just refuses. This sounds annoying but it forces clean, one-directional dependencies.
When we hit a cycle, the usual fixes are:
- Pull the shared types into a third, lower-level package both can import.
- Use an interface so the lower package doesn’t need to know the higher one (dependency inversion).
A cycle is almost always a signal that two packages are really one concern split badly, or that a dependency is pointing the wrong way.
Interview soundbite
A Go module (go mod init, tracked by go.mod/go.sum with semantic import versioning) groups packages, and the community layout is cmd/ for entry points, internal/ for private code, and pkg/ for public libraries. The killer detail is that internal/ is compiler-enforced — only the parent module can import it. Package names stay short and stutter-free (http.Server, not http.HTTPServer), and Go flat-out forbids circular imports, which we break by extracting shared types or introducing an interface.