DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Go: The Complete Guide to AI-Assisted Go Development

Cursor Rules for Go: The Complete Guide to AI-Assisted Go Development

Go is famous for two things: simplicity and performance. The language deliberately rejects clever abstractions, generic-soup type hierarchies, and exception-based control flow. There's one obvious way to do most things, and the standard library shows you how.

Then you ask your AI assistant to write Go code.

What you get back is rarely idiomatic. Cursor and Claude — trained on a planet's worth of Java, Python, and TypeScript — reach for inheritance trees, swallow errors with _, panic on edge cases, define enormous interfaces, spawn goroutines without synchronization, and reach for interface{} like it's any in TypeScript. The code compiles. It even passes basic tests. But it's not Go.

The fix isn't endless prompting. It's .cursorrules — a single file that tells your AI what idiomatic Go actually looks like in your codebase.

This is the complete guide. Seven rules, each with the failure mode, the rule that prevents it, and a before/after example. A copy-paste .cursorrules template at the end. Ship it today.


How Cursor Rules Work for Go Projects

Cursor reads project rules from two locations:

  1. .cursorrules — a single file at the repo root (the classic format)
  2. .cursor/rules/*.mdc — modular rule files with frontmatter (recommended for monorepos)

For Go, I recommend modular rules so that web service rules don't interfere with CLI tool rules in the same workspace:

.cursor/
  rules/
    go-core.mdc          # error handling, naming, packages
    go-concurrency.mdc   # goroutines, channels, sync
    go-testing.mdc       # table tests, mocks, benchmarks
    go-tooling.mdc       # gofmt, govet, golangci-lint
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls when each rule activates:

---
description: Go concurrency patterns for HTTP services
globs: ["**/*.go"]
alwaysApply: false
---
Enter fullscreen mode Exit fullscreen mode

Now let's get to the rules.


Rule 1: Prefer Composition — But Know When Embedding Is Idiomatic

AI models trained on Java reach for inheritance. Since Go has no extends, they substitute struct embedding everywhere — even when plain field composition would be clearer. The result: behaviors leak across types and "is-a" relationships emerge where "has-a" was intended.

The Go idiom is the opposite: prefer named fields. Use embedding only when you genuinely want to forward an interface or method set.

The rule:

