1. Package Naming & Visibility
Every Go file belongs to a package, declared at the top. Visibility outside that
package is controlled by a single, simple rule: an identifier starting with an
uppercase letter is exported (public); lowercase is unexported
(package-private). No public/private keywords at all.
package store
type User struct { // exported — usable from other packages
Name string
}
func NewUser(name string) *User { // exported constructor function
return &User{Name: name}
}
func validate(name string) bool { // unexported — internal helper only
return len(name) > 0
}
Package names themselves are conventionally short, lowercase, and never
pluralized — store, not Stores or store_utils
— since the package name already qualifies every exported identifier at the call
site (store.NewUser, not store.NewStoreUser).
2. Conventional Project Layout
There's no single mandated layout, but a widely-followed community convention (informally, the "standard Go project layout") covers most real services:
my-service/
go.mod
go.sum
cmd/
api/
main.go # the actual entrypoint — kept thin
internal/ # code only this module can import — enforced by the compiler
store/
store.go
handler/
handler.go
pkg/ # code intended for other modules to import (if any)
README.md
internal/ is genuinely special, not just a naming convention — the Go
toolchain refuses to let any package outside this module import from
internal/, which is how a project marks implementation details as
off-limits to external consumers, enforced at compile time.
3. go.mod, go.sum & Reproducible Builds
go mod init github.com/yourname/my-service
go get github.com/go-chi/chi/v5@v5.0.12
module github.com/yourname/my-service
go 1.22
require github.com/go-chi/chi/v5 v5.0.12
go.mod declares the module's own identity and its direct
dependencies with exact versions. go.sum — auto-generated, never
hand-edited — records a cryptographic checksum of every dependency's exact content,
so a build fails loudly if a dependency's published code ever changes underneath a
pinned version. Together, they're what makes go build reproducible: the
same go.mod/go.sum pair produces the same dependency tree
on any machine, indefinitely.
4. Versioning & Publishing a Module
A Go module is published simply by pushing tagged commits to its public repository — there's no separate package registry to upload to, unlike npm or PyPI.
git tag v1.0.0
git push origin v1.0.0
From that point, go get github.com/yourname/my-service@v1.0.0 works
for anyone. Go's module system follows semantic versioning strictly enough that a
major version bump (v2+) requires changing the module path itself
(appending /v2) — a deliberate design choice ensuring v1 and v2 of the
same module can be imported side-by-side in the same program without conflict,
since they're treated as genuinely different import paths.
5. Hands-on Exercise
Restructure a single-file program into a real module
Take a small program and give it the structure a real Go service would actually have.
Requirements:
- Start from any single-file program you've written in this course so far (Week 4's word counter is a good candidate).
- Run
go mod initwith a real module path, and split the code into at least two packages: one ininternal/, one holdingcmd/<name>/main.goas a thin entrypoint that imports frominternal/. - Deliberately make at least one identifier unexported (lowercase) that genuinely doesn't need to be used outside its package, and one exported that does.
- Add one real external dependency with
go get, and inspect the resultinggo.sumto confirm it recorded checksums for it. - Confirm
go build ./...succeeds from the module root, and that attempting to import yourinternal/package from a throwaway file outside the module fails to compile.
If importing your own internal/ package fails from somewhere it shouldn't (inside the same module, where it should be allowed), double-check the import path matches your actual module path from go.mod exactly — a common mistake is importing a hardcoded path copied from an example rather than <your-module-path>/internal/<package>.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
How does Go decide whether an identifier is exported, and why is this simpler than languages with explicit public/private keywords?
How does Go decide whether an identifier is exported, and why is this simpler than languages with explicit public/private keywords?
Purely by the first letter's case — uppercase is exported, lowercase is not — with no separate keyword or annotation required anywhere. It's simpler in the sense that visibility is always visible at the call site itself (you can tell from the name alone), though it does mean visibility and naming are permanently coupled in a way explicit keywords are not.
Q2
What does the Go compiler actually enforce about a package living under internal/, beyond naming convention?
What does the Go compiler actually enforce about a package living under internal/, beyond naming convention?
It refuses to compile any import of an internal/ package from outside the module tree that contains it — this is a real, compiler-enforced restriction, not just a convention other developers are expected to respect. It's the mechanism a module uses to genuinely hide implementation details from external consumers.
Q3
What specific problem does go.sum solve that go.mod alone does not?
What specific problem does go.sum solve that go.mod alone does not?
go.mod pins which version of a dependency to use, but a version tag alone doesn't guarantee the content behind it never changes — go.sum records a cryptographic checksum of each dependency's actual content, so if that content is ever altered after the fact (accidentally or maliciously) at the same version, the build fails loudly instead of silently using different code than everyone else's build used.
Q4
Why does bumping a Go module to a new major version (v2+) require changing the import path itself, rather than just updating a version number in go.mod?
Why does bumping a Go module to a new major version (v2+) require changing the import path itself, rather than just updating a version number in go.mod?
Changing the import path (adding /v2) makes the two major versions genuinely distinct packages from the compiler's point of view, which allows both v1 and v2 of the same module to be imported side-by-side in one program without conflict — useful during a gradual migration. If the import path stayed the same, a program could never depend on both versions at once, and an incompatible v2 could silently break anything still expecting v1's API.