Skip to content
beginner Phase 7 · Packages, Modules, and Project Layout

Packages and Exported Identifiers

Group files into packages and control visibility with capitalization and internal.

40m
0 problems
Topic Progress 0%

The Day Your Single main.go Hit 4,000 Lines

A CLI tool starts as one main.go: flag parsing, config loading, HTTP handlers, database queries, and a reporting function that nobody remembers writing. It compiles. It runs. Then a second developer joins, and both of them start editing the same file every day. Merge conflicts arrive daily. A rename of formatDate in the reporting section breaks the HTTP handler that happened to use the same name. The code is not failing because it is wrong — it is failing because it has no seams.

Go's answer to this is the package. A package is a folder of .go files that share one package clause at the top of every file. The folder is the unit of organization, and the package clause is the promise that the files belong together:

// file: config/loader.go
package config

import "os"

func Load(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, err
    }
    return parse(data)
}
// file: config/types.go
package config

type Config struct {
    Port int
    DSN  string
}

Two files, one package. Neither file imports the other — they are compiled together as a single unit. Config and Load are visible to each other without qualification, exactly as if they were in the same file. The moment you split code into packages, you gain something the monolith cannot give you: the compiler starts enforcing the boundaries between the pieces.

The rule that surprises newcomers: one folder, one package. Every .go file in a directory must declare the same package name (except _test files, which may declare package foo_test). You cannot split a folder across packages, and you cannot nest packages by creating subfolders with the same name — subfolders are new packages, full stop. This is why Go project layouts look the way they do: the directory tree is the package tree.

Splitting the monolith is mechanical. Find a cluster of functions that share state and a purpose — config loading, say — move them to config/, and the compiler immediately tells you every place that depended on them. Those dependency errors are the tool working: they are the seams being drawn.

Capital Letters Are the API

You moved the config loader into its own package. Now main.go needs to call it, and you discover Go's visibility rule, which is unlike almost any other language: an identifier is exported if and only if its first letter is uppercase. No public keyword, no export modifier. The case of the first character is the access control.

package config

// Exported: visible to any package that imports config.
func Load(path string) (Config, error) { ... }

type Config struct {
    Port int    // Exported field: readable and writable from outside.
    dsn  string // Unexported field: invisible outside the package.
}

// unexported: only callable from within the package.
func parse(data []byte) (Config, error) { ... }

This rule applies uniformly to types, functions, methods, struct fields, constants, and variables. Config is part of the package's public surface; parse is an implementation detail. A consumer that imports config can call config.Load and read cfg.Port, but cfg.dsn does not exist as far as the compiler is concerned — the field is not hidden, it is unnameable from outside.

The design consequence is significant. In Java or C#, you write a class with private fields and public getters, and the boundary is enforced by the type system. In Go, the boundary is enforced by naming, which means it is also enforced by convention: a package's exported identifiers are its contract, and everything else is free to change without breaking anyone. This is why Go codebases can refactor aggressively — the compiler draws a hard line around the exported surface, and everything inside that line is fair game.

A practical discipline that follows: export the minimum. When you create a new type or function, start it lowercase. Raise it to uppercase only when another package actually needs it. It is far easier to export something later (a one-character change, no call sites break) than to unexport it (every caller breaks). The unexported default is not a limitation — it is the feature that keeps Go APIs small.

package config

// DSN is exported because callers constructing a Config directly
// need to set it; the field is part of the usable surface.
type Config struct {
    Port int
    DSN  string
}

// Load reads a config file. Exported: this is the package's reason to exist.
func Load(path string) (Config, error) { ... }

Notice what is not exported: parse, the file-reading helper, and any error type the loader returns internally. A consumer of the config package sees exactly two things — Config and Load — and can ignore everything else. That is the entire public API, and it fits in a glance.

internal: The Boundary the Compiler Enforces

Your project has grown: config/, db/, httpapi/, report/. The report package needs to read the database, so it imports db. The httpapi package needs both. So far so good. Then someone on the team writes a quick utility in db/ — a db/internal/sqlutil package with query helpers — and a contractor's code in cmd/backup-tool imports it directly. Next week the sqlutil API changes, and the backup tool breaks. The boundary you intended — "sqlutil is for the db package only" — was a comment, and comments do not compile.

Go has a mechanism for exactly this: the internal directory. Any package inside a directory named internal can only be imported by code rooted at the parent of that internal directory. The compiler enforces it. Violations are build errors, not code-review comments.

myapp/
  config/
  db/
    internal/
      sqlutil/     <-- importable only by packages under db/
  httpapi/
  cmd/
    backup-tool/  <-- CANNOT import db/internal/sqlutil
// db/query.go
package db

import "myapp/db/internal/sqlutil"   // allowed: db is the parent of internal

func GetUser(id int) (*User, error) {
    return sqlutil.QueryOne[User](...)
}
// cmd/backup-tool/main.go
package main

import "myapp/db/internal/sqlutil"   // COMPILE ERROR:
// "use of internal package myapp/db/internal/sqlutil not allowed"

The rule is about the importing package's path, not about friendship or intent. cmd/backup-tool sits outside db/, so it cannot reach into db/internal no matter how reasonable the request. This is the tool that makes large Go codebases survivable: you can publish a package as "internal to the db subtree" and refactor it freely, knowing that the blast radius is compiler-bounded.

Two details worth knowing. First, internal can appear at any depth — a/b/internal/c is importable only from within a/b. Second, test files in the same package can import the package's own internal dependencies, because they are compiled as part of that package. The boundary is about other packages reaching in, not about the package reaching out.