Prefer composition via named struct fields over embedding.
Use embedding ONLY when:
  - You want to satisfy an interface by delegating to an embedded type
  - You want to forward a complete method set (e.g., embedding sync.Mutex,
    io.Reader, or a base type whose methods are part of the new type's contract)
Never embed a struct just to "inherit" its fields.
Never embed multiple types whose method sets overlap.
Enter fullscreen mode Exit fullscreen mode

Bad — embedding used as inheritance:

type Animal struct {
    Name string
    Age  int
}

func (a *Animal) Describe() string {
    return fmt.Sprintf("%s (%d)", a.Name, a.Age)
}

type Dog struct {
    Animal // embedded to "inherit" Name, Age, Describe
    Breed  string
}

func main() {
    d := Dog{Animal: Animal{Name: "Rex", Age: 4}, Breed: "Husky"}
    fmt.Println(d.Describe()) // works, but the relationship is muddled
}
Enter fullscreen mode Exit fullscreen mode

Good — composition with a named field:

type Profile struct {
    Name string
    Age  int
}

type Dog struct {
    Profile Profile
    Breed   string
}

func (d Dog) Describe() string {
    return fmt.Sprintf("%s the %s (%d)", d.Profile.Name, d.Breed, d.Profile.Age)
}
Enter fullscreen mode Exit fullscreen mode

Good — embedding used idiomatically (interface forwarding):

// LoggingRepo wraps a UserRepo and logs every call. Embedding here is
// idiomatic: we explicitly want to forward every UserRepo method.
type LoggingRepo struct {
    UserRepo // embedded — methods are part of LoggingRepo's contract
    log *slog.Logger
}

func (r *LoggingRepo) GetUser(ctx context.Context, id int) (*User, error) {
    r.log.Info("GetUser", "id", id)
    return r.UserRepo.GetUser(ctx, id) // delegates explicitly
}
Enter fullscreen mode Exit fullscreen mode

The first example uses embedding because the AI didn't know what else to do. The second uses it deliberately for a decorator pattern. Same syntax, very different intent.


Rule 2: Never Ignore Errors — Handle Them Explicitly, Don't Panic

The single most common failure in AI-generated Go: _ = doThing(), swallowed errors, and panic(err) calls instead of returning. Every one of these is a production incident waiting to happen.

The rule:

Every error must be handled at the call site. The only acceptable patterns are:
  1. Return the error (wrapped with fmt.Errorf and %w)
  2. Log it AND continue (only in non-critical paths, with explanation)
  3. Assign to _ ONLY when the function signature requires it AND the
     error is documented as impossible (e.g., bytes.Buffer.Write)

Never use panic() in library or service code. Reserve panic for:
  - main() startup failures that prevent the program from functioning
  - Programmer-error invariants (sync.Mutex.Unlock when not locked)

Never use log.Fatal in library code. It calls os.Exit, skipping defers.
Enter fullscreen mode Exit fullscreen mode

Bad — three different sins in eight lines:

func ProcessOrders(orders []Order) []Receipt {
    var receipts []Receipt
    for _, o := range orders {
        r, err := chargeCard(o)
        if err != nil {
            panic(err) // crashes the whole service for one bad card
        }
        _ = saveReceipt(r) // swallowed silently
        receipts = append(receipts, r)
    }
    return receipts
}
Enter fullscreen mode Exit fullscreen mode

Good — explicit handling, errors propagate, partial success is observable:

func ProcessOrders(ctx context.Context, orders []Order, log *slog.Logger) ([]Receipt, error) {
    receipts := make([]Receipt, 0, len(orders))
    for _, o := range orders {
        r, err := chargeCard(ctx, o)
        if err != nil {
            return receipts, fmt.Errorf("ProcessOrders: charge order %s: %w", o.ID, err)
        }
        if err := saveReceipt(ctx, r); err != nil {
            // Not fatal — receipt can be reconstructed from charge log.
            log.Warn("saveReceipt failed", "order", o.ID, "err", err)
        }
        receipts = append(receipts, r)
    }
    return receipts, nil
}
Enter fullscreen mode Exit fullscreen mode

Now the caller decides what to do when a card fails. The receipt-save failure is visible in logs but doesn't tank the batch. No panics, no silent data loss.


Rule 3: Use Interfaces Wisely — Small, At Point of Use, Polymorphism Through Composition

In Java, you define interfaces upfront and implement them with implements. In Go, interfaces are satisfied implicitly. AI models miss this and define gigantic, premature interfaces next to the concrete types — exactly the opposite of the Go idiom.

The rule of thumb that works: define interfaces in the package that consumes them, with the smallest method set that package needs.

The rule:

Define interfaces in the package that USES them, not the package that
implements them. This is "consumer-defined interfaces."

Keep interfaces small — 1 to 3 methods is typical. Single-method interfaces
should use the -er suffix (Reader, Writer, Stringer, Notifier).

Achieve polymorphism via interface composition, not embedding concrete types
or building inheritance trees. If you need both Reader and Closer behavior,
use io.ReadCloser, not a base "ReadableClosable" struct.

Never define an interface with one implementation "just in case."
Add the interface when you have the second implementation OR when you
need to mock for testing — never before.
Enter fullscreen mode Exit fullscreen mode

Bad — fat interface in the implementation package, defined upfront:

// package userdb

type UserService interface {
    GetUser(id int) (*User, error)
    CreateUser(u User) (*User, error)
    UpdateUser(u User) (*User, error)
    DeleteUser(id int) error
    ListUsers(limit, offset int) ([]User, error)
    SearchUsers(query string) ([]User, error)
    CountUsers() (int, error)
    GetUserPreferences(id int) (*Preferences, error)
}

type postgresUserService struct{ db *sql.DB }
// ... implements all 8 methods, even though most consumers need only 1
Enter fullscreen mode Exit fullscreen mode

Good — small interfaces defined where they're needed:

// package billing

// CustomerLookup is the only capability billing needs from the user store.
type CustomerLookup interface {
    GetUser(ctx context.Context, id int) (*User, error)
}

type Service struct {
    customers CustomerLookup
}

func NewService(c CustomerLookup) *Service {
    return &Service{customers: c}
}
Enter fullscreen mode Exit fullscreen mode

Good — composition over inheritance for "do two things":

// Standard library shows the pattern. No "ReaderAndCloser" base type.
type ReadCloser interface {
    io.Reader
    io.Closer
}

// Function consumes the smallest interface it needs.
func drainAndClose(rc io.ReadCloser) error {
    if _, err := io.Copy(io.Discard, rc); err != nil {
        return fmt.Errorf("drain: %w", err)
    }
    return rc.Close()
}
Enter fullscreen mode Exit fullscreen mode

The postgresUserService from the bad example satisfies CustomerLookup automatically — no implements keyword, no coupling to billing. Mocks become trivial: implement one method.


Rule 4: Goroutine Safety — Always Use Channels or sync Primitives

Cursor will happily generate code that spawns five goroutines mutating a shared map and call it "concurrent." Run go test -race and watch the data race detector light up like a Christmas tree.

The rule: every shared piece of state needs a protection mechanism. Channels for ownership transfer, sync.Mutex for shared mutable state, sync.RWMutex for read-heavy workloads, atomic for single-word counters.

The rule:

Goroutine rules:
  - Never read or write shared mutable state without synchronization.
  - Use channels to PASS data between goroutines (transfer ownership).
  - Use sync.Mutex / sync.RWMutex to PROTECT shared state in place.
  - Use atomic.Int64 / atomic.Bool for simple counters and flags.
  - Every goroutine spawned must have a clear lifetime. Use sync.WaitGroup,
    errgroup.Group, or a context cancellation to wait for / stop them.
  - Never use a bare `go` statement in library code without a way for
    callers to know when it's done.
  - Always run tests with `go test -race ./...` in CI.
  - A channel send to nil blocks forever. Initialize with make() before sending.
Enter fullscreen mode Exit fullscreen mode

Bad — data race, goroutine leak, no error handling:

func FetchAll(urls []string) map[string]int {
    results := map[string]int{}
    for _, u := range urls {
        go func() {
            resp, _ := http.Get(u) // captures loop var (pre-Go 1.22)
            results[u] = resp.StatusCode // unsynchronized map write
            resp.Body.Close()
        }()
    }
    return results // returns before goroutines finish
}
Enter fullscreen mode Exit fullscreen mode

Good — errgroup for structured concurrency, mutex for the map:

func FetchAll(ctx context.Context, urls []string) (map[string]int, error) {
    var (
        mu      sync.Mutex
        results = make(map[string]int, len(urls))
    )

    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(8) // bound concurrency

    for _, u := range urls {
        u := u // shadow for safety on Go < 1.22
        g.Go(func() error {
            req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
            if err != nil {
                return fmt.Errorf("FetchAll: build req for %s: %w", u, err)
            }
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return fmt.Errorf("FetchAll: GET %s: %w", u, err)
            }
            defer resp.Body.Close()

            mu.Lock()
            results[u] = resp.StatusCode
            mu.Unlock()
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return results, err
    }
    return results, nil
}
Enter fullscreen mode Exit fullscreen mode

Concurrency bounded. Errors propagated. Map writes serialized. Context flows through. No goroutine outlives the function.


Rule 5: Keep Packages Focused — Single Responsibility, No God Packages

The utils package is the worst Go anti-pattern. So is the common package, the helpers package, and the internal/lib package. AI models invent these constantly because their training data is full of Java's org.acme.util.*.

In Go, a package's name is part of every external reference (time.Now, http.Get). If your package is called utils, every call site reads utils.Something — telling the reader nothing about what's actually happening.

The rule:

Package design:
  - One package = one responsibility. If you can't summarize the package
    in a single sentence, split it.
  - Package names are short, lowercase, single words: time, http, json, user.
  - NEVER name a package: utils, common, helpers, lib, base, core, misc, shared.
  - The package name should describe what it provides. user, billing, auth.
  - Don't create a package with one exported function. Inline it or move it.
  - Avoid circular imports by extracting shared types to a domain package.
  - Use internal/ for code that should not be importable outside the module.
  - File organization within a package: group by feature, not by type
    (no models.go / handlers.go / services.go split).
Enter fullscreen mode Exit fullscreen mode

Bad — utils package becomes a junk drawer:

// package utils
package utils

func StringToInt(s string) (int, error) { /* ... */ }
func FormatTime(t time.Time) string     { /* ... */ }
func ParseEmail(s string) (Email, error){ /* ... */ }
func RetryWithBackoff(fn func() error)  { /* ... */ }
func HashPassword(p string) string      { /* ... */ }
func GenerateUUID() string              { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

Call sites: utils.HashPassword(...), utils.RetryWithBackoff(...). The reader has no idea what's going on without opening the package.

Good — focused packages, descriptive call sites:

// package crypto
func HashPassword(plaintext string) (string, error)

// package retry
func WithBackoff(ctx context.Context, fn func() error) error

// package email
func Parse(s string) (Address, error)

// package uuid
func New() string
Enter fullscreen mode Exit fullscreen mode

Now crypto.HashPassword, retry.WithBackoff, email.Parse — every call site reads like a sentence.


Rule 6: Use defer for Cleanup — But Mind the Scope

defer is one of Go's best features. AI assistants either forget it entirely (leaking file descriptors) or use it inside loops (delaying every cleanup until the function returns, sometimes catastrophically).

The rule:

defer rules:
  - Always defer Close() / Unlock() / Done() immediately after acquiring a resource.
  - defer runs at FUNCTION return, not at block exit. Never put defer
    inside a for loop unless you intend the cleanup to be deferred until
    the function returns.
  - For per-iteration cleanup inside a loop, extract a helper function
    (so defer fires per iteration) OR call cleanup explicitly.
  - The deferred call's ARGUMENTS are evaluated at defer time; the function
    runs at return time. Be intentional about which behavior you want.
  - defer adds a small overhead (~50ns). Don't use it in tight inner loops
    where it shows up in benchmarks.
  - Check the error return of deferred Close() on writers — a flush failure
    on Close() can lose data.
Enter fullscreen mode Exit fullscreen mode

Bad — defer inside a loop holds every file open until the function returns:

func ProcessFiles(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return fmt.Errorf("open %s: %w", p, err)
        }
        defer f.Close() // BUG: all files stay open until ProcessFiles returns
        if err := process(f); err != nil {
            return err
        }
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

With 10,000 paths, this exhausts the file descriptor limit and crashes.

Good — extract per-iteration work into a helper so defer fires correctly:

func ProcessFiles(paths []string) error {
    for _, p := range paths {
        if err := processFile(p); err != nil {
            return fmt.Errorf("ProcessFiles: %s: %w", p, err)
        }
    }
    return nil
}

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // fires at end of processFile, per iteration

    return process(f)
}
Enter fullscreen mode Exit fullscreen mode

Good — deferred close on a writer reports flush errors:

func WriteReport(path string, data Report) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("WriteReport: create %s: %w", path, err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("WriteReport: close %s: %w", path, cerr)
        }
    }()

    return json.NewEncoder(f).Encode(data)
}
Enter fullscreen mode Exit fullscreen mode

