Fundamentals 27 min read

Stop Adding Java Patterns to Go: Unpacking spf13’s Idiomatic Go Principles

The article dissects spf13’s go‑skills repository, exposing how Java‑style project layouts, layered packages, heavy frameworks and misuse of generics clash with Go’s philosophy, and presents concrete, Go‑centric guidelines for package organization, interfaces, error handling, concurrency, testing, standard‑library usage, CLI design, and a pre‑code review checklist.

TonyBai
TonyBai
TonyBai
Stop Adding Java Patterns to Go: Unpacking spf13’s Idiomatic Go Principles

Author Tony Bai introduces Steve Francia (spf13), the creator of Cobra, Viper, Hugo and other core Go projects, and his new go-skills repository aimed at AI agents. The README opens with a blunt statement that developers are importing "structural baggage" from Java and Spring Boot into Go and calling it best practice.

"As Go has grown in popularity, developers are importing structural baggage from other languages—specifically Java and Spring Boot—and rebranding them as best practices."

spf13 targets popular Go project‑layout guides (e.g., golang-standards/project-layout) that promote deep directory nesting, service/, repository/, pkg/, and heavyweight worker pools, calling them Java‑style patterns that contradict Go’s design philosophy.

Golden Rule: Clear is better than clever

"Clear is better than clever. Go code should be boring in the best possible way—predictable, consistent, and immediately understandable to a new developer opening the file for the first time. When in doubt, delete the abstraction."

This maxim becomes the yardstick for every recommendation that follows.

1. Package organization: flatten, domain‑first

spf13 defines a three‑step judgment:

Step 1 – Single package. For a microservice or simple tool, keep everything in the root (or alongside main.go) until a distinct domain warrants a new package.

Step 2 – Use internal/ sparingly. It blocks other modules from importing the code, but for most applications it merely adds path depth; for libraries it should be reserved for truly shared internal types.

Step 3 – Service‑type apps use domain packages. Keep the depth to one level; avoid layered packages like service/. A domain gets its own package only if it can be described in a single sentence and knows nothing about other packages.

Example layout:

myservice/
├── main.go            # only assembly, no business logic
├── config/            # configuration structs, env loading
├── auth/              # authentication & session middleware
├── db/                # data‑store client + all queries
├── storage/           # object storage (S3, R2, GCS)
├── billing/           # payment provider & credit ledger
├── jobs/              # task lifecycle, queue, worker (single domain)
├── web/               # HTTP handlers, templates, static assets
├── transcribe/        # pure domain‑specific functions

Key disciplines extracted:

Each package has a single clear responsibility and is named for what it does (e.g., jobs/), not for its layer (avoid service/).

No horizontal imports; main is the sole assembly point.

Related concerns stay together (e.g., task creation and worker belong in jobs/).

Avoid generic utility packages like utils/, helpers/, common/ —they signal unclear responsibilities.

2. Interface design: write concrete types first, discover interfaces

The classic Go mantra "accept interfaces, return structs" is refined into three rules:

Interfaces are discovered, not pre‑designed. Write concrete types first; only define an interface when multiple implementations need to be interchangeable.

Define the interface where it is used. This determines the direction of package coupling.

Parameters use small interfaces; results are concrete. Prefer io.Reader over *os.File for inputs, but return concrete structs so callers need no type assertions.

Example:

// processor/processor.go
// Consumer defines exactly what it needs.
type UserFetcher interface { FetchUser(id string) (*User, error) }

type Processor struct { fetcher UserFetcher }

The go-spec-reviewer checklist enforces these points: interface defined by the consumer, small (1‑3 methods), and only introduced when a genuine polymorphic need exists.

3. Error handling: errors are values, not exceptions

"Errors aren't exceptions to be caught; they are values to be handled. Check them explicitly."

Handle errors immediately, wrap with context, and keep the happy path unindented:

data, err := os.ReadFile(path)
if err != nil {
    return fmt.Errorf("loading config file %s: %w", path, err)
}

Never hide errors behind else blocks; return early.

4. Concurrency: use channels, not mutexes

Static worker pools are anti‑patterns; the Go scheduler is sufficient. Prefer channel orchestration and errgroup for limiting concurrency:

func FetchAll(ctx context.Context, urls []string, maxConcurrent int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(maxConcurrent)
    for _, url := range urls {
        g.Go(func() error { return fetch(ctx, url) })
    }
    return g.Wait()
}

Go 1.22 fixes loop‑variable capture (no need for url := url), and Go 1.25’s sync.WaitGroup.Go removes the need for manual Add/Done boilerplate.

5. Testing: Go tests should be Go code

Heavy BDD frameworks (e.g., Ginkgo) and complex mock generators are discouraged. Preferred practices:

Table‑driven tests with t.Run.

func TestParseConfig(t *testing.T) {
    tests := []struct{ name, input string; wantErr bool }{ ... }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := ParseConfig(tt.input)
            if (err != nil) != tt.wantErr { t.Fatalf(... ) }
        })
    }
}

