The most impactful refactoring in a Go CLI wasn't adding code — it was deleting pass-through layers, thin wrappers, and premature frameworks. Here's how to recognize abstractions that cost more than they save.
Over 60 refactorings on a security CLI, the highest-ROI changes were deletions. Deletions of abstractions that existed "just in case" and cost every reader cognitive overhead with zero runtime benefit.
Here are the abstractions we removed and why.
1. Pass-through packages
A package that exists only to forward calls to another package:
// internal/app/workflow/evaluate.go
package workflow
func Evaluate(input EvalInput) (Result, error) {
return eval.Evaluate(input) // just forwards
}
Every command imported workflow instead of eval directly. The package had no logic, no transformation, no error handling. It was a phantom layer — it appeared in import paths, confused grep results, and added one more package to understand.
The fix: Delete the package. Rewire all callers to import eval directly. One commit, zero behavior change.
The rule: If a package only forwards calls, it shouldn't exist.
2. Thin wrapper functions
func RenderJSON(w io.Writer, v any) error {
return jsonutil.WriteIndented(w, v)
}
func RenderText(w io.Writer, report Report) error {
return textReporter.Render(w, report)
}
These wrappers added names but no behavior. Every caller could call the underlying function directly. The wrappers existed because "we might add logging later" — but we never did.
The fix: Inline the call at every call site. Delete the wrapper.
The principle: Don't create a function to wrap a single function call. The call itself is already readable.
3. Anemic files
25 Go files with fewer than 20 lines of logic. Each contained a single type or a single function that belonged in its neighboring file.
types.go → 1 type, 0 methods
constants.go → 3 constants
helpers.go → 1 helper function
Every file is a navigation decision. 25 anemic files means 25 wrong guesses when searching for code. Merging types.go into policy.go means the type lives next to the logic that uses it.
The fix: Merge into the logical neighbor. 25 files became 0 additional files — the types moved into the files that used them.
The rule: Name files after the primary type they contain. Don't create types.go, utils.go, or helpers.go.
4. Premature generic frameworks
The most expensive deletion: a 500-line Pipeline[T] generic framework.
type Pipeline[T any] struct {
stages []Stage[T]
}
func (p *Pipeline[T]) Run(ctx context.Context, input T) (T, error) {
for _, stage := range p.stages {
var err error
input, err = stage.Execute(ctx, input)
if err != nil {
return input, err
}
}
return input, nil
}
This was used in only one place — the evaluation pipeline. The stages were three sequential function calls. The framework added generics, interfaces, registration, and error handling for a problem that looked like this:
controls, err := loadControls(ctx, dir)
if err != nil { return err }
result, err := evaluate(controls, snapshots)
if err != nil { return err }
return writeOutput(result)
Three lines of sequential Go. No generics. No interfaces. No framework.
The fix: Delete the pipeline package. Replace with three sequential calls.
The principle: Three lines of sequential Go beats a 500-line generic fluent API. Every time.
5. Backward-compatibility aliases
After renaming invariant to control across 60 files, we kept type aliases for safety:
// Deprecated: use ControlID.
type InvariantID = ControlID
The aliases created confusion about which name was canonical. Grep returned both. Autocompletion showed both. New code used both names randomly.
The fix: Delete all aliases in the same commit as the rename. No transition period. The codebase has no external consumers — there is nobody to break.
The rule: If you have no external consumers, backward compatibility is debt.
6. Dead methods
After the hexagonal migration, deadcode analysis found 207 unreachable functions:
$ deadcode -test ./...
internal/core/controldef.(*Operand).AsBool
internal/core/controldef.(*Operand).AsString
internal/core/controldef.(*Operand).AsNumber
internal/core/controldef.(*Operand).IsZero
internal/core/evaluation.(*EvalContext).GetLogger
...
Methods that were written speculatively, methods left behind after a refactoring, methods duplicated across packages during a migration. All dead. All deleted.
The fix: Run deadcode -test ./... after every structural change. Delete what it finds. No exceptions.
The cost model
Every abstraction has a cost:
| Abstraction | Cost per reader | Runtime benefit |
|---|---|---|
| Pass-through package | Import confusion, grep noise | Zero |
| Thin wrapper | Extra indirection to read | Zero |
| Anemic file | Navigation overhead | Zero |
| Unused generic framework | 500 lines to understand | Zero |
| Type alias | Namespace pollution | Zero |
| Dead method | "Is this used?" investigation | Zero |
If the runtime benefit column is zero, the abstraction is not zero-cost. It's negative-cost.
When to delete
After every structural refactoring, ask:
- Does this package forward calls without adding logic? Delete it.
- Does this function wrap a single function call? Inline it.
- Does this file contain fewer than 20 lines? Merge it.
- Does this framework serve one use case? Replace with sequential code.
- Does this type alias exist for transition safety? Delete it now.
- Does
deadcodefind anything? Delete it all.
The Real Zero Cost Abstraction
Go doesn't have zero-cost abstractions in the Rust sense. Every interface adds a vtable lookup. Every package adds compile time. Every file adds navigation cost. Every line adds reading time.
The only truly zero-cost abstraction in Go is the one you deleted.
Stave is an open-source intent verification engine for cloud infrastructure with 2,650+ controls, compound risk detection, and nine independent reasoning engines.
Top comments (0)