The named return err lets the deferred close report a flush failure without clobbering an existing error.


Rule 7: Leverage interface{} Sparingly — Prefer Generics (Go 1.18+)

Before Go 1.18, you needed interface{} for "any type." That escape hatch is now mostly obsolete. Generics let you write type-safe functions that work across types — no runtime type assertions, no boxing, no panic("unexpected type").

AI models trained on pre-2022 Go code default to interface{} (or any, which is just an alias). They sprinkle .(string) and .(int) type assertions, then panic when the assertion fails.

The rule:

Generics vs interface{} (any):

Use generics [T any] when:
  - The function works on multiple types but the operation is the same
    (e.g., Map, Filter, Min, Max, slice utilities)
  - You need compile-time type safety
  - You want to avoid runtime type assertions

Use interface{} / any only when:
  - You're storing values of truly unknown types (decoded JSON, reflection)
  - You're implementing a generic data structure pre-1.18 (don't, use generics)
  - You're building printf-style variadic logging

Never use interface{} as a parameter type when the call sites pass
known concrete types — make the function generic instead.

Use type constraints (~int, comparable, constraints.Ordered) instead
of `any` when you need operators or comparisons.
Enter fullscreen mode Exit fullscreen mode

Bad — interface{} with runtime type assertions, no compile-time safety:

