Go / Intermediate: Packages and Concurrent Work / Lesson 7
Packages and API Boundaries
Design cohesive packages with small exported contracts, explicit dependencies, and reproducible module requirements.
On this page
Lesson goals
- Separate package identity from module versioning.
- Use visibility and constructors to protect invariants.
- Choose dependencies without creating import cycles.
A package is a unit of responsibility
A package collects code with a coherent purpose and a single import path. A module can contain many packages and versions them together. Keep a package name concise and let exported names read clearly at call sites. Avoid a giant util package whose unrelated helpers create dependency knots and make ownership hard to see.
Go rejects import cycles. Rather than moving arbitrary files until the compiler stops complaining, identify the conceptual dependency direction. A consumer-owned small interface can let high-level logic depend on behavior while a separate adapter supplies storage or transport. Wire the concrete pieces at the command boundary.
Keep invalid states behind operations
An uppercase identifier is exported from a package; a lowercase identifier is package-private, not private to one file. Private fields let constructors and methods enforce relationships. The first example models a nonnegative quantity with a constructor that rejects negative input. Its single-file form keeps the demonstration independently runnable; a real package would publish only the intended API.
The zero value is also a valid quantity here. A constructor may be helpful without being mandatory. If a type instead requires initialization, document that requirement and avoid accidentally returning an unusable zero value from a failure path that the caller might mistake for success.
package main
import (
"errors"
"fmt"
)
type Quantity struct{ value int }
func NewQuantity(value int) (Quantity, error) {
if value < 0 {
return Quantity{}, errors.New("negative quantity")
}
return Quantity{value: value}, nil
}
func (q Quantity) Value() int { return q.value }
func main() {
q, err := NewQuantity(7)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("quantity", q.Value())
}
quantity 7
The constructor accepts seven, and callers observe it through the accessor.
Pass dependencies explicitly
Dependencies can be values and interfaces rather than package-level globals. A function accepting an io.Writer can send output to a buffer in tests and to a file in a command. The consumer needs only the Write contract, not knowledge of every concrete writer. This is ordinary parameterization rather than a special framework.
The second example checks the write result and returns the error to its caller. It avoids terminating the process from a reusable helper. An operation that owns a resource should document closing responsibility; accepting an io.Writer does not normally mean the callee should close a writer it did not create.
package main
import (
"bytes"
"fmt"
"io"
)
func greet(w io.Writer, name string) error {
_, err := fmt.Fprintf(w, "hello %s\n", name)
return err
}
func main() {
var output bytes.Buffer
if err := greet(&output, "Ada"); err != nil {
fmt.Println(err)
return
}
fmt.Print(output.String())
}
hello Ada
A bytes.Buffer satisfies io.Writer, so the helper can be verified without terminal or file access.
Record dependency meaning, not just files
go.mod records module requirements and related directives; go.sum records cryptographic checksums of module content, not a complete traditional lockfile of every possible build input. Module selection follows Go’s documented version rules. A requirement is not permission to assume that every transitive package is used or that every platform selects the same files.
Review dependency changes deliberately and keep generated module metadata consistent. The internal directory convention restricts which importers may access implementation packages. Semantic import versioning places major versions v2 and above in the module path for ordinary modules, so changing a major API can require changing import paths as well as requirements.
Compatibility includes behavior
An unchanged function signature can still break callers if mutation, ordering, error identity, cancellation, or ownership changes. Document those properties near exported declarations. A package that once returned independent data should not start returning a mutable shared slice without revisiting its public contract.
Keep command configuration, logging decisions, and process exit policy outside reusable packages where practical. Test the package through exported behavior while retaining focused internal tests for complex invariants. A small API is easier to preserve, review, and evolve than one exposing every implementation convenience.
Common errors and fixes
Two packages import each other.
- Why it happens
- Responsibilities and dependency direction are tangled.
- How to fix it
- Extract a cohesive shared abstraction or inject a consumer-owned interface.
- Prevent it next time
- Review the import graph before adding cross-package knowledge.
A helper closes a caller’s output unexpectedly.
- Why it happens
- Borrowing was confused with ownership.
- How to fix it
- Leave borrowed writers open unless the contract explicitly transfers responsibility.
- Prevent it next time
- Document creation and closing ownership.
A patch preserves types but breaks consumers.
- Why it happens
- Behavioral promises changed without review.
- How to fix it
- Restore the documented behavior or make a deliberate compatibility transition.
- Prevent it next time
- Test ownership, errors, and ordering at public boundaries.
Check your understanding
1. A package uses a global writer and calls os.Exit after a write error. Redesign its reusable boundary.
Show worked solution
Accept an io.Writer parameter or a constructed dependency and return the write error. Let the command decide how to log and whether to exit. Tests can inject bytes.Buffer and a synthetic failing writer. This makes failure behavior observable without killing the test process and keeps the package from assuming ownership of the caller’s writer.
2. Why should a constructor copying a slice document whether the copy is shallow?
Show worked solution
An outer slice copy separates its backing array, but elements containing pointers, maps, or slices can still refer to shared mutable data. Callers need to know which later mutations are isolated. For []int, copying elements is sufficient for element independence; for richer records, choose a nested-copy or borrowing policy and test it explicitly.
Key takeaways
- Packages own responsibilities; modules own versioned distribution boundaries.
- A narrow dependency is easier to test than hidden global behavior.
- Compatibility includes ownership, errors, and ordering as well as types.