59. Refactoring Go codebases: package seams, APIs, and compatibility
59.1 Refactor around package boundaries, not files
In Go, the package is the real unit of design. Files are just organization. Before splitting a package, ask:
- Does the new package have a small, coherent purpose?
- Can it avoid importing the package that imports it? If not, you are creating a cycle.
- Is the API smaller than the implementation it hides?
- Would a caller outside this directory understand the exported names?
The common refactor is extracting a lower-level package:
cmd/server -> wires dependencies, parses config, starts HTTP server
internal/httpapi -> handlers, middleware, request/response types
internal/users -> user service and business rules
internal/store -> database implementation
Avoid extracting internal/common or internal/utils early. Those names
usually mean the package has no clear owner and will accumulate unrelated
helpers.
59.2 Introduce interfaces at the consumer side
Prefer this:
// internal/users/service.go
type Store interface {
User(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
type Service struct {
store Store
}
Not this:
// internal/postgres/store.go
type StoreInterface interface {
User(ctx context.Context, id string) (User, error)
Save(ctx context.Context, u User) error
}
The consumer knows the narrow behavior it needs. The implementation should not define a broad interface just because it exists.
59.3 Preserve API compatibility deliberately
For exported packages, changing any of these is a breaking change:
- removing an exported name;
- changing a function signature;
- changing interface methods;
- changing struct fields callers set directly;
- changing documented error behavior.
Safer migration pattern:
// Old API kept temporarily.
func New(addr string) *Client {
return NewWithOptions(Options{Addr: addr})
}
type Options struct {
Addr string
Timeout time.Duration
Logger *slog.Logger
}
func NewWithOptions(opts Options) *Client {
// new implementation
}
Deprecate, migrate call sites, then remove only at the next major version.
For public modules, that often means semantic import versioning (/v2).
59.4 Mechanical refactoring checklist
go test ./...
go test -race ./...
go vet ./...
staticcheck ./...
gofmt -w .
go mod tidy
Run these before and after large moves. If the change is truly mechanical, commit it separately from behavior changes; future reviewers and debuggers will thank you.