Use lightweight fakes (e.g., afero.NewMemMapFs()) instead of heavyweight mocks.

Prefer github.com/google/go-cmp/cmp over reflect.DeepEqual for readable diff output.

Leverage new testing features: t.Context(), b.Loop(), and testing/synctest for deterministic time‑based tests.

Avoid time.Sleep in tests; use channels, context cancellation, or the fake clock provided by synctest.

6. Generics: eliminate duplicated algorithms, not create type hierarchies

"Generics exist to eliminate duplicated algorithms, not to create type hierarchies. If you are thinking about generics in terms of inheritance or polymorphism, stop—you are writing Java."

Use generics when the same algorithm applies to multiple concrete types:

func Map[S any, T any](slice []S, f func(S) T) []T {
    result := make([]T, len(slice))
    for i, v := range slice { result[i] = f(v) }
    return result
}

Do **not** define generic interfaces for polymorphism; instead, write specific interfaces like UserStore.

// Bad – generic repository interface (Java‑like)
type Repository[T any] interface { Find(id string) (T, error); Save(entity T) error }

// Good – concrete interface
type UserStore interface { FindUser(id string) (*User, error); SaveUser(u *User) error }

Additional rules: avoid generic base classes, generic services, or using any as a placeholder constraint; use comparable for equality, cmp.Ordered for ordering, and only generic‑ify after the algorithm appears in three or more types.

7. Prefer the standard library

LLMs often suggest third‑party helpers that are now in the std lib (Go 1.21‑1.25). Notable additions: slices (1.21) – Contains, Sort, SortFunc, Reverse, Compact, Clone, Concat (1.22). maps (1.21, iterator 1.23) – Keys, Values, Clone. cmp (1.21) – Compare, Or, plus built‑in min / max. errors.Join (1.20) – replaces multierr. iter.Seq[T] (1.23) – iterator without allocating slices. math/rand/v2 (1.22) – newer API with automatic seeding. encoding/json (1.24) – omitempty now respects zero‑value structs via omitzero.

HTTP routing: since Go 1.22, net/http.ServeMux natively supports method and path‑parameter routing, eliminating the need for external routers unless named routes or regex constraints are required.

mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    // ...
})

Always set server timeouts to avoid slow‑loris attacks:

srv := &http.Server{
    Addr: ":8080",
    Handler: mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout: 10 * time.Second,
    WriteTimeout: 30 * time.Second,
    IdleTimeout: 120 * time.Second,
}

8. CLI architecture: Command‑First

spf13 advocates treating the binary as a command router; Cobra/Viper handle flags and configuration, while business logic stays oblivious to the CLI layer.

Build commands via factory functions (e.g., NewRootCmd()) instead of package‑level var declarations.

Each test creates a fresh command tree and Viper instance to avoid state leakage.

Use RunE (not Run) so errors propagate correctly.

Set SilenceUsage: true and SilenceErrors: true on the root command; output goes through cmd.OutOrStdout() for testability.

Validate arguments with Cobra’s built‑in validators (e.g., cobra.ExactArgs(1)).

func Execute() error { return NewRootCmd().Execute() }

func NewRootCmd() *cobra.Command {
    v := viper.New()
    rootCmd := &cobra.Command{Use: "mycli", SilenceUsage: true, SilenceErrors: true,
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return initConfig(v, cmd) },
    }
    rootCmd.AddCommand(NewServeCmd(v))
    rootCmd.AddCommand(NewBuildCmd(v))
    return rootCmd
}

Core packages must not import github.com/spf13/cobra or github.com/spf13/viper ; they remain pure Go libraries.

9. Review checklist: go‑spec‑reviewer

The go-spec-reviewer tool enforces the principles before any code is written, asking questions like:

Is the design simple and does it do one thing well?

Are interfaces small and defined at the point of use?

Does a CLI follow Cobra/Viper conventions?

Are dependencies justified against standard‑library alternatives?

Is there YAGNI compliance—no premature abstractions?

It turns spf13’s nine‑point guideline into an executable checklist, ensuring that “idiomatic Go” is validated during design rather than only during code review.

Conclusion

spf13’s ordering of values—clarity over cleverness, concreteness over abstraction, standard library over frameworks, composition over inheritance, channels over locks, and values over exceptions—mirrors the long‑standing Go wisdom found in Effective Go, the std lib source, and GopherCon talks. The go‑skills repository codifies this wisdom for both humans and AI agents, making the “not‑Go” patterns explicit and providing a concrete path to truly idiomatic Go code.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CLItestingconcurrencyGoerror handlinginterfacesidiomaticpackage design
TonyBai
Written by

TonyBai

Tony Bai's tech world (tonybai.com). Not satisfied with just "knowing how", we strive for mastery. Focused on Go language internals, high-quality engineering practices, and cloud‑native architecture, exploring cutting‑edge intersections of Go and AI. Gophers who pursue technology are welcome—follow me and evolve with Go.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.