Tinker AI
Read reviews
intermediate 8 min read

Aider on a Go codebase: idioms, error handling, and the gotchas

Published 2026-03-18 by Owner

Aider works on Go codebases. The terminal-native, git-native workflow fits Go’s culture well. But there are a handful of Go-specific patterns where Aider’s defaults aren’t quite right, and the fixes are small if you know what to look for.

This is the configuration and prompt habits I’ve landed on after about four months of using Aider on a Go service codebase — about 25k lines, mostly HTTP handlers, business logic, and a few daemons.

The conventions file that earns its keep

In CONVENTIONS.md at repo root:

# Go conventions for this project

## Style

- Go 1.22+, modules-based, no GOPATH-era patterns
- gofmt is authoritative; never write code that wouldn't pass gofmt
- Imports grouped: stdlib, then external, then internal — separated by blank lines
- Struct fields use camelCase for unexported, PascalCase for exported
- File names use snake_case: user_handler.go, not userHandler.go

## Error handling

- Always check errors at every call site. Never `_ = something()` to discard.
- Wrap with context using `fmt.Errorf("failed to X: %w", err)` — preserve the chain
- Sentinel errors are exported as `ErrFooBar` and checked with `errors.Is`
- Custom error types implement `error` and are checked with `errors.As`
- Return errors as the LAST return value, always. Never `(error, T)`.

## Concurrency

- No naked goroutines without a way to know they finished
- Always pair `go` with WaitGroup, errgroup, or a context-cancellable channel
- Mutexes are unexported; the API protects them, not the caller
- Channels are typed strictly; no `interface{}` channels

## Patterns we use

- Functional options for configurable constructors: `New(opts ...Option)`
- Context as first parameter for any I/O or potentially-blocking function
- Interfaces defined at the consumer, not the implementer
- Tests in `_test.go` files alongside the code, table-driven where possible

## Patterns we don't use

- `panic` for control flow — only for genuinely unrecoverable state
- `init()` functions for anything beyond literal constant initialization
- Global mutable state — pass dependencies explicitly
- ORMs — use `database/sql` with `sqlc`-generated query helpers

About 50 lines, every line load-bearing. The “Patterns we don’t use” section is the most valuable for keeping Aider on script.

Where Aider needs explicit prompting on Go

Some Go-specific things Aider gets wrong without explicit guidance:

1. Error wrapping vs. plain fmt.Errorf

Without prompting, Aider sometimes writes:

return fmt.Errorf("could not process: %s", err.Error())

This loses the error chain — errors.Is and errors.As won’t work upstream. The right form:

return fmt.Errorf("could not process: %w", err)

The %w verb wraps. Aider knows about this, but defaults to %s more often than not unless told. The conventions file mentions %w; even so, I sometimes have to remind it explicitly in prompts.

2. Interface satisfaction at the call site, not the type

Go interfaces are satisfied implicitly. The idiom is to define the interface in the package that consumes it, not the package that implements it. Aider, trained on a lot of Java-influenced Go, tends to do the opposite — defining interfaces alongside the types that implement them.

When this matters, I add to the prompt: “Define the StorageReader interface in the handlers package where it’s consumed, not in the storage package.”

3. Context propagation

Aider sometimes accepts a context.Context parameter and then doesn’t use it — passing context.Background() to internal calls instead of forwarding the received context. This breaks cancellation propagation.

The fix in conventions: “Any function that accepts a context.Context must forward it to all I/O calls inside it. Never replace with context.Background() except at root entrypoints.”

4. Struct embedding vs. composition

Go’s struct embedding is powerful and easy to misuse. Aider sometimes embeds when explicit composition would be clearer, especially for “has-a” relationships.

The rule: “Use embedding only for is-a relationships where method promotion is desired. For has-a relationships, use named fields.”

5. Goroutine lifetime

Without explicit guidance, Aider will sometimes write go someFunc() with no way to know when it finishes. This is technically valid Go and almost always wrong in production code.

The conventions cover this. In prompts, I add a reminder when relevant: “Use errgroup for the parallel fetches; we need to wait for all and surface errors.”

Test patterns Aider handles well

Where Aider shines on Go:

Table-driven tests. This pattern is ubiquitous in Go and Aider produces clean table-driven tests on first try, almost every time.

func TestParseUser(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    User
        wantErr error
    }{
        {"valid", `{"id":1,"name":"Alice"}`, User{ID: 1, Name: "Alice"}, nil},
        {"invalid json", `{`, User{}, ErrInvalidJSON},
        {"empty", ``, User{}, ErrEmptyInput},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseUser(tt.input)
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("got err %v, want %v", err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

For new tests, “Add table-driven tests for ParseUser covering valid, invalid JSON, and empty input cases” produces almost exactly the above shape.

Subtest patterns with t.Run. Cleanly handled.

testing.TB for shared helpers. When I ask for a test helper that should work in both *testing.T and *testing.B, Aider knows to use testing.TB.

Cleanup with t.Cleanup. Modern test cleanup, used correctly when prompted.

Test patterns where Aider needs more guidance

testify vs. stdlib. Aider defaults to testify/assert when I prefer stdlib testing for the project. The convention “Use stdlib testing only, no testify” handles this.

Mocking. Go’s mocking story is fragmented. Aider sometimes generates gomock-style mocks, sometimes hand-rolled, sometimes testify/mock. Without convention guidance, the result is mixed. With “Use hand-rolled stubs that satisfy the interface, not gomock,” Aider produces consistent output.

Race detector tests. Aider doesn’t add -race-aware tests by default. For concurrent code, I add to the prompt: “Include a test that exercises the function from multiple goroutines and runs cleanly under -race.”

A worked example

A typical Aider session for adding a new HTTP handler:

> /add internal/handlers/users.go internal/storage/users.go internal/types/user.go

> Add a GET /users/{id} handler that fetches a user by ID and returns it as JSON.
> Use the existing UsersStorage interface (defined in handlers package, satisfied by 
> internal/storage). Include error handling: 404 for not found, 500 for unexpected 
> errors with proper logging.

Aider’s output (typical):

func (h *UsersHandler) GetByID(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(chi.URLParam(r, "id"))
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }
    
    user, err := h.storage.GetByID(r.Context(), id)
    if err != nil {
        if errors.Is(err, storage.ErrUserNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        h.logger.Error("get user failed", "id", id, "err", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    if err := json.NewEncoder(w).Encode(user); err != nil {
        h.logger.Error("encode response failed", "err", err)
    }
}

Decent first draft. Two things I’d adjust:

  1. Encoding error after writing headers — should be logged but the response is already partially written; the existing handling is OK in practice
  2. The chi.URLParam assumes go-chi router; if I’m on net/http, I’d want r.PathValue (Go 1.22+)

Both adjustments are quick. The output is much closer to final than what Cursor produces on the same task without project-specific rules.

What this gets you

Across a few months of mixed work — handler additions, refactors, test coverage, small features — Aider on Go feels like a solid productivity tool when the conventions are tight. The output isn’t perfect; the discipline of CONVENTIONS.md plus targeted prompt context plus careful review of every diff is real work.

The payoff is qualitative: Aider’s output looks like the rest of the codebase. New PRs from Aider sessions go through normal code review without the “this doesn’t match our style” comments that uncalibrated AI output produces.

For Go specifically, the language’s small, opinionated style space helps. There’s a “right” way to write most things in Go, and Aider knows the right way more often than not. The rules are about anchoring it on the right way consistently. Once anchored, it works well.