Internal Packages — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solutions are at the end.
Easy¶
Task 1 — Your first internal/ package¶
In a fresh module, create one public package and one internal package. Confirm:
- The public package is at
<module>/greet/and the internal one at<module>/internal/words/. cmd/main.goimports both and prints a greeting.go build ./...succeeds.
Goal. Walk the smallest possible "I have an internal package" path end-to-end.
Task 2 — Force a forbidden-import error on purpose¶
Create a second module locally. Try to import the previous module's internal/words from the second module. Expect this error:
Goal. See the error message in your own terminal so you recognise it later.
Task 3 — Two siblings, one shared internal/¶
Add two packages <module>/handler/ and <module>/service/. Both should import <module>/internal/log. Confirm both build.
Goal. Confirm that any code under the parent of internal/ may import.
Task 4 — Move a public package into internal/¶
Take the <module>/greet/ package from Task 1. Move it under internal/:
Update every import. Run go build ./.... Run go test ./... if you have tests.
Goal. Practise the git mv plus import-update workflow.
Task 5 — Move an internal package out¶
Reverse Task 4: move internal/greet back to greet/. Update imports. Confirm build.
Goal. Practise the demotion (promotion to public) workflow. Both directions should feel like one keystroke plus a sed.
Medium¶
Task 6 — Multi-level internal/¶
Reshape your project to:
project/
├── go.mod
├── handler/
│ ├── handler.go
│ └── internal/
│ └── parse/
│ └── parse.go
├── service/
│ └── service.go
└── internal/
└── log/
└── log.go
Confirm: - handler/handler.go may import both handler/internal/parse and internal/log. - service/service.go may import internal/log but not handler/internal/parse.
Add a service import for handler/internal/parse and capture the build failure. Then remove it.
Goal. Internalise the "feature-private vs module-private" distinction.
Task 7 — Black-box tests for an internal/ package¶
In internal/log/, write a log_test.go using package log_test. Import <module>/internal/log from the test. Confirm go test ./... succeeds.
Goal. Confirm tests inside the internal/ subtree can use the public-style import.
Task 8 — Tests in a top-level tests/ directory¶
Create tests/log_test.go. Make it package tests and import <module>/internal/log. Confirm go test ./... succeeds.
Now move that file to a different module (a sibling repo with its own go.mod). Confirm the test fails to build with the rule's error.
Goal. Distinguish "outside the package, inside the parent" from "outside the parent, full stop."
Task 9 — Refactor a leak¶
Take a project (yours or this exercise file's) where the public surface has accidentally exposed a helper. Identify the helper. Move it into internal/. Update imports. Run go list ./... | grep -v internal to verify it no longer appears in the public list.
If you have a real downstream consumer (or simulate one with a sibling module), watch the consumer break and document the migration.
Goal. Practise the surface-shrinking refactor with all its consequences.
Task 10 — Promote an internal/ package consciously¶
Pick an internal/ package that has matured. Decide it should be public. Move it. Add:
- Package-level doc comment with a Synopsis.
- An
Exampletest function. - A line in
README.mdlisting it as part of the public API.
Verify with go doc <module>/<pkg> and go test ./... -v -run Example.
Goal. Feel the cost of making something public. It is more work than hiding it.
Task 11 — Identify the parent of internal/ for any path¶
For each of the following import paths, write down the parent directory and the set of importers that would be allowed:
example.com/lib/internal/xexample.com/lib/foo/internal/xexample.com/lib/foo/internal/x/internal/yexample.com/internalstuff/x(note:internalis a substring)example.com/lib/Internal/x(capitalI)
Goal. Train your eye to see the boundary at a glance. The last two are tricky — the rule does not fire on substrings or non-lowercase elements.
Task 12 — cmd/internal/ for shared CLI helpers¶
Add a layout where two binaries share helpers private to the cmd/ subtree:
project/
├── go.mod
├── cmd/
│ ├── api/main.go
│ ├── worker/main.go
│ └── internal/
│ └── flagutil/
│ └── flagutil.go
└── internal/
└── domain/
└── domain.go
Confirm: - Both cmd/api and cmd/worker may import cmd/internal/flagutil. - internal/domain may not import cmd/internal/flagutil.
Goal. See multi-level internal/ solving a real layout problem.
Hard¶
Task 13 — Refactor a real project's public surface¶
Take a Go project of your own (or fork a small open-source one). Walk its public surface:
For each entry, ask the surface-audit questions (stable? documented? necessary?). Identify three packages that should be hidden. Move them. Verify:
If the project is published (module example.com/yourthing), draft release notes for a v2.0.0 describing the breaking change.
Goal. Run the senior-level surface audit on a real codebase.
Task 14 — Multi-module mono-repo with deliberate sharing¶
Build:
monorepo/
├── go.work
├── server/
│ ├── go.mod
│ └── internal/...
├── sdk/
│ ├── go.mod
│ └── ...
└── shared/
├── go.mod ← intentional shared module
└── ...
server and sdk both import shared. Neither imports the other's internal/. Confirm:
- The mono-repo builds with
go build ./...from the root (workspace mode). - Each module builds independently with its own
go.mod. - Neither
servernorsdkcan reach into the other'sinternal/.
Goal. Practise the discipline of multi-module mono-repos: deliberate sharing, no leaks.
Task 15 — Build a tiny "is this import allowed" tool¶
Write a Go program that reads go list -json ./... output and reports any import that violates the internal/ rule. The program should:
- Parse
go list -json ./...JSON. - For each package, walk its
Imports. - For each import, run the algorithm from
professional.mdto decide allowability. - Print violations with importer, imported, and the parent of the offending
internal/element.
Run it on a project with at least one violation (you may have to create one).
Goal. Implement the rule yourself. It will fit in 60 lines.
Task 16 — Architectural enforcement with depguard¶
Add golangci-lint with depguard rules to one of your projects. Configure rules so that:
internal/repo/...may not be imported frominternal/handler/...directly (must go throughinternal/service/...).internal/...may not be imported fromcmd/...(must go through a public façade orinternal/wiring/).
Run the linter and watch it catch violations the toolchain would not.
Goal. See internal/ and lint rules as complementary tools, not substitutes.
Task 17 — Promote an internal/ package across modules¶
Set up a scenario where one of your modules has internal/util that another module wants to use. Resolve it three ways:
- Duplicate
utilin the second module. - Promote
utilto public in the first module. - Extract
utilinto a third, dedicated module both depend on.
For each, document what changed in: - go.mod files - Import paths - Release tags - Stability commitments
Goal. Live the trade-offs of cross-module sharing.
Task 18 — Write a "stability policy" for a real library¶
Pick a library you maintain (or a fictional one). Write a STABILITY.md covering:
- What is the public API? (
<module>/...minus/internal/.) - What stability does each public package promise (patch-level? minor? major-only?)?
- How is breakage announced? (
Deprecated:comments, release notes, deprecation period.) - What is the policy for promoting an
internal/package to public? - What is the policy for hiding a public package under
internal/?
Add a CI check that diffs public.txt (the list of non-internal packages) on every PR.
Goal. Codify the senior-level discipline so contributors can't drift it.
Bonus / Stretch¶
Task 19 — Read the cmd/go source¶
Clone the Go source repository. Find the function that enforces internal/. Read it end to end. Find:
- The exact name of the function in your Go version.
- The error message it produces.
- Where the rule fires (load time vs build time).
Note the line count. It will be smaller than you expect.
Goal. Demystify the toolchain. The rule really is "ten lines of path manipulation."
Task 20 — Port the rule to a different build system¶
If you use Bazel or Buck2, write a custom rule that enforces internal/ on Go targets. (Or: just write the predicate and run it against your BUILD.bazel files.)
Goal. Confirm that the rule ports cleanly outside cmd/go.
Solutions (sketched)¶
Solution 1¶
mkdir hello && cd hello
go mod init example.com/hello
mkdir -p greet internal/words cmd
cat > greet/greet.go <<'EOF'
package greet
import "example.com/hello/internal/words"
func Hello(name string) string { return words.Greet() + ", " + name }
EOF
cat > internal/words/words.go <<'EOF'
package words
func Greet() string { return "hi" }
EOF
cat > cmd/main.go <<'EOF'
package main
import (
"fmt"
"example.com/hello/greet"
)
func main() { fmt.Println(greet.Hello("world")) }
EOF
go run ./cmd
Solution 2¶
mkdir other && cd other
go mod init example.com/other
go mod edit -replace=example.com/hello=../hello
go mod edit -require=example.com/hello@v0.0.0
cat > main.go <<'EOF'
package main
import "example.com/hello/internal/words"
func main() { _ = words.Greet }
EOF
go build ./...
# main.go:3:8: use of internal package example.com/hello/internal/words not allowed
Solution 3¶
Both handler/handler.go and service/service.go import <module>/internal/log and call its public function. Both compile because both live under the module root, which is the parent of internal/.
Solution 4¶
git mv greet internal/greet
sed -i '' 's|example.com/hello/greet|example.com/hello/internal/greet|g' cmd/main.go
goimports -w .
go build ./...
Solution 5¶
Same in reverse:
git mv internal/greet greet
sed -i '' 's|example.com/hello/internal/greet|example.com/hello/greet|g' cmd/main.go
goimports -w .
go build ./...
Solution 6¶
The service import of handler/internal/parse produces:
Solution 7¶
// internal/log/log_test.go
package log_test
import (
"testing"
"example.com/hello/internal/log"
)
func TestPublicAPI(t *testing.T) {
if log.Banner() == "" {
t.Fatal("expected non-empty")
}
}
Solution 8¶
The tests/ directory is inside the module root, which is the parent of internal/. The test compiles. When moved to a separate module, the rule rejects:
Solution 9¶
Pick (for example) a Helper function in <module>/util/. Move:
go list ./... | grep -v internal no longer shows util. Solution 10¶
Addutil/doc.go: Add util/example_test.go: package util_test
import (
"fmt"
"example.com/hello/util"
)
func ExampleDoSomething() {
fmt.Println(util.DoSomething("x"))
// Output: ...
}
Solution 11¶
| Path | Parent | Allowed importers |
|---|---|---|
example.com/lib/internal/x | example.com/lib | anything under example.com/lib/... |
example.com/lib/foo/internal/x | example.com/lib/foo | anything under example.com/lib/foo/... |
example.com/lib/foo/internal/x/internal/y | example.com/lib/foo/internal/x | anything under example.com/lib/foo/internal/x/... (the deepest boundary wins) |
example.com/internalstuff/x | — | anyone (internal is a substring, not a path element) |
example.com/lib/Internal/x | — | anyone (Internal with capital I is not the magic name) |
Solution 12¶
cmd/internal/flagutil is reachable from anything under cmd/. internal/domain is not under cmd/, so it gets:
Solution 13¶
Realistic before/after for a small library:
before.txt
example.com/lib
example.com/lib/api
example.com/lib/parser
example.com/lib/util ← accidentally public
example.com/lib/cmd/cli
after.txt
example.com/lib
example.com/lib/api
example.com/lib/parser
example.com/lib/cmd/cli
util moved to internal/util; equivalents are now provided through Parser.Helper." Solution 14¶
Each module'sgo.mod lists replace example.com/shared => ../shared for local development; once shared has a real version tag this can be removed. Solution 15¶
package main
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
type Pkg struct {
ImportPath string
Imports []string
}
func parent(imported string) (string, bool) {
elems := strings.Split(imported, "/")
last := -1
for i, e := range elems {
if e == "internal" {
last = i
}
}
if last == -1 {
return "", false
}
return strings.Join(elems[:last], "/"), true
}
func allowed(imported, importer string) bool {
parent, ok := parent(imported)
if !ok {
return true
}
return importer == parent || strings.HasPrefix(importer, parent+"/")
}
func main() {
out, _ := exec.Command("go", "list", "-json", "./...").Output()
dec := json.NewDecoder(strings.NewReader(string(out)))
for dec.More() {
var p Pkg
if err := dec.Decode(&p); err != nil {
return
}
for _, imp := range p.Imports {
if !allowed(imp, p.ImportPath) {
fmt.Printf("VIOLATION: %s imports %s\n", p.ImportPath, imp)
}
}
}
}
Solution 16¶
# .golangci.yml
linters:
enable:
- depguard
linters-settings:
depguard:
rules:
no-handler-to-repo:
files: ["**/internal/handler/**"]
deny:
- pkg: "example.com/.../internal/repo"
desc: "handler must call service, not repo directly"
no-cmd-to-internal:
files: ["**/cmd/**"]
deny:
- pkg: "example.com/.../internal/"
desc: "cmd/ wires through a façade, not directly"
Solution 17¶
| Approach | go.mod changes | Stability commitment |
|---|---|---|
| Duplicate | none | none — each copy can drift |
| Promote | first module exposes a new public package | yes — first module owes stability |
| Extract third module | new go.mod, both modules require it | yes — third module owes stability |
Solution 18¶
A skeleton STABILITY.md:
# Stability Policy
## Public API
Anything under `example.com/lib/...` *not* in an `internal/` directory.
## Stability Tiers
- Public packages: SemVer (patch / minor / major)
- `internal/` packages: free to change at any release
## Breaking changes
Any change to a public package signature requires a major version bump.
## Deprecation
Mark with `// Deprecated:`. Removed no earlier than the next major release.
## Promotion / hiding
- internal → public: requires a documented use case + senior review.
- public → internal: major version bump, release notes, deprecation period.
Solution 19¶
The function (Go 1.22) is disallowInternal in cmd/go/internal/load/pkg.go. It is roughly 80 lines including comments and edge cases. The error message is:
loadPackage, not during compilation. Solution 20¶
For Bazel rules_go, this is already implemented. For a custom rule, you would write a genrule or aspect that runs the algorithm from Solution 15 on every go_library target.
Checkpoints¶
After completing the easy tasks: you can confidently create, move, and reason about internal/ packages.
After medium: you can refactor public surfaces, scope visibility with multi-level internal/, and explain why the rule rejects in any given case.
After hard: you can audit a real codebase, design a multi-module mono-repo deliberately, and write tooling that enforces or extends the rule.