Skip to content

Why

Go has two levels of visibility, and the lower one covers the whole package.

Level Reach
Exported Every importer
Unexported Every file in the package

There is no third level. In a flat package, every helper is a package-wide name, and every field is a package-wide reach.

Splitting the package to get a boundary has a price.

  • Import cycles, and interfaces written only to break them.
  • Every name the two halves share has to be exported. You wanted a boundary between two files, and you published an API.
  • A wrong boundary costs more. Moving a declaration between files is free. Moving it between packages breaks every importer.

internal/ does not help here. It limits who may import a package, but it adds no level below unexported. So most Go code is better off flat, and the cost appears inside the package.

Teams handle this with a convention: this helper belongs to this file. The convention lives only in the heads of the people who wrote the package.

An AI agent does not know it either. It sees an unexported helper in scope, so it calls it. It sees an unexported field, so it writes to it. Each edit compiles and passes a quick review, and the package becomes a mesh.

declscope checks the convention instead. When an agent crosses a boundary, it is told what it crossed and given a fix. The decision is then written in the source, where the next agent reads it.

What it reports

One database package holds a repository per entity.

// user_repository.go
package database

import (
    "context"
    "database/sql"
    "strings"

    "example.com/app/domain"
)

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)
}

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

import (
    "database/sql"

    "example.com/app/domain"
)

type OrderRepository struct{ db *sql.DB }

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 scan helpers are named apart by hand, since only one could be called scan. That mark says which repository owns which helper, and nothing holds anyone to it.

normalizeEmail was written for scanUser. scanOrder calls it, and the compiler accepts that.

$ declscope ./...
user_repository.go:28:6: func normalizeEmail is private to namespace "userRepository", but is used from namespace "orderRepository"
order_repository.go:17:17:      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, which -fix inserts

Here the helper belongs to neither repository. The repair is a third file, and a stated scope.

// email.go
package database

import "strings"

//declscope:package
func normalizeEmail(s string) string {
    return strings.ToLower(strings.TrimSpace(s))
}

Tip

-fix always widens, because that is the repair a tool can apply. It writes the directive where the declaration stands, and never moves a declaration to another file. Where the boundary is worth keeping, move the call yourself.

Where declscope sits

Three linters draw boundaries in Go, at three scales.

A Go program drawn as nested frames. Between the api and database packages, depguard asks whether one package may import another. A green arrow runs from api to database, and a red one back from database to api is crossed out. Inside database, between user_repository.go and order_repository.go, declscope asks whether one file may reach another's declaration. A red arrow from scanOrder to normalizeEmail is crossed out. At the edge of the program, deadcode asks whether anything is reachable at all. The mail package sits greyed out with no arrow entering it, captioned unreachable.

Linter Scale The question it answers
depguard Between packages May this package import that one?
declscope Within one package May this file reach that declaration?
deadcode Whole program Is this reachable at all?

The three compose. depguard keeps the package graph honest, declscope keeps each package honest inside, and deadcode removes what neither needs to reach.