This is an English version of an article I wrote in Japanese.
The flat package
Opinions differ, but one school in Go says: few packages, large ones. Flat packages. Go against it, and split a package just to get a boundary, and you pay for it.
- Import cycles, and the interfaces you write only to invert a dependency and break them
- Every name the two halves share has to be exported. You wanted a boundary between two files, and you published an API. Keeping the exposure down means an
internal/layer at every boundary you draw - A wrong boundary costs more. Moving a declaration between files disturbs little. Moving it between packages tends to break every importer
So most Go code is said to be healthier flat. Going flat does not make the problem go away, though. The flat-package school cuts both ways. Go has exactly two levels of visibility.
| Level | Reach |
|---|---|
| Exported | Every importing package |
| Unexported | Every file in the package |
There is no third level, no file scope. In a flat package, the smallest helper is a package-wide name, and any field can be written from anywhere in the package.
This helper belongs to this file, so do not call it from another file.
The convention may well be sound. The only place it exists is in the head of the person who wrote the package. You can restate it in a comment every time, and the compiler still does not know it.
Can an AI agent keep the discipline?
In the age of agentic AI, that unwritten understanding tears easily. An agent finds an unexported helper in scope, so it calls it. It sees an unexported field, so it writes to it. Each choice compiles, and a quick review lets it through. Comments help somewhat, never completely. AI writes fast, so the debt piles up just as fast.
"Just write it in CLAUDE.md," you say? I did. Even then, I could not make it hold. A convention in natural language cannot be checked deterministically, so it can only ever work probabilistically.
So let a machine check the convention instead. The package stays flat, the boundary stays inside it, and a dedicated linter does the checking. When an agent crosses a boundary, it is told what it crossed and how to repair it, and the next agent reads that decision. Run that loop.
So I built it.
https://github.com/mpyw/declscope
mise use -g "github:mpyw/declscope" # go install and go tool work too
declscope ./...
What it reports
Picture one database package, with a repository implementation per entity. A common enough layout.
user_repository.go
package database
type UserRepository struct{ db *sql.DB }
func (r *UserRepository) Find(ctx context.Context, id int64) (*domain.User, error) {
row := r.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = ?`, id)
return scanUser(row)
}
// turn a raw result set into a domain entity
func scanUser(row *sql.Row) (*domain.User, error) {
var u domain.User
var email string
if err := row.Scan(&u.ID, &email); err != nil {
return nil, err
}
u.Email = normalizeEmail(email)
return &u, nil
}
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
order_repository.go
package database
type OrderRepository struct{ db *sql.DB }
func (r *OrderRepository) List(ctx context.Context, userID int64) ([]domain.Order, error) {
// elided: run the query and get rows
for rows.Next() {
o, err := scanOrder(rows)
// elided
}
return orders, rows.Err()
}
// turn a raw result set into a domain entity
func scanOrder(rows *sql.Rows) (domain.Order, error) {
var o domain.Order
var email string
if err := rows.Scan(&o.ID, &email); err != nil {
return domain.Order{}, err
}
o.BuyerEmail = normalizeEmail(email)
return o, nil
}
The two helpers do the same job. They pack a raw result set into a domain entity, nothing more. Really, both want to be called scan.
But a flat package can declare only one scan. So we write them apart by hand, as scanUser and scanOrder.
Look closely at what just happened. The User and Order in those names state ownership: which repository each helper belongs to. Something we did to dodge a collision has turned into a convention.
And the compiler does not know that convention.
The crossing an AI agent makes
normalizeEmail was written in user_repository.go, for scanUser. Yet scanOrder calls it.
o.BuyerEmail = normalizeEmail(email) // this one belongs to user_repository.go
The compiler accepts it. It is just a call to an unexported function in the same package. A review can easily miss it, and an agent simply uses whatever it finds in scope.
declscope catches it.
$ declscope ./...
user_repository.go:21:6: func normalizeEmail is private to namespace "userRepository", but is used from namespace "orderRepository"
order_repository.go:21:20: used here, in namespace "orderRepository"
A crossing has two answers
| Answer | How |
|---|---|
| Keep the boundary | Move the call inside the namespace |
| Share on purpose | Write //declscope:package. -fix inserts it for you |
Which one fits here? Normalizing an email address belongs to neither repository. So what we actually want is a third place: create email.go and move it there.
Except boundary does not go quiet on the move alone. Put it in email.go and it is still used from both user and order. You move it out, and then you state that it is shared.
email.go
package database
//declscope:package
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
That one line stays in the source as a statement that this is shared. The next person to open the file reads it first, and so does the next agent.
-fixcan insert//declscope:packagefor you, but not in this case. All-fixcan do is widen a declaration where it stands. It cannot move one across files. Leave it to the tool andnormalizeEmailbecomes shared while sitting inuser_repository.go.
-fixalways widens, because that is the repair a tool can apply mechanically. Deciding where a declaration should live is exactly the judgment you want an AI agent to make.
Only two things to learn
The file name is the namespace
A namespace is the unit within which a private declaration may be used. By default each file is its own namespace, named after the file in lowerCamelCase (user_repository.go becomes userRepository).
Suffixes that follow Go's own conventions are dropped:
*_test.go,*_windows.go,*_darwin.goand the like.
When one unit spans several files, write a directive before the package clause to join a shared namespace.
user_repository.go
//declscope:namespace user
package database
The core namespace
There is one more: a namespace with no name, one per package.
client.go
//declscope:core
package transport
func doSomething() int { return 1 }
Several files may carry //declscope:core, and they share the one core namespace.
To boundary, the core is just one more unnamed namespace. Nothing is special about it. A core declaration is private to the core by default, and reaching it from outside is reported like any other crossing.
client.go:4:6: func doSomething is private to the core namespace, but is used from namespace "other"
Where it matters is qualify, below. The core has no name, so qualify asks nothing of its declarations. It is the escape hatch for the file that carries the package's own subject, and for utilities used from everywhere, so that neither is forced to wear a meaningless prefix.
Files in the core share one namespace, so they can reach each other's private declarations. Throw everything into the core and that part is a flat package again. Use it sparingly.
There are only two scopes: package and private
| Scope | Meaning | Rust equivalent |
|---|---|---|
package |
Usable anywhere in the package | pub(super) |
private |
Usable only inside its own namespace | No modifier |
There is no public. Go already spells that with a capital letter, and a use beyond the package edge is outside what declscope examines.
By default an exported declaration is package, and everything else is private.
Four rules
| Rule | The question it asks | Default |
|---|---|---|
boundary |
May this file touch that declaration? | Always on |
qualify |
Reading this name on its own, can you tell which concern owns it? | Off |
surplus |
Is that shared declaration really needed? | On |
directive |
Does that directive decide anything? | Always on |
The bottom two need little explanation. surplus reports a //declscope:package with no visible use from another namespace. directive reports a directive that binds nothing, or is malformed. Both audit whether what you wrote is still earning its keep.
The boundary rule
This is what we saw at the top. It reports a private declaration used from outside its namespace.
It covers package-level declarations and a type's members, which are a struct's fields and an interface's method names. And the namespace has no exceptions to how it is decided. It is the file the declaration is physically written in, and nothing else.
- A member is written inside its type's declaration, so it belongs to the file the type is in
- A method with a receiver belongs to the file it is written in, wherever its type lives
Splitting a type's methods across files is ordinary Go. Let us add a small query builder to the same database package.
statement.go
package database
// Statement carries the state of a query under construction
type Statement struct {
Table string
wheres []string
args []any
}
func (s *Statement) Build() (string, []any) {
q := "SELECT * FROM " + s.Table
if len(s.wheres) > 0 {
q += " WHERE " + strings.Join(s.wheres, " AND ")
}
return q, s.args
}
query.go
package database
// the DSL that stacks conditions. Methods on Statement, gathered here
func (s *Statement) Where(cond string, args ...any) *Statement {
s.wheres = append(s.wheres, cond)
s.args = append(s.args, args...)
return s
}
The type goes in statement.go, and the DSL that stacks conditions goes in query.go. This shape turns up in well-known Go libraries.
$ declscope ./...
statement.go:6:5: field Statement.wheres is private to namespace "statement", but is used from namespace "query"
query.go:5:5: used here, in namespace "query"
query.go:5:23: used here, in namespace "query"
statement.go:7:5: field Statement.args is private to namespace "statement", but is used from namespace "query"
query.go:6:5: used here, in namespace "query"
query.go:6:21: used here, in namespace "query"
Putting Where in query.go is not itself held against you. What got reported is wheres and args, and they were recorded against statement.go, where they are declared.
These two files are really one concern split in two, so the thing to do is merge the namespaces.
query.go
//declscope:namespace statement
package database
Now query.go joins the statement namespace, and the errors are gone.
The qualify rule
It is off by default, but I recommend turning it on. It asks that a package-level declaration carry its file's namespace somewhere in its name. Where boundary asks "may this file touch that declaration?", qualify asks "reading this name on its own, can you tell which concern owns it?"
It is not a demand for a prefix. Forcing a prefix often produces unnatural names, so the check is deliberately flexible about where the namespace appears.
This naming rule has no effect whatsoever on the
boundaryrule.
Also, struct fields and methods in the same namespace as their type are out of scope.
Take the query builder from above, where the two sit side by side. Build is in statement.go with its type, and Where is in query.go.
statement.go
type Statement struct {
Table string
wheres []string // out of scope
}
func (s *Statement) Build() (string, []any) { /* ... */ } // out of scope
query.go
func (s *Statement) Where(cond string) *Statement { /* ... */ } // in scope
$ declscope ./...
query.go:4:21: method Where does not carry namespace "query" anywhere in its name;
rename it to QueryWhere, or to another name that carries "query"
Nothing is asked of Build, and only Where is reported. Two methods on the same type, in the same shape, treated differently.
What divides them is whether the method is foreign.
- A method in the same namespace as its struct is already identified well enough by the struct's own naming. It is not made to carry the convention a second time in its own name.
- A method in a different namespace from its struct is treated as a foreign method, and is asked to carry that namespace's name.
Of course QueryWhere, the name the tool suggests, is plainly not the answer. One of these would be better:
- Merge the namespaces into one
statement - Rename
query.goto the narrower topic it actually covers, such aswhere.go
For a second example, go back to user_repository.go from the top of this article. scanUser is declared there, on line 11. Turn this rule on, and the first thing you hear is that scanUser is up for renaming.
$ declscope ./...
user_repository.go:11:6: func scanUser does not carry namespace "userRepository" anywhere in its name;
rename it to userRepositoryScanUser, or to another name that carries "userRepository"
userRepositoryScanUser is dreadful, obviously. But the report itself is right, and what is wrong is the rename it suggests.
Think about it. Is the concern this file covers userRepository? It is not. It is user. Repository is in the file name and names no concern.
user_repository.go
//declscope:namespace user
package database
Now the namespace is user, scanUser carries it, and the report goes quiet. The same goes for order_repository.go.
That leaves normalizeEmail. It cannot carry user. And it cannot because it does not belong to user.
email.go
package database
//declscope:package
func normalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
Move it to email.go and the namespace becomes email, which normalizeEmail carries, so the error goes away.
That clears every error in the package. What qualify asks is not "change the name" but "do this name and the place it sits agree with each other?" The answer may be a new name, or a new namespace, or a new file.
The settings I recommend
The same ones declscope holds itself to.
.declscope.yaml
rules:
naming:
qualify: ondemand # required once a package has a second namespace
exported: true # reach exported declarations too
qualify: ondemand switches on for a package with two or more namespaces, and stays off for a package with one. A prefix repeated across everything distinguishes nothing, so it would be pointless there. This is usually the setting you want.
exported: true touches the public API, so adopting it will not always go smoothly. In a repository built mainly around internal, it may go in with little disruption. On a new project I would take it every time.
Note that a rename is never suggested for an exported declaration. It is only reported. Uses outside the package are invisible to declscope, so it cannot judge whether rewriting is safe. Running
-fixwill never change your public API behind your back.
Adopting it on an existing codebase
For a codebase that already has crossings in bulk, use a baseline.
$ declscope baseline ./... # writes .declscope-baseline.yaml
Today's violations are recorded, and from then on only new ones are reported. An entry is keyed by package, rule, namespace and declaration name, never by position, so it survives code moving within a file.
And a baseline suppresses without endorsing. Nothing is written into the source, so the rules still apply to every new declaration, and an entry disappears only when the violation is fixed. What you cleaned up shows up in git diff.
Using it with an AI agent
Back to the motivation. declscope runs wherever an agent's edits are checked: in CI, and inside the agent's own build loop.
On an existing codebase, take a baseline once at the start. The agent is then shown only the boundaries its own edits crossed.
Two things separate this from a convention that is merely written down.
| Property | Effect on the agent |
|---|---|
| The diagnostic names the namespace that was crossed | It learns why the use is wrong, and the repair is mechanical |
| A directive is a durable record of intent | The next agent inherits the decision instead of re-deriving it |
The second one is what I was really after. "This helper may be shared" used to be a decision that died in a review comment, or in somebody's memory. With declscope it stays in the source as one line of //declscope:package, and it is the first thing the next agent reads when it opens the file.
The adoption know-how ships as a skill too
A README is where you write what declscope is. But what an agent trips over when introducing it to an existing repository turned out to be entirely different knowledge. That part ships as a skill.
gh skill install mpyw/declscope
Here is the sort of thing it says.
-
A failed build reports zero diagnostics. And that is indistinguishable from success. Get
go build ./...passing before you read any count - A baseline turns every count into zero. Move it aside before you measure, or the codebase will look clean
-
Zero from
qualifydoes not mean clean. It is off by default, so never report "no problems" from a zero without checking the config. The config is looked up from the analyzed package upwards, so a repository can hold several. Find them all -
//declscope:namespacegoes before the package clause. After it, the directive is silently inert. When nothing you do moves the diagnostics, suspect the placement first - The configuration is the repository owner's decision, not the agent's. Ask before writing a config file, and wait for the answer
It also gives an order of work. Clear boundary first, and clear it by moving the boundary rather than widening everything. Then measure again. While you clear boundary, two namespaces may merge into one, which takes ondemand out of force, and qualify reports can vanish in a chain. Do it the other way round and you spend the day renaming things that were about to disappear.
Limits
declscope reads one package at a time, and counts a use only where a name is written. So the following are invisible to it.
- Operations on a struct's whole value (copying, comparing and zeroing name no field)
- Reflection,
//go:linkname, generated code -
A declaration nobody uses.
boundaryworks by finding uses, so unused code produces nothing
For the last one, pair it with a linter for unused code, such as deadcode. To draw boundaries between packages, that is depguard. depguard keeps the package graph honest, declscope keeps each package honest inside, and deadcode strips what neither needs to reach.
Wrapping up
- Go has two levels of visibility, and no file-scoped private. Stay flat, and "this one belongs to this file" exists only in somebody's head
- An AI agent does not know that convention, and breaks it far faster than people used to. A convention in natural language cannot be checked deterministically, so it only ever works probabilistically
- declscope checks that convention deterministically, with the package still flat. A crossing always has two answers: keep the boundary, or state that the sharing is deliberate
-
Turn
qualifyon as well. Its reports do not say "change the name". They ask whether this file is a sensible place to declare this - A directive is a durable record of a decision somebody made. The next agent inherits it instead of re-deriving it
- The adoption procedure ships as a skill. An author can now write and distribute the instructions an agent is meant to read
https://github.com/mpyw/declscope
If anything is unclear, or a rule feels too strict, please tell me in an issue or in the comments.

Top comments (0)