Package Design
In Go, a package is the fundamental unit of code organization. One package occupies one directory, and its name becomes the prefix callers use to access its exports. Good package design makes Go code readable, discoverable, and maintainable. The standard library is the best style guide for how to do it well.
One Package = One Directory
Every .go file in a directory must have the same package declaration. You cannot split a package across multiple directories, and you cannot have two packages in the same directory (except for test files with _test suffix packages).
myproject/
server/
server.go // package server
handler.go // package server
middleware.go // package server
store/
store.go // package store
postgres.go // package store
Each directory is a self-contained unit. When you import myproject/server, you get everything in the server/ directory.
Package Name = Directory Name
By convention, the package name matches the directory name. The directory server/ contains package server. The directory store/ contains package store.
// File: server/server.go
package server
type Config struct {
Port int
Host string
}
There are exceptions. The main package can live in any directory (typically cmd/appname/). Test files can use a _test suffix package for black-box testing. But for library packages, the name should match the directory.
Exported vs Unexported
Go uses capitalization for visibility, not keywords like public or private.
package user
// Exported: accessible from other packages
type User struct {
ID int
Name string
}
// Exported function
func New(name string) User {
return User{Name: name, ID: nextID()}
}
// Unexported: only accessible within this package
func nextID() int {
// internal implementation
return 0
}
// Unexported field
type cache struct {
entries map[int]User
}
Callers from outside the package see user.User, user.New, but cannot access nextID or cache. This boundary is enforced by the compiler, not by convention.
The internal/ Directory
When unexported visibility within a single package is not enough, and you need to share code between packages in your module without exposing it to external consumers, use internal/.
myproject/
internal/
auth/
auth.go // only importable by code in myproject/
database/
db.go // only importable by code in myproject/
server/
server.go // can import myproject/internal/auth
cmd/
api/
main.go // can import myproject/internal/auth
The Go toolchain enforces this rule. Any package whose import path contains an internal segment can only be imported by code rooted at the parent of internal.
// This works — same module, above the internal/ boundary
import "myproject/internal/auth"
// This fails at compile time — external module trying to import internal
import "github.com/someone/myproject/internal/auth"
Avoid Package-Level State
Global variables in a package create hidden dependencies, make testing harder, and introduce race conditions in concurrent programs.
// Bad: package-level state
package db
var defaultConn *sql.DB
func Init(dsn string) error {
var err error
defaultConn, err = sql.Open("postgres", dsn)
return err
}
func Query(q string) (*sql.Rows, error) {
return defaultConn.Query(q) // hidden dependency on Init()
}
// Good: explicit dependencies
package db
type Store struct {
conn *sql.DB
}
func NewStore(conn *sql.DB) *Store {
return &Store{conn: conn}
}
func (s *Store) Query(q string) (*sql.Rows, error) {
return s.conn.Query(q)
}
The second version is testable, concurrent-safe, and makes its dependencies explicit. The caller decides when and how to create the connection.
Package Naming
Go has strong conventions for package names.
Short. One word is ideal: http, fmt, json, os, sync.
Lowercase. Never use camelCase or PascalCase for package names.
No underscores or hyphens. Use httputil, not http_util or http-util.
No stutter. The package name is part of the qualified identifier. If the package is http, a type should be http.Server, not http.HTTPServer. If the package is user, the constructor should be user.New(), not user.NewUser().
// Good: no stutter
package user
func New(name string) User { ... }
// Caller writes: user.New("Alice")
// Bad: stutters
package user
func NewUser(name string) User { ... }
// Caller writes: user.NewUser("Alice") — "user" appears twice
Descriptive of what it provides. The package name tells you what the package does. compress/gzip provides gzip compression. encoding/json provides JSON encoding.
The Standard Library as a Style Guide
The standard library is the best reference for package design in Go. Study these packages for patterns.
net/http — A single package provides the HTTP client, server, handler interface, and request/response types. It demonstrates how one cohesive package can cover a broad area without being bloated.
encoding/json — Clean API with Marshal, Unmarshal, struct tags, and streaming via Encoder/Decoder. Shows how to design a package with both simple and advanced usage paths.
io — Defines the core interfaces (Reader, Writer, Closer) and utility functions (Copy, ReadAll). A model for small, composable interfaces.
fmt — Short name, clear purpose. fmt.Println, fmt.Sprintf, fmt.Fprintf — every function name reads naturally with the package prefix.
// The standard library reads like well-written prose
buf := bytes.NewBuffer(nil)
_, err := fmt.Fprintf(buf, "Hello, %s", name)
data, err := io.ReadAll(resp.Body)
Organizing Package Contents
Within a package, organize files by responsibility. Each file should have a clear focus.
server/
server.go // Server type, New(), Start(), Shutdown()
handler.go // HTTP handlers
middleware.go // Middleware functions
config.go // Configuration types and defaults
server_test.go // Tests
There is no strict rule about how to split files, but each file should be readable on its own. If a file grows beyond a few hundred lines, consider whether it has multiple responsibilities that deserve separate files.
Common Pitfalls
- Giant packages. A package with dozens of files and thousands of lines is hard to navigate. Split it into focused sub-packages.
- Tiny packages with one type. Going too far the other way creates a maze of imports. A package should represent a concept, not a single type.
- Circular imports. Go does not allow circular package dependencies. If package A imports B and B needs something from A, you need to restructure — usually by extracting a shared interface into a third package.
- Exporting too much. Start with everything unexported. Export only what external callers need. You can always export later; unexporting is a breaking change.
- Package-level init(). The
init()function runs automatically when a package is imported. It is useful for registering drivers but makes code harder to test and reason about. Use it sparingly.
Key Takeaways
- One package = one directory. The package name should match the directory name.
- Capitalization controls visibility: uppercase is exported, lowercase is unexported.
- Use
internal/to share code within your module without exposing it externally. - Avoid package-level state — use struct types with explicit dependencies instead.
- Keep package names short, lowercase, and free of underscores.
- Avoid stutter:
user.New(), notuser.NewUser(). - Study the standard library for excellent package design examples.