Use internal proactively, not as a cleanup. When you create a helper package that exists solely to serve one other package, put it in internal/ from day one. The cost is zero — it is a directory name — and the benefit is that the architecture is enforced by the toolchain rather than by a wiki page nobody updates. When you later need to share the helper, you move it out of internal/ and the compiler shows you every importer that was relying on it. That is the boundary doing its job: it makes the private public only when you deliberately decide to, and it shows you the full cost of that decision.

When a Folder Becomes a Package

The flip side of splitting is knowing when not to. Go teams that split too early end up with packages that contain one function and a type, where the "abstraction" is just indirection. The heuristic that works: split when a cluster of code has a reason to change together and a reason to be tested alone.

Concretely, the signals that a package boundary is warranted:

  • A distinct responsibility. Config loading, database access, HTTP transport — each has its own failure modes and its own tests. When you find yourself writing // config-related helpers comments inside a file, that is a package asking to exist.
  • A consumer that wants the piece without the rest. If report needs the database but not the HTTP handlers, and it currently imports a package that drags both in, the database code wants to be its own package.
  • A cycle in the making. If two halves of a package keep needing each other's types, that tension is the compiler telling you the boundary is wrong. (Import cycles and how to break them are covered in Import Cycles and Package Design.)

The signals that you should not split yet:

  • The code is small and cohesive. A 200-line file with one type and its methods is not a problem. Go files are meant to be read top to bottom.
  • You are splitting to reduce file size. A 2,000-line file is a smell, but the fix is usually extracting functions or types, not creating packages. Packages add import edges; functions do not.
  • You are splitting by layer prematurely. Putting every model in one package and every handler in another, before the code has actually grown, produces a structure where every change touches two packages and no change is local.

A good package has a name you can say in a sentence: "the config package loads configuration." If you find yourself saying "the utils package has various helpers," the package has no identity — it is a dumping ground, and the boundary is lying about the code's structure. Rename it to what it actually does, or dissolve it back into the packages that use it.

The deepest reason to get this right: in Go, the package is the unit of both compilation and API design. Every package you create is a promise — a set of exported identifiers that someone will depend on. Create packages with the same care you would create a public library, because that is what they are.

Quiz

1. A teammate creates a helper function `normalizeEmail` in the `users` package and wants the `billing` package to call it. What is the minimal change that makes this work?

Question 1 options

2. Your project has `db/internal/querybuilder`. A new service in `services/orders` needs to use it. What happens when you try to import it?

Question 2 options

3. Which of these is a signal that code should be split into its own package?

Question 3 options

4. Why is it easier to export an unexported identifier later than to unexport an exported one?

Question 4 options

Flashcards

Question

What makes an identifier visible outside its Go package?

Answer

The case of its first letter. Uppercase first letter (Load, Config, Port) means exported and callable from any importing package. Lowercase (load, config, port) means unexported and invisible outside the package — not hidden, but unnameable by the compiler.

Question

What is the one-package-per-folder rule?

Answer

Every .go file in a directory must declare the same package name (test files may use foo_test). Files in one folder are compiled as a single unit and can reference each other's identifiers without imports. Subfolders are always new packages — the directory tree is the package tree.

Question

What does the internal directory enforce, and who enforces it?

Answer

A package inside an internal directory can only be imported by code rooted at the parent of that internal directory. The Go compiler enforces this — violations are build errors. It turns architectural boundaries ("this helper is only for the db package") into compiler-checked facts rather than conventions.

Question

Why should you default to unexported identifiers?

Answer

Exporting is additive and safe; unexporting breaks every caller. Starting lowercase means the package's public API grows only when a real consumer needs it, keeping the exported surface — the actual contract — as small as possible. The compiler then enforces that everything else can change freely.

Question

What are the signals that code should become its own package?

Answer

A distinct responsibility with its own failure modes, a consumer that wants the piece without the rest of the current package, or emerging import tension between two halves. Not signals: file length alone, import count, or team size — those produce indirection without boundaries.

Revision Notes

Key Takeaways

  • 1. A package is a folder of files sharing one package clause; the directory tree is the package tree
  • 2. Visibility is controlled solely by the case of the first letter of an identifier — no keywords
  • 3. internal packages are importable only from the parent subtree, and the compiler enforces it
  • 4. Default to unexported; exporting is additive and safe, unexporting breaks every caller
  • 5. Split code into packages by responsibility and consumption patterns, not by file size or convention

Interview Tips

  • • "How does Go handle visibility?" — Answer: capitalization of the first letter, and explain the design consequence: the exported set is the contract, everything else is refactorable without breakage
  • • "What is internal for?" — Give the db/internal/sqlutil example: it makes package boundaries compiler-enforced so refactoring inside the subtree can't break outside consumers
  • • "When would you extract a package?" — Name the two signals: a distinct responsibility and a consumer that wants the piece without the rest; mention that premature splitting by layer creates churn, not structure
  • • "Why no public/private keywords?" — The naming rule keeps API surface decisions visible in the identifier itself and makes the exported contract greppable in any file

Cheat Sheet

Packages and Exported Identifiers

Visibility rule:

First letter Meaning Example
Uppercase Exported — visible to importers Load, Config, Port
Lowercase Unexported — package-private load, parse, dsn

Package rules:

  • One folder = one package; every file declares the same package clause
  • Files in one package reference each other without imports
  • Subfolders are always new packages

internal rule:

db/internal/sqlutil   <- importable only from under db/

Compiler-enforced. Violations are build errors.

Decision rule: start unexported, raise to exported only when another package needs it. Exporting is additive; unexporting breaks callers.

Split when: distinct responsibility + consumer wants the piece alone. Don't split for: file size, import count, team size.

Continue Learning