Skip to content
Back to skills

Write Go

ASecurity

Apply base Go conventions in either organization: naming, errors, context, time, secrets, dependencies, formatting, and lint. Add write-go-service or write-go-kit where applicable.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
developmentrustgobashsqlnodetestingapibackendsecurityperformance

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned November 7, 2026

npx -y skills add a-novel-kit/stack --skill write-go --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Write Go?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Write Go
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/a-novel-kit-write-go/badge)](https://www.skillsdirectory.com/skills/a-novel-kit-write-go)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: write-go
description: >
  Apply base Go conventions in either organization: naming, errors, context, time, secrets,
  dependencies, formatting, and lint. Add write-go-service or write-go-kit where applicable.
---

# Go Conventions (common)

Load [develop-feature](../develop-feature/SKILL.md) for drafting, agreement, test timing, and final
cleanup. Use focused checks while the solution is being discussed; complete the relevant regression
suite after issue scope approval. The code-quality rules below apply throughout.

This is the base layer for Go in every a-novel / a-novel-kit repository. The rules hold
**whatever the repo kind** — a backend service, a shared library, or a one-off tool. Repo-kind
rules live in two companion skills; load the one matching where you work **in addition to** this
one:

- **`write-go-service`** — clean-architecture services under `a-novel` (`app/service-*`,
  `app/platform-*`): the `cmd`/`internal/{config,lib,dao,core,handlers,models}`/`pkg` layout,
  the interface+implementation pattern, transactions, OpenTelemetry instrumentation, REST/gRPC
  handlers, and the layer-specific test patterns.
- **`write-go-kit`** — shared libraries under `a-novel-kit` (`kit/golib`, `kit/jwt`, …): the
  golib-stays-minimal philosophy, when something earns its own package, and the public/community
  API obligations that come with it.

When work spans repos and needs versions kept in sync to merge cleanly, also load
**`manage-versions`**.

**Before touching any file, read it.** Before touching a package, read its siblings and the
interfaces it depends on and exposes. These codebases are deliberately consistent — coherence with
the surrounding code outranks personal preference. When two files disagree, the newer one and the
one closest to your change usually win; when it is genuinely unclear, ask.

**Look up the API before you use it.** Before writing code that touches an external package or a
non-trivial stdlib API, check the official `pkg.go.dev` docs and the package's README / release
notes. APIs evolve, and the first approach that comes to mind is often not the idiomatic one.
Seconds of reading prevent subtle misuse.

---

## After every edit

Run, in this order, after any change to Go files:

```
pnpm generate:go   # ONLY when you changed an interface or a .proto file — regenerates mocks / proto stubs
pnpm format:go     # always
pnpm lint:go       # always
```

`pnpm format:go` / `pnpm lint:go` exist in every Go repo (they wrap `go mod tidy` and a pinned
`golangci-lint` invoked through `golangci-lint.mod`); never skip them. If `lint` flags something
you did not introduce, fix it anyway while you are in the file.

**When local lint fails and CI is green on the same commit, clean the cache before you believe it.**
`golangci-lint.mod` pins the version, so local and CI run the identical linter and a disagreement is
environmental — chasing version drift is wasted effort. A stale analyzer cache is the usual cause:
facts about the standard library go stale across toolchain bumps, and once `staticcheck` can no
longer prove `(*testing.T).Fatalf` terminates, every `if x == nil { t.Fatalf(…) }` followed by a
deref of `x` reads as a nil dereference. CI never sees it because its runners start cold.

```bash
go tool -modfile=golangci-lint.mod golangci-lint cache clean
```

Re-run after cleaning. Findings that survive a cold run are real; editing code to appease the rest
is churn against a false positive.

Then, before the change is done:

1. Invoke **`write-go-tests`** — after scope approval, complete tests for meaningful changed
   behavior and regression risks, not a quota per file. Run the affected suites
   (`a-novel test --type=go -y`; raw `go test ./<pkg>/...` for focused iteration). Earlier tests
   should be limited to those needed to validate the draft. Coverage is complete before readiness.
2. Invoke **`document-code`** — doc comments for every symbol you added or changed. Also part of
   the change.

(Repo-kind skills add steps: `write-go-service` adds the `write-openapi` step when a REST
contract changes; `write-go-kit` adds the public-API and docs-site obligations for a graduated
package.)

---

## Dependencies

The standing goal across both orgs: **the fewest libraries doing the most.** Every dependency is
a maintenance liability, a supply-chain surface, and a constraint on future choices.

- **Stdlib first.** If `net/http`, `encoding/json`, `context`, `errors`, `fmt`, `time`,
  `crypto/*`, `slices`, `maps`, `cmp`, etc. cover the need, use them. Never pull a package for
  what the standard library already does.
- **Reuse before adding.** Prefer a package already in `go.mod`. A second library that overlaps
  one already present is almost never worth it.
- **Mine what's already imported.** When implementing, don't re-research online practices — the
  planning phase (`plan-feature`) already did that. Do read the **documentation of the libraries
  already in `go.mod`** for the task at hand: a helper, option, or whole subsystem an imported
  dependency already provides is code you neither write nor maintain.
- **Vet candidates.** A new dependency must be: well-maintained (recent commits, responsive
  issue tracker, real test coverage); **owned by an organization, not a single personal
  account** — org ownership survives a maintainer losing interest; and **broad** — pick the
  library covering the most of the surrounding problem space, so one dependency replaces three.
  A narrow utility from an individual's account is the worst combination.
- **Ask first — always.** Introducing a package not already in `go.mod` requires explicit
  developer approval. No exceptions, not even a small utility. (`a-novel-kit` repos hold this bar
  even higher — see `write-go-kit`; `a-novel` services keep their `internal/lib/` as close to
  empty as possible.)
- **Remove, don't accumulate.** During maintenance, actively look for dependencies — and
  hand-rolled helpers — that a newer upstream now subsumes, and delete the duplicate.
- **Fix what govulncheck reports.** A module that pins `govulncheck` in `govulncheck.mod` gets a
  `lint-go` warning for each vulnerability its code reaches, and the daily CI digest collects those
  from the default branch. The report never fails CI. Move the module to its fixed version, or the
  `go` directive for a standard-library finding, and confirm with
  `go tool -modfile=govulncheck.mod govulncheck ./...`.

---

## Package naming

One directory = one package. Package names are **short, lowercase, single words** — no
underscores, no camelCase: `package dao`, `package jwk`, `package httpf`. The name is what every
caller types in imports; keep it unambiguous, collision-free, and non-stuttering with the types
inside it (`jwk.Key`, not `jwk.JwkKey`).

---

## File naming

Multi-word file names are **camelCase** — never snake_case (`master_key.go` ✗), never
run-together when the name is two words (`masterkey.go` ✗): `masterKeyContext.go`,
`userSearch.go`. A test file mirrors the production file it covers with a `_test.go` suffix
(`userSearch.go` → `userSearch_test.go`) — see `write-go-tests` for why the underscore (not a
dot) matters and how to share fixtures across packages. The repo-kind skills define the
layer/role prefixes (`pg.*`, `rest.*`, `grpc.*`, `*.config.go`, `common.go`, …).

---

## Naming conventions

### Variables and fields

- **Be explicit.** `userDao`, not `repo`. `orderCreateService`, not `svc`. Length is not
  the cost; ambiguity is.
- **Short names are fine for conventional roles only**: `w` / `r` for HTTP handler params, `ctx`
  for `context.Context`, `err` for errors, `i` / `k` / `v` in range loops, `t` for `*testing.T`.
- **Initialisms keep Go's casing**: `ID`, `URL`, `JSON`, `JWT`, `HTTP`, `SQL`, `TTL`, `DSN`, `KID`,
  as in `GcloudProjectID`, `GrpcURL` and `PostgresDSN`. An initialism that _starts_ an unexported
  identifier goes all-lowercase: `id`, `url`.
- **Layer and protocol prefixes are words**: `Rest`, `Grpc`, `Pg`, `Jwk`, `Dao`, as in
  `RestJwkGet`, `GrpcClaimsSign`, `PgJwkSearch` and `JwkSearchDao`. Generated code keeps the casing
  its generator chose, such as protobuf's `GetId`.
- **Don't shadow imported package names** (`json`, `http`, `context`, `errors`, `time`, …).
  Rename the variable.
- **A zero value is a `var` declaration**: `var position int`, not `position := 0`. `:=` introduces
  a value computed on the spot.
- **A default takes the name of the field it fills**, then `Default`: `LayerListLimitDefault` fills
  `LayerListRequest.Limit`. A synonym, as in `LayerListDefaultSize`, gives one value two terms.

### Constructors

Every type with a constructor uses `New<TypeName>(deps...) *<TypeName>`. Return the concrete
pointer, not an interface — unless the concrete type is genuinely an implementation detail hidden
by design (e.g. a `pkg/go` client behind an interface).

**Always the composite-literal form**, never `new(T)`:

```go
// WRONG — `new(T)` is a second style for the same thing; mixing the two is noise.
func NewPgUserSelect() *PgUserSelect { return new(PgUserSelect) }

// CORRECT — reads identically whether the struct is empty or has fields.
func NewPgUserSelect() *PgUserSelect              { return &PgUserSelect{} }
func NewUserSearch(r UserSearchDao) *UserSearch { return &UserSearch{dao: r} }
```

Treat any `new(T)` you find as cleanup-on-sight when the file is already in scope.

---

## Declarations outside control flow

Keep a declaration out of the statement it feeds, so the statement reads as control flow and a name
says what the data is:

- A long literal, such as a table, a slice of structs or a map, written inside a `range`, `if`,
  `switch` or call argument becomes a named variable declared just before the statement.
- A closure longer than three lines becomes a named function or method, unless a closure is the
  idiomatic form there, as for a `t.Run` body, an `errgroup.Go` task or an HTTP handler. In a
  service's dao, core and handlers layers, a closure that takes the context, such as a `WithinTx` body or a
  retried write, stays a closure: the `a-novel-must-open-span` semgrep rule requires a span in every
  named function there, and a wrapper's span only repeats the one below it.

---

## Parameters

A function takes only the parameters it uses. Where a callback signature asks for more, such as
`lo.Map`'s index, wrap the call in a closure instead of adding the parameter to the function:

```go
// WRONG — the index exists only to match lo.Map.
func newCell(entity *dao.Cell, _ int) *Cell { … }

// CORRECT
cells := lo.Map(rows, func(row *dao.CellListRow, _ int) *Cell { return newCell(&row.Cell) })
```

---

## Error handling

- **Lowercase, no trailing punctuation.** Errors get wrapped and read mid-sentence:
  `errors.New("user not found")`, not `"User not found."`.
- **Sentinels for expected outcomes**: `var ErrUserNotFound = errors.New("user not found")`,
  defined in the same file as the type that produces it (or a shared file if reused across the
  package). Map an upstream/library error onto your own sentinel by _joining_ it — `err =
errors.Join(err, ErrUserNotFound)` — so callers keep both identities.
- **One vocabulary per concept.** If the type is `Jwk`, every error message that refers to it
  says `"jwk …"` — never `"key not found"` in one file and `"jwk not found"` in another. Wording
  drift is easy to create and hard to dashboard around. Take the term from the type name and use
  it everywhere the error surfaces.
- **Wrap with context and `%w`**: `fmt.Errorf("search users: %w", err)`. Always `%w`, never
  `%v`, so callers keep `errors.Is` / `errors.As` identity. The message chain should let a reader
  trace the call path.
- **Wrap at the call site.** A helper that runs one call and relabels its error is a
  [pass-through wrapper](../prefer-small-solutions/SKILL.md#shape-the-code-for-a-scanning-reader):
  the label belongs where the call happens.

  ```go
  // WRONG: one more frame to step through, for a label.
  func marshalJSON(v any, cause string) ([]byte, error) {
  	out, err := json.Marshal(v)
  	if err != nil {
  		return nil, fmt.Errorf("marshal(%s): %w", cause, err)
  	}

  	return out, nil
  }

  // CORRECT
  payload, err := json.Marshal(claims)
  if err != nil {
  	return nil, fmt.Errorf("marshal claims: %w", err)
  }
  ```

- **Never silently discard an error.** If one truly can be dropped, write `_ = ...` with a
  comment saying why.

### Reporting errors on spans — once, where they start

Span telemetry follows OpenTelemetry's
[recording-errors guidance](https://opentelemetry.io/docs/specs/semconv/general/recording-errors/).
A trace already links its spans, so each fact is recorded once.

- **Every failed operation reports its error on its own span** with `otel.ReportError(span, err)`,
  whether it raises the error or propagates it. Two moves are forbidden: a bare `return nil, ErrXxx`
  from a function that has a span, and suppressing the report based on the error's _identity_ (a
  `reportUnexpected(span, err)` keyed on "known" sentinels). "Expected" is never a property the error
  value carries.
- **The detail is recorded once.** `ReportError` describes the error on the first span that reports
  it; each span it then propagates through takes the `Error` status alone. Wrapping
  (`fmt.Errorf("...: %w", err)`) keeps that mark, so compare a reported error with `errors.Is`, never
  `==`.
- **Success records nothing.** Leave the status `Unset` and return the value directly.
- **A handled error is not a span error.** When an operation recovers and completes, such as a
  best-effort cleanup that failed, it records nothing: the child span that failed already holds it.
- **A transport handler span fails only on a server fault**, as the OpenTelemetry HTTP and gRPC server
  conventions do. `golib/httpf.HandleError` marks the span for a 5xx and leaves a 4xx unset. A gRPC
  handler answers a server-fault code (`Internal`, `Unavailable`, `Unknown`, `DataLoss`,
  `DeadlineExceeded`) with `grpcf.Fault(code, msg, otel.ReportError(span, err))`, which keeps the
  reported error as the cause the server span's `error.type` names. It returns a client-error code
  (`InvalidArgument`, `NotFound`, …) without a report.
- **No span events.** OpenTelemetry is deprecating them, and a progress marker or exception event
  repeats what the span's status and duration already show.

Spans are independent: a child span ending `Error` does not taint the parent. The DAO and core spans
both end `Error` for a 404 while the handler span stays `Unset`; the "is the service broken" view is
built on the server span's HTTP status code. For bulk-anomaly visibility on a specific security
sentinel, use a counter or an audit log rather than span status. `ReportError` does not end the span;
a `defer span.End()` does. `write-go-service` covers span naming, attributes, and the
span-per-operation rule.

---

## Context rules

- **Never store `context.Context` in a struct.** It is request-scoped and must not outlive the
  call. Pass it as the first parameter of every method that needs it.
- **Contexts flow downward only** — caller to callee. Never return one.
- **Context values are for request-scoped data that crosses API boundaries** — DB transactions,
  trace spans, auth tokens. They are not a back door for optional arguments.

---

## Time capture

When an operation derives more than one timestamp from "now" — `created_at` + `expires_at`,
`start` + `deadline`, any paired audit fields — capture `now := time.Now()` **once** and reuse it:

```go
// WRONG — two wall-clock reads; expires_at - created_at is not exactly the TTL, and a test can't freeze it.
repo.Exec(ctx, &dao.InsertRequest{Now: time.Now(), Expiration: time.Now().Add(cfg.TTL)})

// CORRECT — one logical instant.
now := time.Now()
repo.Exec(ctx, &dao.InsertRequest{Now: now, Expiration: now.Add(cfg.TTL)})
```

Only call `time.Now()` more than once when you genuinely want distinct measurements (e.g.
computing an elapsed duration).

---

## Secrets and sensitive data

- **Never log, trace, or put in span attributes**: passwords, tokens, API keys, private keys,
  key ciphertexts, signed JWTs, or any other credential material. Record identifiers only
  (`user.id`, `key.id`). A redacted `"*****"` of the same length still leaks the input length over
  every trace — don't do that either.
- **Never return secret material in an error message or a transport response.** Errors get
  logged; responses get cached and indexed.
- **Compare secrets in constant time** — `crypto/subtle.ConstantTimeCompare`, never `==` or
  `bytes.Equal` — and on the "not found" branch of an authentication path do the equivalent work
  (e.g. a throwaway hash comparison) so a lookup miss costs the same as a wrong-secret outcome
  and timing does not reveal whether the subject exists.

---

## Loop variable scope

These codebases target Go 1.22+, where `for` loop variables are per-iteration; a closure captures
the right value with no extra copy. Remove any `current := item` shadow copy inside a `for range`
loop when you encounter one — it is dead code on this minimum version.

---

## Performance traps

[prefer-small-solutions](../prefer-small-solutions/SKILL.md#keep-simple-code-fast) lists the traps
every language shares. Go adds these:

- **Recursion on input depth.** A goroutine stack grows by copying itself. Past the maximum size,
  Go stops with a fatal error that `recover` cannot catch, and the whole service exits. Walk a tree
  shaped by input with an explicit stack:

  ```go
  stack := []*Node{root}

  for len(stack) > 0 {
  	node := stack[len(stack)-1]
  	stack = stack[:len(stack)-1]

  	visit(node)

  	stack = append(stack, node.Children...)
  }
  ```

  Walk files with `fs.WalkDir`; the file system bounds its depth.

- **`defer` inside a loop.** A deferred call runs when the function returns, not when the iteration
  ends, so every file, lock, or span opened in the loop stays open until then. Move the loop body
  into a function that defers its own cleanup.
- **Setup repeated per call.** `regexp.MustCompile`, `template.Must(template.New(…).Parse(…))`, and
  `&http.Client{}` belong in a package variable or the constructor. A client built per call also
  loses connection reuse.
- **Expensive work in a package variable.** A package-level initializer runs in every binary that
  links the package, test binaries and consumers of a published client included. Keep it to cheap
  setup such as a compiled `regexp`. Build an expensive value, such as a password hash, on first use
  with `sync.OnceValue`, and warm it at the entrypoint when the first request must not pay for it.
  `GODEBUG=inittrace=1` prints each package's init time and allocations.
- **A goroutine per item.** An unbounded fan-out has no ceiling on memory or open connections. Use
  `errgroup.Group` with `SetLimit`.
- **`append` into a slice without capacity.** Each time the backing array fills, `append`
  allocates a larger one and copies every element. When the final length is known, allocate once:
  `make([]T, len(source))` assigned by index, or `make([]T, 0, len(source))` when a filter may drop
  items.
- **A slice built to be joined.** Collecting parts into a `[]string` for `strings.Join` allocates the
  slice, grows it, and copies every part a second time. Write the parts into a `strings.Builder`,
  with `Grow` first when the length is easy to bound:

  ```go
  var sb strings.Builder

  sb.WriteString(base)

  for _, segment := range segments {
  	sb.WriteByte('/')
  	sb.WriteString(url.PathEscape(segment))
  }

  return sb.String()
  ```

- **A value encoded through an intermediate.** `json.Marshal` then `w.Write` holds the whole payload
  before sending it, and every `[]byte(s)` or `string(b)` conversion copies the data. Encode into the
  destination, as `golib/httpf.SendJSONStatus` does with `json.NewEncoder(w)`, and keep one
  representation from read to write.
- **A body read whole.** `io.ReadAll` on an untrusted request or response body has no ceiling. Wrap
  the body in `http.MaxBytesReader` or `io.LimitReader` first.
- **A pipe abandoned by an early return.** The writer of an `io.Pipe` or a `cmd.StdoutPipe` blocks
  once nobody reads, and `cmd.Wait` blocks with it. `defer` the reader's `Close` right after
  creating it, or kill the process before `Wait` on a failure path.
- **A shared fetch under a lock or a request context.** A cache refresh that fetches while holding
  its write lock stalls every reader for the fetch's latency. One caller's context also cancels the
  fetch for every waiter. Fetch outside the lock under `context.WithoutCancel(ctx)` with its own
  timeout, then swap the result in.
- **`fmt.Sprintf` to join strings on a hot path.** It boxes every argument into an `any`, and each
  box allocates. Join with `+` or `strings.Builder`. Take one segment with `strings.Cut`, not
  `strings.SplitN`.

`time.After` inside a loop is not a leak on Go 1.23 and later: the runtime collects an unreferenced
timer. Do not replace it with a `Ticker` for that reason; a ticker changes the pacing.

Package-level variables, constant tables, and startup code keep `strings.Join`, `fmt.Sprintf`, and
`+`. They run once, so a builder there costs readability and saves nothing.

---

## Correctness traps

Before you write or review code that shares state across goroutines, runs a subprocess, serves a
stream or a unix socket, or builds a URL or DSN from parts, read
[correctness traps](references/correctness-traps.md). It lists the race, cancellation and
stdlib pitfalls that reached `master` once, each with the fix that held.

---

## Common pitfalls

- **`context.Context` stored in a struct.** Always a method parameter.
- **A new dependency added without asking.** Explicit developer approval, every time.
- **Logging / tracing secret material.** Identifiers only — never the secret.
- **Multiple `time.Now()` for timestamps that should share one instant.** Capture once, reuse.
- **Suppressing span reporting for an error.** Every span'd layer reports every error it returns — no
  identity-keyed `reportUnexpected` helper, no bare `return nil, ErrXxx` from a layer with a span.
- **`new(T)` in a constructor.** Use `&T{}`.
- **A parameter added only to fit a callback's signature.** Wrap the call in a closure.
- **snake_case or run-together multi-word file names.** camelCase.
- **Discarding an error without `_ =` and a why-comment.**
- **A helper that only relabels one call's error.** Wrap at the call site.
- **A `[]string` built for one `strings.Join` in per-request code.** Write into a `strings.Builder`.
- **Shadowing an imported package name with a local variable.** Rename the variable.

Files in this skill

  • SKILL.md22.3 KB
  • references/correctness-traps.md5.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.