Week 8: Packages, Modules & Project Structure

Every week so far has lived in a single file. Before building anything larger — starting with the REST API in Week 10 — this week covers how the Go ecosystem actually expects a real, multi-file, multi-package project to be organized, versioned, and built reproducibly.

Module 8 of 16 Week 8 of 16 ~3 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Organize code into packages, and control visibility with exported vs. unexported identifiers
  • Lay out a Go project following the layout convention the ecosystem actually expects
  • Explain what go.mod and go.sum each guarantee
  • Version and tag a module for other projects to depend on

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.

internal/store/store.go
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:

a typical service layout
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

terminal
go mod init github.com/yourname/my-service
go get github.com/go-chi/chi/v5@v5.0.12
go.mod
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.

terminal — tagging a release
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

Hands-on

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:

  1. Start from any single-file program you've written in this course so far (Week 4's word counter is a good candidate).
  2. Run go mod init with a real module path, and split the code into at least two packages: one in internal/, one holding cmd/<name>/main.go as a thin entrypoint that imports from internal/.
  3. Deliberately make at least one identifier unexported (lowercase) that genuinely doesn't need to be used outside its package, and one exported that does.
  4. Add one real external dependency with go get, and inspect the resulting go.sum to confirm it recorded checksums for it.
  5. Confirm go build ./... succeeds from the module root, and that attempting to import your internal/ package from a throwaway file outside the module fails to compile.
Hint

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?

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?

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?

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?

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.