Course outline

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.

About 5 min readContent reviewed
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.

go A public operation around a private invariant
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())
}
Expected output
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.

go A narrow injected output dependency
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())
}
Expected output
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.

Official references