func Max(a, b interface{}) interface{} {
    switch av := a.(type) {
    case int:
        bv := b.(int) // panics if b isn't int
        if av > bv {
            return av
        }
        return bv
    case float64:
        bv := b.(float64)
        if av > bv {
            return av
        }
        return bv
    }
    panic(fmt.Sprintf("unsupported type %T", a))
}

// Caller has to type-assert the result. Yuck.
biggest := Max(10, 20).(int)
Enter fullscreen mode Exit fullscreen mode

Good — generic, type-safe, no runtime panics:

import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

biggest := Max(10, 20)             // int, inferred
biggestF := Max(1.5, 2.7)          // float64, inferred
biggestS := Max("apple", "banana") // string, inferred
// Max(10, "twenty") — won't compile. The compiler catches the bug at build time.
Enter fullscreen mode Exit fullscreen mode

Good — slice utilities with generics:

func Filter[T any](s []T, keep func(T) bool) []T {
    out := make([]T, 0, len(s))
    for _, v := range s {
        if keep(v) {
            out = append(out, v)
        }
    }
    return out
}

evens := Filter([]int{1, 2, 3, 4, 5}, func(n int) bool { return n%2 == 0 })
adults := Filter(users, func(u User) bool { return u.Age >= 18 })
Enter fullscreen mode Exit fullscreen mode

