1. Why “don’t export extra stuff” sometimes doesn’t help
If you’ve already started splitting a project into packages, pretty quickly a temptation appears: “let me just import that handy helper; it’s exported and it sits there so nicely.” And it seems like no big deal—after all, the code compiles. But a couple of weeks later, you discover that half the project knows the internals of the other half, and any change triggers a domino effect.
The problem is that export/non-export only solves access to names, but it does not solve access to the package itself. If a package is importable, sooner or later someone (including you three days from now) will pull “a temporary thing, just to be quick.” And then that “just to be quick” becomes architecture.
This is where internal/ comes in: it’s a way to tell Go tools, “this package is internal; you may not import it from the outside, even if you really want to.”
What internal/ is and what rule Go checks
The mechanism is simple and at the same time very “Go-like”: no annotations, configs, or magic keywords. There is only directory structure and a rule enforced by the go tool.
Go has supported “internal packages” since Go 1.4: if a package is located in a directory named internal (or under it), then it cannot be imported from code that lives “outside the allowed subtree.” The rule is described directly in the documentation: a package of the form .../a/b/c/internal/d/e/f may only be imported by code within the tree .../a/b/c.
The same thing is explained by go help packages: “code in an internal directory is importable only from within the tree rooted at the parent of internal.”
Let’s not use abstract a/b/c; let’s use a human example. Imagine this structure:
example.com/myapp/
app/
internal/
validate/
cmd/
myapp/
domain/
If we have a package example.com/myapp/app/internal/validate, then only packages within example.com/myapp/app/... are allowed to import it, because the parent of internal is app. Packages from cmd/... or domain/... are already “outside,” so they are not allowed.
This is important: internal/ does not make a package “private by names”; it makes it private by location in the tree.
2. Access map: who can import whom
Now there will be a mini-table so your brain doesn’t try to keep everything in memory (it’s already busy trying not to forget to put , in import).
Assume we have this package: example.com/myapp/app/internal/validate
Then the restrictions are roughly:
| Importing package | Can it import app/internal/validate? | Why |
|---|---|---|
|
yes | it’s inside the app/... tree |
|
yes | it’s inside the app/... tree |
|
no | cmd/... is not inside app/... |
|
no | domain/... is not inside app/... |
This simple table is already half the battle in design: before creating internal, ask yourself, “who should be allowed to import this?”
4. Where to place internal/: inside a layer or at the project root
When you first learn about internal/, you may want to do this:
internal/
everything/
That is, hide absolutely everything there that you “don’t want to expose.” This is not always bad, but it often leads to a weird effect: you’ve created a “pseudo-monolith” inside the project where everything is friends with everything, because internal sits too high.
It helps to keep two scenarios in mind.
Scenario one: internals of a specific layer. Then internal lives inside that layer, for example app/internal/.... This means: “internals are available only to this layer and its subpackages.” This is a great way to prevent the CLI/HTTP layer from poking into someone else’s kitchen.
Scenario two: internals of the entire project (module). Then internal is placed close to the project root: internal/.... In this case, almost all packages inside the project can import it, but external projects cannot. This is convenient for shared utilities that are not meant to be a public library.
Neither option is “more correct” by default. The correct one is the one that matches your responsibility boundary. And yes, this is one of those cases where architecture literally shows up in the folder structure.
5. Hiding validation in app/internal/validate
Let’s continue our mini task app (a hypothetical todo). We already have:
- domain.Task and the constructor domain.NewTask(...),
- the use-case layer app that orchestrates creation and persistence,
- a storage adapter (for example, in-memory).
Suppose in the app package we have logic to validate the title. At first it could just be a function in app, but now we’ll take a step toward a stricter boundary: validation is an implementation detail of use cases, not something that whoever wants should import.
Before: a helper inside app
Let’s start with a primitive variant:
// app/validate.go
package app
import "errors"
var ErrBadTitle = errors.New("bad title")
func validateTitle(title string) error {
if title == "" {
return ErrBadTitle
}
return nil
}
Everything looks fine here, but this approach has a hidden pitfall: as soon as you get subpackages app/..., someone will start copying/dragging these helpers around, and soon you’ll have “validation in three places, but implemented differently.”
After: moving it to app/internal/validate
Now let’s do it so that only the use-case layer can use this utility.
// app/internal/validate/title.go
package validate
import "errors"
var ErrBadTitle = errors.New("bad title")
func Title(s string) error {
if s == "" {
return ErrBadTitle
}
return nil
}
Notice: the package is named validate, not internal—internal is the folder name, not the package name. The package name should be normal so imports read well.
Using it from within app
Now in app we import the internal package:
// app/add_task.go
package app
import (
"context"
"example.com/myapp/app/internal/validate"
)
func AddTask(ctx context.Context, store TaskStore, title string) error {
if err := validate.Title(title); err != nil {
return err
}
return nil
}
This example is short and doesn’t actually persist anything (we already wrote that earlier), but the idea is clear: validate became an “internal pocket” of app.
6. Verifying the restriction: use of internal package not allowed
Now comes the moment many people remember the most: you don’t negotiate with the team, you negotiate with the compiler (okay, with the go tool, but that sounds less epic).
Imagine that in cmd/myapp/main.go someone decided to “reuse the validation,” because “what’s the big deal.”
// cmd/myapp/main.go
package main
import (
"example.com/myapp/app/internal/validate"
)
func main() {
_ = validate.Title("hello")
}
And here go will honestly tell you: not allowed. This is exactly what we wanted: the CLI must not reach into the internals of the use-case layer.
The official wording in go help packages is precisely about this: importing a package from internal is allowed only from within the tree rooted at the parent of internal.
7. Useful nuances and when internal/ makes sense
Multiple internal/ directories in one project is normal
When you first use internal/, it may feel like there must be only one. In practice you can have several, and it’s very convenient: each internal folder creates its own visibility boundary.
For example, this structure can make perfect sense:
example.com/myapp/
internal/
buildinfo/ (internals of the whole project)
app/
internal/
validate/ (internals of the app layer)
adapters/
memstore/
internal/
debug/ (internals of a specific adapter)
The logic is: internal/buildinfo is available to all packages in the project, but not to outside code. app/internal/validate is available only within app/.... And adapters/memstore/internal/debug is available only within adapters/memstore/....
And this is a very pleasant thing: you can keep a project tidy without turning the root into a “util junkyard,” while also not exposing more than you should.
Where internal/ is especially appropriate
What matters now is not to “do internal just for internal,” but to apply it where it actually reduces coupling and protects the architecture.
A good candidate is “internal implementations” that change often. For example, you want to have several small helpers in app: text normalization, parsing some input strings, preparing messages, simple validation. If an external layer imports these things, you’ll lose the freedom to change them.
Another candidate is things that look too tempting to reuse, but shouldn’t be reused. A typical example: some internal/sqlutil or internal/httpjson. At first it feels great to pull that in everywhere. Then you discover you accidentally created a “shared layer” that everything depends on, and any change requires synchronized edits across half the project.
A bad candidate is a “real contract.” If another layer must use something, it should be in the public API: either in domain, or in app, or in a separate package intended for use. internal should not be a way to “hide something everyone needs,” otherwise you’ll constantly fight your own boundaries.
internal/ is about import, not export
If you’re used to thinking “public/private” via uppercase/lowercase letters, then internal/ can feel strange at first: “why can I write Validate.Title, but I can’t import the package?”
Because the mechanism works at the level of import ability, not at the level of names inside the package. This is explicitly emphasized in the description: go checks the import path, and if it contains an internal element, it checks whether the importing code is inside the required subtree.
This means that inside internal you can have exported identifiers (and that’s normal). They are simply exported only for “your own”, i.e., for those who are allowed to import this package.
8. Typical mistakes when working with internal/
Mistake #1: placing internal/ too high and getting a “shared junkyard.”
If you create internal at the project root and start dumping everything into it, then formally you “hid it from the outside world,” but inside the project you created a giant shared package soup. At some point, almost every package will start importing internal/..., and it will become a hidden monolith. This is fixed not by banning it, but by design: keep internal closer to the layer whose internals you are protecting.
Mistake #2: placing internal/ too low and accidentally preventing “your own” code from using what it needs.
Sometimes people create app/usecase/internal/..., and then wonder why app/service cannot import it. The reason is simple: the boundary is defined by the parent of the specific internal folder. If you need all of app/... to see those internals, then internal must live at app/internal/..., not deeper.
Mistake #3: using internal/ as a replacement for a proper contract.
If a package is needed by multiple layers, it’s not “internal”; it’s shared. Hiding it in internal is like hiding the office kettle in a cabinet and handing out the key on a schedule: technically possible, but the team will start hating both the cabinet and the tea. Better to move the contract into the right layer (often that’s domain or the public app package).
Mistake #4: trying to “bypass” the restriction by copying code or using weird import paths.
The restriction exists not to make your life harder, but to signal that you’re trying to pull a dependency across the wrong boundary. If you really want to import app/internal/... from cmd/..., it’s usually a sign that either cmd shouldn’t contain that logic, or that logic should be exposed as a public part of app (via a function/method), or moved into another package based on responsibility.
Mistake #5: forgetting the error happens at import time, not “at runtime.”
internal/ is great because it breaks the build immediately. But beginners sometimes interpret this as “Go is weird.” In reality, it’s just honestly applying the rule from the documentation: importing .../internal/... is allowed only from a specific subtree.
GO TO FULL VERSION