Skip to content

Chapter 7: Package Design & Modules

Good package design makes your code maintainable and reusable. Let’s explore Go’s module system and package organization best practices.

Package design is how you organize code into cohesive, reusable units. Well-designed packages have clear purposes, minimal dependencies, and intuitive APIs. Poorly designed packages create confusion, tight coupling, and maintenance nightmares.

Go’s module system, introduced in Go 1.11 and standard since Go 1.16, revolutionized dependency management. It provides versioning, reproducible builds, and a clear dependency graph. Understanding modules and package organization is essential for building maintainable Go projects.

This chapter covers module fundamentals, package naming and organization, the internal directory pattern, API design, and versioning. You’ll learn how to structure projects that scale from small utilities to large applications.

A Go module is a collection of packages with a go.mod file at the root. The go.mod file declares the module path (import path prefix) and its dependencies. Each dependency specifies a version, making builds reproducible.

Modules solved Go’s original dependency problem: “go get” fetched the latest version of dependencies, making builds unpredictable. Today, modules ensure that building your code today produces the same result as building it next year.

Key concepts:

  • Module path: Uniquely identifies your module (usually a repository URL)
  • Semantic versioning: Dependencies specify versions like v1.2.3
  • Minimal version selection: Go chooses the oldest allowed version that satisfies all constraints
  • go.sum: Checksums verify dependency integrity

Example go.mod file (it is not Go source - it has its own small grammar):

module github.com/username/myproject
go 1.24
require (
github.com/some/dependency v1.2.3
)

The go directive is not just documentation: it selects the language version the compiler applies to this module. A module that says go 1.21 does not get Go 1.22’s per-iteration loop variables even when built with a Go 1.24 toolchain. Bump it deliberately, and only when you are ready for the semantics that come with it.

Good package names are the foundation of clear APIs. Package names appear in every usage, so they should be short, memorable, and descriptive. The name should communicate what the package provides without being overly generic.

Naming rules:

  • Short - http, json, fmt - short names are easy to type and read
  • Lowercase - no underscores or mixedCaps - Go convention
  • Singular - user not users - package contains user types/functions
  • Not generic - avoid util, common, base - these reveal nothing about purpose

Why this matters: Package names become part of identifiers. user.Service is clearer than users.UserService (redundant) or common.Service (too generic). Good names make code self-documenting.

// Good
package user
package auth
package postgres
// Avoid
package utils
package helpers
package common

Package organization determines how easy your code is to navigate, test, and maintain. Go’s flat package structure (no deep nesting) encourages simple, focused packages. A well-organized project groups related functionality while keeping packages independent.

Common patterns:

  • cmd/: Entry points for multiple binaries
  • internal/: Private packages not importable by external projects
  • pkg/: Public packages intended for external use (optional)
  • api/: API definitions, protobuf files, OpenAPI specs

Key principle: Organize by responsibility, not by layer. Avoid models/, controllers/, services/ structure from other languages. Instead, group by domain: user/, order/, payment/.

A typical project structure:

myproject/
├── go.mod
├── go.sum
├── main.go # or cmd/myapp/main.go
├── internal/ # Private packages
│ ├── auth/
│ └── database/
├── pkg/ # Public packages (optional)
│ └── client/
└── api/ # API definitions
└── v1/

The internal directory restricts package access:

Go uses capitalization to control visibility:

In most languages documentation is a separate artifact that drifts out of date. In Go it is part of the package: the comment immediately above a declaration is its documentation, and the same text appears in go doc, on pkg.go.dev, and in your editor’s hover. There is no separate format to learn and nothing to publish - which also means an undocumented exported identifier is simply an undocumented API.

The rules are short:

  • A doc comment sits immediately above the declaration, with no blank line between. One blank line and it is just a comment.
  • It starts with the name being declared: // NewServer returns ..., not // This function creates .... Tools index and truncate on that first sentence, so it must stand alone.
  • Write complete sentences. The first one is the summary shown in package listings.
  • A package comment starts with // Package name ... and appears above package name in exactly one file - conventionally doc.go for anything longer than a line.
  • Mark removals with // Deprecated: use X instead. on its own paragraph. Linters and editors key off that exact prefix.
  • Since Go 1.19, gofmt formats doc comments as lightweight markup: blank-line paragraphs, indented code blocks, # Headings, and [Name] links to other identifiers.
// Package cache provides an in-memory key/value store with expiry.
//
// Values are kept until their TTL elapses or the cache is cleared.
// A Cache is safe for concurrent use by multiple goroutines.
//
// # Sizing
//
// New pre-allocates for the given capacity; see [New] and [Cache.Set].
package cache
// ErrExpired is returned by [Cache.Get] when an entry exists but its
// TTL has elapsed. Compare with errors.Is.
var ErrExpired = errors.New("cache: entry expired")
// New returns a Cache that holds up to capacity entries.
//
// New panics if capacity is negative.
func New(capacity int) *Cache { ... }
// Len returns the number of entries currently held, including
// entries that have expired but not yet been evicted.
//
// Deprecated: use [Cache.Stats] instead, which reports both counts.
func (c *Cache) Len() int { ... }

Read it back the way a user will:

Terminal window
go doc ./cache # package summary + exported API
go doc ./cache.Cache.Set # one symbol
go doc -all ./cache # everything, including examples

Example Functions Are Documentation That Cannot Rot

Section titled “Example Functions Are Documentation That Cannot Rot”

The best documentation in Go is an Example function: it lives in a _test.go file, appears on the doc page next to the thing it documents, and is compiled and run by go test. Give it an // Output: comment and the test fails the moment the documented behaviour changes. Prose lies; examples cannot.

Naming decides where it attaches: ExampleNew documents New, ExampleCache_Set documents the Set method on Cache, and a trailing lowercase suffix gives you variants - ExampleCache_Set_withTTL.

Run this one - the playground executes it as a test, so you can watch the // Output: assertion pass, then change the greeting and watch it fail:

Go modules use semantic versioning:

Terminal window
# Add a specific version
go get github.com/user/pkg@v1.2.3
# Add latest
go get github.com/user/pkg@latest
# Add specific commit
go get github.com/user/pkg@abc123
# Update all dependencies
go get -u ./...

For v2+, the import path must include the major version:

import (
"github.com/user/pkg" // v0 or v1
"github.com/user/pkg/v2" // v2.x.x
"github.com/user/pkg/v3" // v3.x.x
)

Use workspaces for multi-module development. Like go.mod, go.work has its own grammar and is not Go source:

go 1.24
use (
./module1
./module2
./shared
)
  1. Short, clear package names - avoid generic names like util
  2. Internal packages - hide implementation details
  3. Capitalize to export - control your public API
  4. Doc comments are the API - start with the identifier’s name, no blank line above; Example functions are documentation that go test verifies
  5. Accept interfaces, return structs - flexible inputs, clear outputs
  6. Functional options - extensible configuration, including injected dependencies like a clock
  7. errors.Is, not == - even for io.EOF
  8. Semantic versioning - v2+ in import path; the go directive in go.mod also selects language semantics

Design a Package API

medium

Create a logger package configured with functional options: log level, output prefix, timestamps - and an injectable clock. WithClock is already written for you as a worked example; implement the other three options, the constructor defaults, and Log.


Package boundaries are only real once something has to cross them:


Chapter in progress
0 / 14 chapters completed

Next up: Chapter 8: Clean Architecture in Go