One implementation, every type, fully type-checked. No reflection, no boxing, no runtime cost.


The Complete .cursorrules File for Go

Drop this into your project root as .cursorrules, or split into .cursor/rules/*.mdc files. It's the consolidated version of every rule above plus a few tooling defaults.

# Go Cursor Rules

## Composition
- Prefer named struct fields (composition) over struct embedding.
- Use embedding ONLY for interface forwarding (decorator pattern) or to
  inherit a complete method set that's part of the new type's contract.
- Never embed a struct just to reuse its fields.

## Error Handling
- Every error must be handled at the call site.
- Wrap errors with fmt.Errorf and %w to preserve the chain.
- Never use panic() in library or service code. Reserve panic for
  startup failures and programmer-error invariants only.
- Never use log.Fatal outside main() — it skips defers.
- Never write `_ = someCall()` unless the error is documented as impossible.

## Interfaces
- Define interfaces in the package that USES them (consumer-defined).
- Keep interfaces small — 1 to 3 methods, typically.
- Single-method interfaces use the -er suffix (Reader, Notifier).
- Polymorphism via interface composition (io.ReadCloser), not concrete embedding.
- Don't define an interface for a single implementation "just in case."

## Concurrency
- Every shared mutable variable needs synchronization (channel, mutex, atomic).
- Channels TRANSFER ownership; mutexes PROTECT in-place state.
- Every spawned goroutine must have a clear stop signal (context, WaitGroup, errgroup).
- Bound concurrency with errgroup.SetLimit or a worker pool — never unbounded `go`.
- Run `go test -race ./...` in CI. No exceptions.
- Never use a bare `go` statement in library code.

## Packages
- Package names: short, lowercase, single word, descriptive of contents.
- Banned package names: utils, common, helpers, lib, base, core, misc, shared.
- One package = one responsibility, expressible in one sentence.
- Use internal/ for code not meant for external import.
- Group files by feature, not by type (no models.go / handlers.go split).

## Defer and Resources
- Defer Close()/Unlock()/Done() immediately after acquiring the resource.
- Defer fires at FUNCTION return — extract helpers when you need per-iteration cleanup.
- Defer arguments evaluate at defer time; the call itself runs at return time.
- Check the error from deferred Close() on writers (flush failures lose data).

## Generics vs interface{}
- Prefer generics [T any] over interface{} for type-parameterized functions.
- Use type constraints (constraints.Ordered, comparable) instead of `any`
  when you need operators.
- Reserve interface{} / any for truly unknown types (decoded JSON, reflection).
- Never sprinkle .(int) / .(string) type assertions when generics would work.

## Context
- Functions performing I/O take ctx context.Context as the first parameter.
- Pass ctx through the entire call chain — never call context.Background()
  inside a function that already has a context.
- Always set timeouts on external calls (context.WithTimeout).

## Tooling
- All code must pass `gofmt -s` and `go vet ./...`.
- Run `golangci-lint run` before declaring code complete.
- Add `go.work` if working across multiple modules.
- Use `go mod tidy` after dependency changes.
- Use `errcheck` to catch unhandled errors that the compiler misses.

## Testing
- Use table-driven tests with t.Run subtests for multi-case scenarios.
- Test error paths, not just happy paths.
- Use `go test -race -cover ./...` in CI.
- Mock at the interface boundary defined in the consumer package.
- Use httptest.Server for HTTP client tests, not real network calls.
Enter fullscreen mode Exit fullscreen mode

Real Examples: AI-Generated Code Before and After Rules

Here's what changes in practice. These are real prompts I've sent to Cursor and the diff between "no rules" and "rules in place."

Example 1: "Build me an HTTP handler that creates a user."

Without rules — typical AI output:

package main

var db *sql.DB

func init() {
    var err error
    db, err = sql.Open("postgres", os.Getenv("DB_URL"))
    if err != nil {
        panic(err)
    }
}

type UserHandler struct{}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    var u map[string]interface{}
    json.Unmarshal(body, &u)
    name := u["name"].(string)
    email := u["email"].(string)
    _, err := db.Exec("INSERT INTO users (name, email) VALUES ($1, $2)", name, email)
    if err != nil {
        panic(err)
    }
    w.Write([]byte("ok"))
}
Enter fullscreen mode Exit fullscreen mode

Sin count: global db, init() for setup, panic() on startup failure, swallowed io.ReadAll error, swallowed Unmarshal error, panicking type assertions, no context propagation, panic on DB failure, ignored w.Write error.

With rules in .cursorrules — same prompt, idiomatic output:

package userhttp

type Creator interface {
    Create(ctx context.Context, name, email string) (*User, error)
}

type Handler struct {
    users Creator
    log   *slog.Logger
}

func NewHandler(users Creator, log *slog.Logger) *Handler {
    return &Handler{users: users, log: log}
}

type createRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    var req createRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    if req.Name == "" || req.Email == "" {
        http.Error(w, "name and email required", http.StatusBadRequest)
        return
    }
    user, err := h.users.Create(r.Context(), req.Name, req.Email)
    if err != nil {
        h.log.Error("create user", "err", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    if err := json.NewEncoder(w).Encode(user); err != nil {
        h.log.Error("encode response", "err", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Dependency injection. Consumer-defined interface. Context propagated. No panics. Errors logged and translated to HTTP status codes. JSON decoded into a typed struct, no interface{} assertions.

Example 2: "Write a worker that processes jobs concurrently."

Without rules:

func ProcessJobs(jobs []Job) {
    for _, j := range jobs {
        go func() {
            result := process(j)        // captures j, classic closure bug
            results = append(results, result) // unsynchronized
        }()
    }
}
Enter fullscreen mode Exit fullscreen mode

Three bugs in five lines: closure captures the loop variable (pre-1.22), unbounded goroutines, data race on results. No way to know when the work is done.

With rules:

func ProcessJobs(ctx context.Context, jobs []Job, workers int) ([]Result, error) {
    results := make([]Result, len(jobs))

    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(workers)

    for i, j := range jobs {
        i, j := i, j // shadow for Go < 1.22
        g.Go(func() error {
            r, err := process(ctx, j)
            if err != nil {
                return fmt.Errorf("ProcessJobs: job %s: %w", j.ID, err)
            }
            results[i] = r // safe — each goroutine writes a distinct index
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}
Enter fullscreen mode Exit fullscreen mode

Bounded concurrency. Context propagated. Each goroutine writes to its own slot — no mutex needed. Errors propagate cleanly.


Get the Full Pack

These seven rules cover the highest-leverage Go patterns where AI assistants consistently fail. Drop them into .cursorrules and you'll see the difference on the very next prompt.

If you want the same depth for Java, Rust, TypeScript, Python, React, Next.js, and more — all the rules I've packaged from a year of refining Cursor configs across production codebases — they're all at:

oliviacraft.lat

One pack. Fifteen languages and frameworks. Battle-tested rules with before/after examples for each. Stop fighting your AI assistant and start shipping idiomatic code on the first try.

Top comments (0)