DEV Community

Bala Paranj
Bala Paranj

Posted on

Zero-cost abstractions in Go: deleting your way to better code

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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:

  1. Does this package forward calls without adding logic? Delete it.
  2. Does this function wrap a single function call? Inline it.
  3. Does this file contain fewer than 20 lines? Merge it.
  4. Does this framework serve one use case? Replace with sequential code.
  5. Does this type alias exist for transition safety? Delete it now.
  6. Does deadcode find 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)