Restricting access to internal packages among siblings
0 reputation · 17 May 2023, 15:53 UTC
0 reputation · 17 May 2023, 15:53 UTC
In Go, the internal directory restricts package access to the parent directory and its subdirectories. While this effectively prevents external modules from Importing sensitive code, it does not provide a mechanism to restrict access between sibling packages located within the same internal tree.
When designing a modular architecture, it is common to expose utility logic to a subset of internal packages while keeping it hidden from other parts of the same module. The current directory-based enforcement is too coarse for this requirement, as any package within the path-level boundary can import any exported identifier from a sibling.
Is there an idiomatic way to limit exposure of an internal package to specific neighbors without restructuring the directory entirely? How can fine-grained visibility be enforced when the internal constraint is already in use?
32275 reputation · 18 May 2023, 01:07 UTC
No. Go has no idiomatic mechanism to make one internal package visible to only a chosen subset of sibling packages. The internal rule is purely directory-based: a package under .../a/internal/... can be imported by any code rooted at .../a, and the compiler offers no per-sibling allowlist. If two packages share the same internal parent boundary, they can import each other's exported identifiers, full stop.
This is intentional design, not an oversight. Go deliberately keeps visibility tied to directory layout so that import legality is obvious from the file tree alone. The language designers' expectation is that finer-grained boundaries come from how you structure packages and modules — or from tooling and code review — rather than from language-level access control lists. So the lack of a sibling-scoping feature is a \"won't add,\" not a missing trick you haven't found yet.
The internal boundary is defined by the parent of the internal directory. If package util should only be reachable by foo and bar but not baz, move it into a deeper internal subtree whose parent is the smallest common ancestor of only the intended importers:
module/
foo/
bar/
baz/
shared/
internal/
util/ # importable only from shared/ and below
If foo and bar live under shared/ but baz does not, baz gets a compile error when it tries to import util. Verify with go build ./... — the disallowed sibling should fail with an \"use of internal package not allowed\" error.
For a genuinely hard boundary, put the restricted code in its own module with private repository access, and add it as an explicit dependency only of the allowed consumers. This gives you versioning and access control at the repository level, at the cost of release coordination and go.mod/replace overhead. Confirm the dependency surface with go list -deps ./... and by reviewing go.mod/go.sum.
If restructuring isn't feasible, keep nearly everything unexported and expose a narrow, deliberate API. Understand the limit: this is encapsulation, not enforcement. Any sibling inside the boundary can still import and call whatever you export, so don't treat it as a security control.
Since the compiler won't help, add a dependency-policy check: a small script (or a tool like go-arch-lint or a go list -deps wrapper) that fails the build when a forbidden import path appears. Pair it with go vet and staticcheck. This catches violations at review time even though the language permits them.
Build tags can technically vary what gets compiled, but they are not a visibility boundary — they're easy to bypass, can silently exclude code, and complicate cross-platform builds. Treat them as a last-resort organizational tool, not an access mechanism.
Before moving code to satisfy visibility, check your dependency direction. Relocating packages can introduce import cycles if the layering isn't clean first; go build ./... after each move will surface these immediately.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.