Code of the Day
IntermediatePackages & testing

Package design

Organise Go code into packages — naming, exported vs unexported identifiers, init(), and avoiding circular imports.

GoIntermediate11 min read
Recommended first
By the end of this lesson you will be able to:
  • Apply Go's naming conventions for packages
  • Distinguish exported (uppercase) from unexported (lowercase) identifiers
  • Explain what init() does and when to use it
  • Describe what a circular import is and how to avoid it
  • Sketch a typical Go project layout

Go organises code into — the unit of compilation, namespace, and reuse. A package is just a directory of .go files that all share the same package declaration at the top. Understanding how packages work shapes every architectural decision you make in Go.

Package naming

A package name should be:

  • A short, lowercase, single word: store, user, http, parser.
  • Descriptive of what it provides, not what it does to something else.
  • Not a generic catch-all like util, common, or helpers.

The name appears at every call site — store.Get(id) reads cleanly, utilities.GetUser(id) does not. When callers use your package name as a qualifier, shorter and more specific is better.

// Good: one concept per package
package store   // knows about persistence

// Avoid: mixing unrelated things
package utils   // nobody knows what to expect here

Exported vs unexported identifiers

Go's visibility mechanism is capitalisation, and it applies to everything: types, functions, variables, constants, struct fields, and interface methods.

package store

// Exported — callers in other packages can use these
type User struct {
    ID    int
    Name  string
    email string   // unexported — hidden from other packages
}

// Exported function
func Get(id int) (*User, error) { ... }

// Unexported helper — internal use only
func validate(u *User) bool { ... }

are package-private. There is no public/private/protected — the single rule (uppercase = , lowercase = unexported) covers everything.

A struct field being unexported does not make the struct immutable. You can still provide exported methods that read or write the field. This is how you enforce invariants: the constructor (exported function) validates the value, the field stays unexported, and mutation happens only through methods that re-validate.

init()

Each package can declare one or more init() functions. They run automatically when the package is first imported, after all variable initialisations:

package store

var defaultTimeout time.Duration

func init() {
    // Runs once, when the package is imported
    defaultTimeout = 30 * time.Second
}

Use init() sparingly. It runs hidden from callers and makes code harder to test in isolation. Prefer explicit initialisation through constructors or New… functions. Legitimate uses include registering drivers (database/sql), seeding random number generators, and self-registering plugins.

Avoid side effects in init() that depend on external state (environment variables, files, network). If initialisation can fail, it cannot return an error from init() — the only option is to panic, which is rarely correct at package load time.

Circular imports

Go prohibits circular imports at the compiler level. If package a imports b and b imports a, the build fails. This is a deliberate constraint: it keeps the dependency graph a DAG and prevents tangled codebases.

When you find yourself with a circular dependency, the usual fix is to extract the shared type or interface into a third package that both sides import:

Before: user ↔ store (circular)
After:  user → types ← store (types holds the shared interface)

The other common fix is to pass a function or interface as a parameter rather than importing the package that provides it — dependency injection over direct import.

A typical project layout

myapp/
├── go.mod
├── main.go            // or cmd/myapp/main.go
├── internal/          // unexported to outside modules
│   └── store/
│       └── store.go
├── user/
│   └── user.go
└── config/
    └── config.go

The internal/ directory is special: Go only allows packages inside it to be imported by code rooted at the parent directory. It is the standard way to share code within a module without making it a public API.

Check your understanding

Knowledge check

  1. 1.
    Which package name best follows Go conventions for a package that manages database connections?
  2. 2.
    Go allows circular imports as long as the cycle is not deeper than two packages.
  3. 3.
    You place code in myapp/internal/cache/. Which package can import it?

Do it yourself

Create a minimal two-package project: a greet package with an exported Hello(name string) string function and a main.go that imports and calls it.

mkdir -p hello/greet
# write greet/greet.go and main.go
go run .

Where to go next

Now that you understand how packages are structured, the next lesson covers Go modules — how the go.mod and go.sum files manage dependencies and versioning across a project.

Finished reading? Mark it complete to track your progress.

On this page