The merge queue problem
You join a team of twelve engineers. The codebase has grown for two years without a strict architectural review. Every Friday, the merge queue backs up. Reviewers spend more time arguing about package boundaries, naming consistency, and error handling patterns than actual business logic. The code runs in production, but navigating it feels like walking through a house where every door is unlocked and every room follows a different floor plan. Go solves this not with heavy frameworks or rigid linting pipelines, but with a few deliberate design choices baked directly into the language. Maintainability in Go comes down to explicit boundaries, predictable naming, and treating your public API like a signed contract.
Visibility is a compiler feature
In many languages, you write public or private keywords to control access. Go skips the keywords and uses capitalization. A name starting with a capital letter is exported. A name starting with a lowercase letter is hidden from other packages. This is not a style guide suggestion. The compiler enforces it at build time. If you try to access a lowercase identifier from another package, the build fails immediately. This forces you to think about what actually needs to leave your package. Most code should stay local. Export only what another package genuinely needs to call or inspect.
Convention aside: Public names start with a capital letter. Private names start lowercase. There are no visibility keywords. The compiler treats capitalization as the boundary marker.
Keep your surface area small. Hide implementation details behind unexported helpers.
The anatomy of a clean package
Every Go package lives in its own directory. The directory name becomes the import path suffix. That name should describe the package's job, not its internal mechanics. A package named utils or helpers tells a reviewer nothing. A package named cache, auth, or metrics sets a clear expectation. Inside the package, every exported identifier gets a documentation comment on the line directly above it. The comment starts with the identifier's name. This is how go doc and IDE tooltips generate their output. If you skip the comment, your teammates get a blank page when they hover over your function.
Here is a minimal package that follows the pattern:
// Package auth handles user authentication and session validation.
package auth
// User holds the profile data for a registered account.
type User struct {
// ID stores the unique database identifier for the account.
ID string
// Name stores the display name shown in the UI.
Name string
}
// Authenticate checks credentials against the database and returns a User.
// It returns an error if the credentials are invalid or the database is unreachable.
func Authenticate(username, password string) (*User, error) {
// Validate input length to prevent downstream SQL injection attempts.
if len(username) == 0 || len(password) == 0 {
return nil, fmt.Errorf("missing credentials")
}
// Query the database and map the result to a User struct.
// Database interaction omitted for brevity.
return &User{ID: "u_123", Name: username}, nil
}
The package name auth matches the directory. The struct User is exported because other packages need to read user data. The function Authenticate is exported because it is the entry point. The comment for Authenticate explains what it does and what it returns on failure. The compiler will reject any attempt to import auth and call authenticate with a lowercase a. The visibility boundary is absolute. When a teammate runs go doc auth, they see exactly what you documented. They do not see internal helpers, private structs, or implementation details.
Document your exports. Let the compiler guard your internals.
How the toolchain enforces consistency
Large teams survive on predictability. Go provides three built-in tools that remove formatting debates and surface design flaws before code reaches production. gofmt runs automatically in most editors and reformats code to a single canonical style. You do not argue about indentation, brace placement, or spacing. The tool decides. go vet runs static analysis to catch common mistakes like formatting verbs that do not match argument types, unreachable code, or suspicious pointer usage. go doc pulls your comments and struct tags into a readable reference. When you follow these tools, code reviews shift from policing style to evaluating architecture.
Convention aside: gofmt is mandatory. Do not fight the formatter. Trust gofmt. Argue logic, not formatting.
Run the toolchain locally. Ship consistent code.
Realistic package design
Real code handles errors, respects cancellation, and separates interfaces from implementations. Go teams rely on a few patterns to keep large codebases predictable. Error handling uses explicit if err != nil checks. The verbosity is intentional. It forces every caller to acknowledge the failure path. Context flows through long-running calls as the first parameter, conventionally named ctx. Functions that accept a context must respect cancellation and deadlines. Interfaces are accepted as parameters, but structs are returned. This keeps your package decoupled from how callers construct their data.
Here is a more realistic example showing these patterns in action:
// Package cache provides an in-memory key value store with expiration.
package cache
import (
"context"
"time"
)
// Store holds the cached entries and manages their lifecycle.
type Store struct {
// entries maps keys to their stored values and expiration times.
entries map[string]entry
}
// entry wraps a value with its expiration timestamp.
type entry struct {
value interface{}
expiresAt time.Time
}
// NewStore creates a fresh cache instance.
func NewStore() *Store {
// Initialize the map so the first write does not panic.
return &Store{entries: make(map[string]entry)}
}
// Get retrieves a value by key. It returns false if the key is missing or expired.
func (s *Store) Get(ctx context.Context, key string) (interface{}, bool) {
// Check context cancellation before doing any work.
select {
case <-ctx.Done():
return nil, false
default:
}
// Look up the entry and verify it has not expired.
e, ok := s.entries[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.value, true
}
Convention aside: The receiver name s matches the type Store. Go convention favors one or two letter receivers that mirror the type name. You will rarely see this or self. The Get method takes ctx as the first parameter. It checks ctx.Done() immediately. The method returns a value and a boolean instead of an error because cache misses are expected behavior, not failures. This matches standard library patterns like map lookups.
Convention aside: context.Context always goes as the first parameter. Functions that take a context should respect cancellation and deadlines. Context is plumbing. Run it through every long-lived call site.
Convention aside: Accept interfaces, return structs. This is the most common Go style mantra. It prevents unnecessary coupling while giving callers concrete types to work with.
Build for the unhappy path. Make cancellation explicit.
Pitfalls that break team velocity
Large teams stumble when they ignore Go's design signals. Over-exporting is the most common mistake. When every struct field and helper function is capitalized, the package becomes a public API nightmare. Teammates start depending on internal details. A simple refactor breaks three other services. The compiler will not stop you from exporting too much, but it will stop you from using unexported names across package boundaries. If you get cannot refer to unexported name internal.Helper from the compiler, that is the language protecting you. Respect it.
Another trap is vague package names. A package named db or common forces reviewers to read every file to understand its purpose. The import path should read like a sentence. import "github.com/org/project/internal/cache" tells you exactly where the code lives and what it does. When you import a package, you get its exported names. If the name is unclear, the mental tax compounds across the codebase.
Pointer misuse also slows teams down. Strings and slices are already cheap to pass by value. They hold a pointer, length, and capacity internally. Passing a *string adds an extra layer of indirection without saving memory. The compiler will accept it, but it signals confusion about how Go manages memory. Stick to value semantics for small types. Use pointers only when you need to mutate a large struct or explicitly represent a nil state.
Goroutine leaks happen when a background task waits on a channel that never closes. Always provide a cancellation path. If a goroutine blocks on a receive and the sender exits early, that goroutine stays in memory forever. The runtime will not warn you. You will only notice when your process slowly consumes RAM over weeks. The worst goroutine bug is the one that never logs.
Convention aside: _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Never discard an error unless you have a documented reason.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors behind silent swallowing or custom wrappers that lose stack traces.
Follow the conventions. The compiler will catch the rest.
Choosing the right boundary
Package design requires tradeoffs. You want cohesion without coupling. You want clarity without ceremony. Pick the pattern that matches your team's workflow.
Use a single package when the types and functions share a tight conceptual relationship and belong in one directory. Use multiple packages when you need to enforce a dependency direction and prevent circular imports. Use an internal package when you want to share code across a module but hide it from external consumers. Use a public repository when you are publishing a library and need stable versioning. Use value types when the data is small and does not need to represent a missing state. Use pointers when you need to mutate a large struct or explicitly allow nil. Use explicit error returns when the failure path requires immediate handling or cleanup. Use panic only for unrecoverable programming errors that should crash the process.
Draw the line early. Export less. Document what remains.