mirror of
https://github.com/samber/cc-skills-golang.git
synced 2026-09-11 19:46:44 +03:00
initial commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cc-skills-golang",
|
||||
"description": "AI Agent Skills for production-ready Go projects",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Samuel Berthe",
|
||||
"email": "hey@samuel-berthe.fr"
|
||||
},
|
||||
"homepage": "https://github.com/samber/cc-skills-golang",
|
||||
"repository": "https://github.com/samber/cc-skills-golang",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"claude-code-plugin",
|
||||
"plugin",
|
||||
"go",
|
||||
"golang",
|
||||
"ai-agent",
|
||||
"skills",
|
||||
"agentic",
|
||||
"engineering",
|
||||
"developer",
|
||||
"coding",
|
||||
"productivity"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cc-skills-golang",
|
||||
"displayName": "Skills for Golang",
|
||||
"description": "AI Agent Skills for production-ready Go projects",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Samuel Berthe",
|
||||
"email": "hey@samuel-berthe.fr"
|
||||
},
|
||||
"homepage": "https://github.com/samber/cc-skills-golang",
|
||||
"repository": "https://github.com/samber/cc-skills-golang",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"plugin",
|
||||
"go",
|
||||
"golang",
|
||||
"ai-agent",
|
||||
"skills",
|
||||
"agentic",
|
||||
"engineering",
|
||||
"developer",
|
||||
"coding",
|
||||
"productivity"
|
||||
],
|
||||
"skills": "./skills/"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
github: [samber]
|
||||
ko_fi: samuelberthe
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Security scan skills
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['skills/**']
|
||||
pull_request:
|
||||
paths: ['skills/**']
|
||||
schedule:
|
||||
- cron: '0 10 1 * *' # 1st of every month at 10am UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
snyk-agent-scan:
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'samber'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Run Snyk Agent Scan
|
||||
run: uvx snyk-agent-scan@latest --skills ./skills
|
||||
|
||||
claude-skillssafe-scan:
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'samber'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"skillssafe": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.skillssafe.com/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
prompt: |
|
||||
Scan all skill files under the ./skills directory for security issues
|
||||
using the skillssafe MCP server. Report any findings as a comment on
|
||||
the PR or as a job summary.
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Update golang-modernize skill
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 10 2 * *' # 2nd of every month at 10am UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-modernize:
|
||||
if: github.repository_owner == 'samber'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
prompt: |
|
||||
You maintain the golang-modernize skill at skills/golang-modernize/SKILL.md in this repository.
|
||||
|
||||
Your job is to check if this skill needs updating:
|
||||
|
||||
1. Check https://go.dev/dl/ for the latest Go version. If a new Go version has been released that is not yet covered in the skill, research its changelog and add a new section with modernization opportunities.
|
||||
2. Check https://github.com/golangci/golangci-lint/releases for new golangci-lint releases. If new modernize analyzers have been added, update the analyzer table.
|
||||
3. Check if any suggestions in the skill have become too old to be relevant (e.g., patterns from 3+ years ago that should be considered baseline by now). Suggest removing them or moving them to a "baseline" note.
|
||||
4. Check for any significant Go ecosystem news (new standard library packages, deprecated APIs, security advisories) that should be reflected in the skill.
|
||||
5. Update the README.md if needed.
|
||||
|
||||
If there are changes to make, open a PR with a clear description of what was updated and why. If there are different categories of changes, open separate PRs.
|
||||
|
||||
If everything is already up to date, do nothing.
|
||||
allowed_tools: "Read,Write,Edit,Glob,Grep,Bash,WebFetch,WebSearch,mcp__github__get_file_contents,mcp__github__search_code,mcp__github__list_releases,mcp__github__get_latest_release,mcp__github__list_tags,mcp__github__create_pull_request,mcp__github__create_branch,mcp__github__push_files"
|
||||
@@ -0,0 +1,35 @@
|
||||
# Disabled: skills-ref does not yet support the `user-invocable` frontmatter field,
|
||||
# which causes all validations to fail. Re-enable once resolved.
|
||||
# See https://github.com/agentskills/agentskills/issues/105
|
||||
#
|
||||
# name: Validate skills
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# paths: ['skills/**']
|
||||
# pull_request:
|
||||
# paths: ['skills/**']
|
||||
# workflow_dispatch: {}
|
||||
#
|
||||
# jobs:
|
||||
# validate:
|
||||
# if: github.event_name != 'schedule' || github.repository_owner == 'samber'
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v6
|
||||
#
|
||||
# - name: Install skills-ref
|
||||
# run: npm install -g skills-ref
|
||||
#
|
||||
# - name: Validate all skills
|
||||
# run: |
|
||||
# exit_code=0
|
||||
# for skill in skills/*/; do
|
||||
# echo "::group::Validating ${skill}"
|
||||
# if ! skills-ref validate "./${skill}"; then
|
||||
# exit_code=1
|
||||
# fi
|
||||
# echo "::endgroup::"
|
||||
# done
|
||||
# exit $exit_code
|
||||
@@ -0,0 +1 @@
|
||||
/*-workspace/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"proseWrap": "never"
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a Claude Code plugin containing AI agent skills for production-ready Go projects. The repository provides reusable skill definitions that Claude Code can invoke when working on Go codebases.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
skills/ # Claude Code skill definitions
|
||||
<skill-name>/
|
||||
SKILL.md # Required: metadata + instructions
|
||||
references/ # Optional: detailed documentation loaded on demand
|
||||
scripts/ # Optional: executable code
|
||||
assets/ # Optional: templates, resources, linter configs (.golangci.yml, etc.)
|
||||
.claude-plugin/ # Plugin metadata and configuration
|
||||
.cursor-plugin/ # Plugin metadata and configuration (version must match .claude-plugin/plugin.json)
|
||||
gemini-extension.json # Gemini CLI extension manifest (version must match .claude-plugin/plugin.json)
|
||||
```
|
||||
|
||||
## Agent Skills Specification
|
||||
|
||||
All skills MUST conform to the [Agent Skills specification](https://agentskills.io/specification.md). Key requirements are summarized below; the spec is the source of truth when in doubt.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
New skills go in `skills/<skill-name>/SKILL.md`. Each SKILL.md has YAML frontmatter. Fields per the [Agent Skills spec](https://agentskills.io/specification.md) — **this project requires all fields marked "Project-required"**:
|
||||
|
||||
| Field | Required | Constraints |
|
||||
| --- | --- | --- |
|
||||
| `name` | Spec-required | 1-64 chars. Lowercase `a-z`, digits, hyphens. No leading/trailing/consecutive hyphens. **Must match parent directory name.** |
|
||||
| `description` | Spec-required | 1-1024 chars. Describes what the skill does **and when to use it** — this is the primary triggering mechanism. Be specific and slightly "pushy" to avoid under-triggering. |
|
||||
| `license` | Project-required | License name or reference to a bundled license file. Use `MIT` for this project. |
|
||||
| `compatibility` | Project-required | 1-500 chars. Describe actual requirements. Base: `Designed for Claude Code or similar AI coding agents.` Extend when needed: add `Requires git`, `Requires internet access`, `Requires Python 3.14+ and uv`, etc. Skills with no special requirements use the base string only. |
|
||||
| `metadata` | Project-required | Must include `author` (string) and `version` (semver `a.b.c` string, e.g. `"1.0.0"`). |
|
||||
| `user-invocable` | Project-required | Boolean. `true` for skills invocable as slash commands (e.g. `/golang-security`), `false` (default) for contextual skills that auto-trigger. |
|
||||
| `allowed-tools` | Project-required | Space-delimited list of pre-approved tools. See "Allowed tools" below. |
|
||||
|
||||
Example frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: golang-example
|
||||
description: Golang skill for X. Use when doing Y.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents. Requires go compiler and git.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
```
|
||||
|
||||
**Version discipline:** Versions follow semver (`a.b.c`). New skills start at `1.0.0`. When modifying a skill, the developer must increment its `metadata.version` and the plugin version in `.claude-plugin/plugin.json` before merging. CI enforces both checks on PRs. Do not auto-increment versions — remind the developer as a next step.
|
||||
|
||||
### Description quality
|
||||
|
||||
Descriptions are the primary triggering mechanism — they determine whether a skill activates or stays silent. A poorly calibrated description wastes context (too broad) or never fires (too vague).
|
||||
|
||||
**Too vague** (under-triggering) — one-liner descriptions without "Use when..." clauses. The model cannot match user intent to the skill. Fix by adding specific trigger scenarios, API names, and import paths.
|
||||
|
||||
```yaml
|
||||
# Bad — no trigger context, will be ignored
|
||||
description: Implements X in Golang using library/foo
|
||||
|
||||
# Good — specific triggers, matches real user activity
|
||||
description: Implements X in Golang using library/foo — feature A, feature B, and feature C. Apply when using or adopting library/foo, or when the codebase imports `github.com/library/foo`.
|
||||
```
|
||||
|
||||
**Too broad** (over-triggering) — phrases like "whenever writing Go code", "when naming any identifier", "essential for ANY conversation". These match virtually all Go work and flood the context with irrelevant skills. Fix by narrowing to the specific concern the skill uniquely addresses.
|
||||
|
||||
```yaml
|
||||
# Bad — triggers on all Go work
|
||||
description: Use when writing code, reviewing style, or writing comments in Golang.
|
||||
|
||||
# Good — triggers only when style is the actual concern
|
||||
description: Golang code style conventions. Use when the user explicitly asks about formatting, style review, or project coding standards.
|
||||
```
|
||||
|
||||
**Overlap** (competing triggers) — when two skills claim the same trigger keywords, the model may load the wrong one. Fix by adding explicit boundary disclaimers with `→ See` cross-references, following the performance skill cluster pattern.
|
||||
|
||||
```yaml
|
||||
# Good — clear boundary
|
||||
description: "...Not for measurement methodology (→ See golang-benchmark skill)."
|
||||
```
|
||||
|
||||
**Library-specific skills** follow a consistent pattern: describe what the library does, list key API surface, then "Apply when using or adopting X, or when the codebase imports Y." This is the gold standard for contextual (non-user-invocable) skills.
|
||||
|
||||
Every skill description MUST contain the word "Golang" so that skills are only triggered for Go projects, never for other languages.
|
||||
|
||||
## Allowed Tools
|
||||
|
||||
Every skill MUST declare an `allowed-tools` field. Start from the **default set** and add skill-specific extras as needed.
|
||||
|
||||
**Default tools** (include in every skill):
|
||||
|
||||
```
|
||||
Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
```
|
||||
|
||||
**Skill-specific extras** — add only when relevant:
|
||||
|
||||
| Extra tool | When to add |
|
||||
| --- | --- |
|
||||
| `mcp__context7__resolve-library-id mcp__context7__query-docs` | Library-specific skills that recommend fetching docs via context7 |
|
||||
| `Bash(benchstat:*)` | Benchmark or performance skills |
|
||||
| `Bash(dlv:*)` | Troubleshooting or debugging skills |
|
||||
| `Bash(gotests:*)` | Testing skills that generate test scaffolding |
|
||||
| `Bash(protoc:*)` | gRPC or protobuf skills |
|
||||
| `Bash(swag:*)` | Swagger/OpenAPI skills |
|
||||
| `Bash(wire:*)` | Google Wire DI skills |
|
||||
| `Bash(goreleaser:*)` | CI/CD or release skills |
|
||||
| `Bash(gh:*)` | Git or GitHub-related skills |
|
||||
| `Bash(govulncheck:*)` | Security or dependency management skills |
|
||||
| `Bash(curl:*)` | API testing or GraphQL skills |
|
||||
| `WebFetch` | Library-specific skills, skills requiring deep research/analysis, skills fetching external docs or resources |
|
||||
| `WebSearch` | Skills requiring deep research or analysis (security, benchmarking, performance, troubleshooting, observability) and skills that discover resources or track updates |
|
||||
|
||||
When creating a new skill, suggest a tailored `allowed-tools` list based on the skill's purpose.
|
||||
|
||||
## Skill Body
|
||||
|
||||
The body contains step-by-step instructions. Use secondary markdown files in `references/` for depth (referenced via relative links like `[Details](references/details.md)`). Keep file references one level deep from SKILL.md — avoid deeply nested reference chains.
|
||||
|
||||
**Important:** When including non-markdown content (configuration files, scripts, templates, linter configs, etc.), create them as separate files in `assets/` rather than embedding them directly in markdown. Reference these files from your markdown using relative links (e.g., `[View config](assets/example.yml)`). This keeps markdown files clean, makes assets reusable, and allows proper syntax highlighting when the files are viewed separately.
|
||||
|
||||
### Token budgets
|
||||
|
||||
- **~100 tokens per description** — loaded at startup for all skills
|
||||
- **< 5.000 tokens per SKILL.md** (spec recommendation) — keep focused on essentials
|
||||
- **< 2.500 tokens per SKILL.md** (project recommendation)
|
||||
- **< 500 lines per SKILL.md** — move detailed reference material to `references/`
|
||||
- **Use secondary markdown files for depth** — Claude reads these on demand, so they don't count against context until needed
|
||||
- **2-4 skills loaded simultaneously** in a typical session
|
||||
- **Stay below ~10k tokens of total loaded SKILL.md** to avoid degrading response quality
|
||||
|
||||
This is a budget. A 100 lines SKILL.md is even better. Feel free to stay below the limits.
|
||||
|
||||
#### Top-of-body directives
|
||||
|
||||
Place these directives at the very top of the body, before the first heading, in this order:
|
||||
|
||||
| Directive | Required | Format | When to include |
|
||||
| --- | --- | --- | --- |
|
||||
| **Persona** | Optional | `**Persona:** You are a <role>. <mindset or goal>.` | Analytical/generative/multi-mode skills |
|
||||
| **Thinking mode** | Optional | `**Thinking mode:** Use \`ultrathink\` for <task>. <Why deep reasoning matters>.` | Deep analysis: profiling, security auditing, root cause analysis |
|
||||
| **Modes** | Optional | `**Modes:**` section listing each invocation mode and its sub-agent strategy | Skills invoked in distinct contexts (audit, coding, review, code understanding...) |
|
||||
|
||||
All three are optional. A short procedural skill may have none. A complex orchestrating skill may have all three.
|
||||
|
||||
#### Persona (optional)
|
||||
|
||||
Place `**Persona:**` at the very top of the body, before any heading. Keep it to 1–2 sentences: role → mindset or goal. No fictional biography.
|
||||
|
||||
```
|
||||
**Persona:** You are a <role>. <Mindset/assumption or goal>.
|
||||
```
|
||||
|
||||
**Include a persona when:**
|
||||
|
||||
- The skill has a well-defined analytical or generative domain (security, performance, debugging) — it primes the model to prioritize angles it would otherwise reach only with longer prompts.
|
||||
- The skill is invoked by **multiple distinct user types or tasks** (reviewer vs. builder, auditor vs. coder) — a persona helps the model adopt the right frame for each invocation context.
|
||||
- The skill produces stylistic output (docs, code review, commit messages) — it maintains tone consistency across invocations.
|
||||
- The skill orchestrates sub-agents — it implicitly defines the delegation policy and conflict resolution strategy.
|
||||
|
||||
**Skip a persona when:**
|
||||
|
||||
- The skill is purely procedural ("run X, read Y, output Z") — there is nothing to anchor.
|
||||
- The skill body is very short (~10 lines) — instruction density matters more.
|
||||
|
||||
**Risk:** A persona that is too rich in a leaf skill can override global CLAUDE.md instructions if the model perceives an identity conflict. Keep leaf personas minimal and orthogonal to the global persona.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `golang-security` (audit + coding, orchestrator): `You are a senior Go security engineer. You apply security thinking both when auditing existing code and when writing new code — threats are easier to prevent than to fix.`
|
||||
- `golang-performance` (analytical, orchestrator): `You are a Go performance engineer. You never optimize without profiling first — measure, hypothesize, change one thing, re-measure.`
|
||||
- `golang-testing` (generative + analytical): `You are a Go engineer who treats tests as executable specifications. You write tests to constrain behavior, not to hit coverage targets.`
|
||||
- `golang-troubleshooting` (orchestrator + analytical): `You are a Go systems debugger. You follow evidence, not intuition — instrument, reproduce, and trace root causes systematically.`
|
||||
- `golang-code-style` (procedural/short) → **skip persona**.
|
||||
|
||||
#### Skill modes and parallelization (optional)
|
||||
|
||||
Some skills serve multiple distinct **modes** — e.g. `golang-security` is used both for _auditing_ existing code and for _writing_ new secure code. Skills that have multiple modes SHOULD add a short **"Modes"** section early in their body naming each mode and its execution strategy.
|
||||
|
||||
**Common mode names and their strategies:**
|
||||
|
||||
| Mode | Scope | Execution |
|
||||
| --- | --- | --- |
|
||||
| **Coding / Write** | Generating new code | Sequential; optionally a background agent for non-blocking checks |
|
||||
| **Review** | A PR diff | Sequential; start from changed files, then trace call sites and data flows into adjacent code — a bug may live outside the diff but be triggered by it |
|
||||
| **Audit** | Full codebase | Parallel sub-agents split by concern or scope |
|
||||
|
||||
**When to parallelize with sub-agents:**
|
||||
|
||||
Sub-agents can be used in three complementary ways:
|
||||
|
||||
1. **Split by concern** — each agent handles one type of search or analysis in parallel. Agents may read the same file independently; that is expected and acceptable.
|
||||
|
||||
Example — `golang-security` audit mode (up to 5 agents):
|
||||
- Agent 1 — injection (SQL, command, LDAP): grep `fmt.Sprintf` in queries, `exec.Command` with user input
|
||||
- Agent 2 — auth & authorization: JWT handling, session management, middleware chains
|
||||
- Agent 3 — cryptography: `math/rand`, hardcoded secrets, weak hash algorithms
|
||||
- Agent 4 — dependencies: `govulncheck ./...`, review `go.sum`
|
||||
- Agent 5 — input validation & error leakage: `http.Error`, stack traces in responses
|
||||
|
||||
2. **Split by scope** — each agent covers a different part of the codebase doing the same task. Useful for large repositories where one agent would miss files.
|
||||
|
||||
Example — `golang-performance` across a monorepo: Agent 1 covers `pkg/`, Agent 2 covers `internal/`, Agent 3 covers `cmd/`.
|
||||
|
||||
3. **Background agents** — run analysis (e.g., security checks, lint, test coverage) in the background while the main agent continues coding. The background agent does not block the primary workflow; its results are surfaced when it completes. Use this pattern when the analysis is useful but not on the critical path.
|
||||
|
||||
Example — `golang-security` in coding mode: launch a background agent to grep for common vulnerability patterns in newly written code while the main agent finishes implementing the feature.
|
||||
|
||||
**Write / generate mode** — follow the skill's sequential instructions unless background agents are explicitly used for non-blocking analysis.
|
||||
|
||||
### Ultrathink policy
|
||||
|
||||
Skills that require deep analytical reasoning (profiling interpretation, root cause analysis, security auditing) include a **Thinking mode:** `ultrathink` instruction in their SKILL.md body. When you encounter this instruction, activate maximum extended thinking — these tasks punish shallow reasoning with wrong conclusions.
|
||||
|
||||
When creating or modifying a skill that involves deep analysis, profiling, debugging methodology, or security auditing, add this line in the top-of-body directives block, after **Persona** (if present) and before the first heading:
|
||||
|
||||
```
|
||||
**Thinking mode:** Use `ultrathink` for <task description>. <Why deep reasoning matters for this skill>.
|
||||
```
|
||||
|
||||
Update the README.md Ultrathink column (🧠 emoji) to keep track of skills requiring ultrathink mode.
|
||||
|
||||
### Tool reference sections
|
||||
|
||||
When a skill mentions an important tool (e.g. `go test`, `pprof`, `dlv`, `benchstat`), create a `references/` markdown file with a comprehensive reference section listing many command examples. This helps users discover tool capabilities without leaving the skill content.
|
||||
|
||||
**Example:** For the `samber/cc-skills-golang@golang-testing` skill, create `references/go-test.md` with examples like:
|
||||
|
||||
```bash
|
||||
go test ./... # all tests
|
||||
go test -run TestName ./... # specific test by exact name
|
||||
go test -race ./... # race detection
|
||||
go test -cover ./... # coverage summary
|
||||
go test -bench=. -benchmem ./... # benchmarks
|
||||
[...]
|
||||
```
|
||||
|
||||
When the tool has **sub-commands, flags, or configuration files**, showcase them generously — list every useful sub-command with a realistic example, show flag combinations for common workflows, and include sample config files with inline comments. Developers discover tool capabilities through examples, not by reading `--help` output.
|
||||
|
||||
Link to this reference from the main SKILL.md using relative markdown links.
|
||||
|
||||
### Progressive disclosure
|
||||
|
||||
Skills are structured for efficient context use:
|
||||
|
||||
1. **Metadata** (~100 tokens): `name` and `description` are loaded at startup for all skills
|
||||
2. **Instructions** (< 5.000 tokens recommended by AgentMD specification): full SKILL.md body loaded when skill activates
|
||||
3. **Instructions** (< 2.500 tokens recommended by me): SKILL.md body loaded when skill activates
|
||||
4. **Instructions** (< 10.000 tokens recommended by me): full SKILL.md body + secondary files loaded when skill activates
|
||||
5. **Resources** (as needed): files in `scripts/`, `references/`, `assets/` loaded only when required
|
||||
|
||||
Keep SKILL.md under 500 lines. Move detailed reference material to separate files.
|
||||
|
||||
This is a budget. A 100 lines SKILL.md is even better. Feel free to stay for below the limits.
|
||||
|
||||
### Validation
|
||||
|
||||
<!-- Disabled: skills-ref does not yet support the `user-invocable` field.
|
||||
See https://github.com/agentskills/agentskills/issues/105
|
||||
|
||||
Use [skills-ref](https://github.com/agentskills/agentskills/tree/main/skills-ref) to validate skills:
|
||||
|
||||
```bash
|
||||
skills-ref validate ./skills/<skill-name>
|
||||
```
|
||||
-->
|
||||
|
||||
## Skill Architecture
|
||||
|
||||
Each concept must live in exactly one skill. Skills cross-reference each other instead of duplicating content.
|
||||
|
||||
### Performance skill cluster
|
||||
|
||||
Four skills cover performance and observability with distinct ownership:
|
||||
|
||||
- **`samber/cc-skills-golang@golang-performance`** - optimization patterns and methodology ("if X bottleneck, then apply Y")
|
||||
- **`samber/cc-skills-golang@golang-benchmark`** - measurement methodology, deep analysis, profiling interpretation, benchstat, CI regression detection
|
||||
- **`samber/cc-skills-golang@golang-troubleshooting`** - debugging workflow, root cause finding, pprof setup/capture, Delve, GODEBUG
|
||||
- **`samber/cc-skills-golang@golang-observability`** - everyday continuous monitoring (logs, metrics, tracing, alerts) - always-on signals
|
||||
|
||||
The first three form a "deep analysis" cluster for temporary focused investigation. `samber/cc-skills-golang@golang-observability` covers the always-on production signals. Each concept lives in exactly one skill.
|
||||
|
||||
### Atomic skills and deduplication
|
||||
|
||||
Concept drift between skills creates confusion when the agent loads the wrong one — or two competing ones. Each concept MUST live in exactly one skill (the "owner"). All other skills cross-reference the owner with `→ See` using the fully-qualified `owner/repo@skill` identifier. When splitting or merging skills, update every cross-reference to the affected skills. Prefer small, focused skills over large monolithic ones.
|
||||
|
||||
### Company override convention
|
||||
|
||||
Some skills are community defaults, not mandates. They include a note at the top of their body that defers to a company skill that explicitly supersedes them.
|
||||
|
||||
**To override a generic skill**, add this line near the top of your company skill's body (replace `<skill-name>` with the target):
|
||||
|
||||
> This skill supersedes `samber/cc-skills-golang@<skill-name>` skill for [company] projects.
|
||||
|
||||
The override is skill-specific: your company skill must name each generic skill it supersedes. Plugin-wide override (`samber/cc-skills-golang`) is not supported — be explicit. The README skills table (Override column) lists which skills support this.
|
||||
|
||||
### Cross-skill references
|
||||
|
||||
Skills use the `owner/repo@skill:version` identifier format for cross-references. This convention aligns with the [skills CLI](https://github.com/vercel-labs/skills) `owner/repo@skill` install shorthand and extends it with an optional `:version` segment for pinning.
|
||||
|
||||
| Segment | Required | Description | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| `owner` | yes | GitHub owner or organization | `samber` |
|
||||
| `repo` | yes | Repository name | `cc-skills-golang` |
|
||||
| `skill` | yes | Skill name (from frontmatter `name` field) | `golang-security` |
|
||||
| `version` | no | Semver version — omit unless pinning matters | `1.2.0` |
|
||||
|
||||
**Full form:** `samber/cc-skills-golang@golang-security:1.2.0` **Common form (no version):** `samber/cc-skills-golang@golang-security`
|
||||
|
||||
Always use the fully-qualified `owner/repo@skill` form in backticks, even for references within the same plugin. This makes every reference portable, searchable, and unambiguous regardless of where the skill is consumed.
|
||||
|
||||
**Inline:** see the `samber/cc-skills-golang@golang-database` skill. **Arrow-prefixed lists:** `→ See `samber/cc-skills-golang@golang-database` skill for …`
|
||||
|
||||
**Install mapping:** the identifier maps to skills CLI commands:
|
||||
|
||||
- `samber/cc-skills-golang@golang-security` → `npx skills add samber/cc-skills-golang --skill golang-security`
|
||||
- `samber/cc-skills-golang` → `npx skills add samber/cc-skills-golang`
|
||||
|
||||
### Large repository research
|
||||
|
||||
When a skill requires broad codebase understanding (e.g. migration, refactoring, architecture review), it SHOULD recommend using parallel sub-agents (up to 5) via the Agent tool to explore different areas of the repository simultaneously. Each sub-agent should target a distinct search scope (e.g. different packages, file patterns, or concerns). This dramatically reduces research time on large codebases.
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
### Avoid duplicating linter rules
|
||||
|
||||
Skills should NOT re-explain rules that are already enforced by linters (e.g. golangci-lint). If a `.golangci.yml` is present in the skill directory, the linter is the source of truth for style and correctness rules. Skill instructions should focus on higher-level patterns, architecture decisions, and judgment calls that linters cannot catch — not low-level rules like formatting, naming conventions, or import ordering that tools already enforce automatically.
|
||||
|
||||
### Teach reasoning, not only rules
|
||||
|
||||
Skills MUST teach Claude how to think about problems, not just list prescriptive rules. Every recommendation needs a "why" — what goes wrong without it, what consequence the reader avoids. Bare imperatives like "NEVER do X" without rationale are not acceptable.
|
||||
|
||||
When a recommendation addresses a problem that can be confirmed with a diagnostic tool, add a **`Diagnose:`** line indicating which tool(s) to use to validate the hypothesis before applying the fix. This is essential in performance-oriented skills (`samber/cc-skills-golang@golang-performance`) but also useful in any skill where a tool can confirm the root cause (e.g. race detector for concurrency, `go vet` for safety, `govulncheck` for security). The diagnostic tool must NOT apply the fix automatically (e.g. never use `--fix` flags) — let the LLM interpret the diagnostic output and perform the improvement itself, so changes are tracked and can include explanatory comments.
|
||||
|
||||
Format Diagnose lines with a carriage return before each tool, numbered by importance and potential impact (`1-`, `2-`, `3-`, …):
|
||||
|
||||
```md
|
||||
**Diagnose:** 1- `go tool pprof -alloc_objects` — find which functions allocate the most objects; expect hot-path functions near the top 2- `go build -gcflags="-m"` — check which variables escape to the heap; expect `"moved to heap"` for values that should stay on the stack 3- Prometheus `rate(go_memstats_alloc_bytes_total[5m])` — track allocation rate trend in production; compare before/after deploy to detect regressions
|
||||
```
|
||||
|
||||
Diagnostic tools include CLI commands (pprof, fieldalignment, benchstat), runtime introspection (`GODEBUG`, `runtime.ReadMemStats`), and production monitoring queries (Prometheus PromQL, continuous profiling). Use CLI tools for local investigation and monitoring queries for production trend analysis.
|
||||
|
||||
Transformation patterns:
|
||||
|
||||
- **Best Practices items**: embed the tradeoff in one sentence — "Naked returns help in short functions but become confusing when readers must scroll to find what's returned"
|
||||
- **Common Mistakes tables**: inject the "because" into the Fix column — "`math/rand` output is predictable; an attacker can reproduce the sequence. Use `crypto/rand`"
|
||||
- **Code example comments**: carry the reasoning — `// ✗ Bad — nil map has no backing storage; writing panics at runtime`
|
||||
- **Section intros**: add a 1-2 sentence framing paragraph that establishes the mental model before listing specifics
|
||||
|
||||
### Library-specific skills
|
||||
|
||||
When a skill describes a third-party library (e.g. `samber/cc-skills-golang@golang-samber-do`, `samber/cc-skills-golang@golang-google-wire`), the skill instructions **must** include a disclaimer that the skill is not exhaustive and recommend referring to the library's official documentation and code examples for up-to-date API signatures and usage patterns. This ensures the agent always works with current API signatures and best practices, even if the skill's static markdown becomes outdated.
|
||||
|
||||
Skills dedicated to a single open-source project (CLI tool, library, SDK) **must** also include a line at the end of the skill body pointing to the issue tracker for bugs or unexpected behavior:
|
||||
|
||||
```
|
||||
If you encounter a bug or unexpected behavior in <tool>, open an issue at <repo>/issues.
|
||||
```
|
||||
|
||||
**Important:** Skill body text must NEVER contain explicit MCP tool-calling instructions (e.g. "call `resolve-library-id`", "call `query-docs`", "use the MCP context7 server"). These trigger prompt-injection detections in security scanners (Snyk). Instead, use generic formulations like:
|
||||
|
||||
```
|
||||
This skill is not exhaustive. Please refer to library documentation and code examples for more informations. Context7 can help as a discoverability platform.
|
||||
```
|
||||
|
||||
The `mcp__context7__*` tools may still be listed in `allowed-tools` frontmatter — only the body instructions are restricted.
|
||||
|
||||
## Evaluation
|
||||
|
||||
### Adversarial evaluation design
|
||||
|
||||
Run skill evaluation with the pattern recommanded by `/skill-creator`. Use `/tmp/{skill-name}-workspace` as default workspace for ephemeral files.
|
||||
|
||||
Evals MUST be adversarial — they test the skill's **unique value**, not common knowledge the model already has. A good eval has a "trap" the model falls into without the skill but avoids with it. Every rules of a skill must have its test.
|
||||
|
||||
Size evaluations to the skill's **Directory (tok)** column in README.md: expect **~10 assertions per 1,000 tokens** of skill content (full directory excluding evals), with a **minimum of 50 assertions**. Examples from the current table:
|
||||
|
||||
| Skill | Directory (tok) | Min assertions |
|
||||
| --------------- | --------------- | -------------- |
|
||||
| Code style | 2,613 | 50 |
|
||||
| Error handling | 4,145 | 62 |
|
||||
| Testing | 5,913 | 89 |
|
||||
| Design patterns | 9,122 | 137 |
|
||||
| Security | 21,470 | 322 |
|
||||
| Benchmark | 29,081 | 436 |
|
||||
|
||||
Store your evaluation scenarios in `skills/{name}/evals/evals.json`.
|
||||
|
||||
**Design principles:**
|
||||
|
||||
- **Never test common knowledge.** If the model passes both with and without the skill, the eval is useless. Avoid testing well-known patterns (e.g. `bufio.Scanner` for file reading, `strings.Builder` for concatenation, basic `make` preallocation).
|
||||
- **Test the skill's unique guidance.** Identify what the skill teaches that the model wouldn't do by default — subtle tradeoffs, non-obvious stdlib choices, Go-specific gotchas.
|
||||
- **Create traps.** Frame the task so the natural/default approach is wrong. The skill should steer toward the correct approach.
|
||||
- **Test judgment, not API knowledge.** Ask "which data structure?" not "how to use data structure X?". The model knows APIs; the skill adds architectural judgment.
|
||||
- **Avoid leading prompts.** Don't mention the correct approach in the task description (e.g. don't say "use container/list" — say "implement LRU cache"). Don't hint at the answer.
|
||||
- **Stress-test edge cases.** The skill's common-mistakes tables and "when NOT to use" guidance are high-value targets.
|
||||
- **Isolate the evaluated skill.** When running "without" evals, do NOT load any skill that covers overlapping content — a colliding skill would give the model guidance it shouldn't have, inflating the "without" score and masking the evaluated skill's true uplift. When running "with" evals, load only the skill under test (and its explicit cross-references if needed). For example, when evaluating `golang-error-handling`, do not load `golang-code-style` or `golang-safety` — they contain overlapping error-handling advice that would contaminate the baseline.
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
|
||||
- Testing `strings.Builder` when the task obviously needs string building → model knows this
|
||||
- Testing `make([]T, 0, n)` when the task obviously needs preallocation → model knows this
|
||||
- Testing `bufio.Scanner` for file reading → model knows this
|
||||
- Testing `container/heap` when the task says "priority queue" → model knows this
|
||||
- Any eval where both with/without score 100% → eval is too easy, redesign it
|
||||
|
||||
#### Evaluation Reporting
|
||||
|
||||
Eval results go in `EVALUATIONS.md` at the repo root. Append new skill sections — never overwrite previous runs. The file is wrapped in `<!-- prettier-ignore-start/end -->` so Prettier doesn't break the HTML spans.
|
||||
|
||||
**Structure per skill:**
|
||||
|
||||
```
|
||||
## `skill-name` — vX.Y.Z
|
||||
|
||||
Summary table (Overall with/without/delta)
|
||||
|
||||
<details>
|
||||
<summary>Full breakdown (N assertions)</summary>
|
||||
|
||||
Metadata line (model, runs, grading method)
|
||||
Flat table: # | Assertion | With | Without
|
||||
- Eval header rows: empty # cell, bold eval name + description, bold score spans
|
||||
- Assertion rows: a.b numbering, assertion text, colored ✓/✗ spans
|
||||
- Failed cells may include short evidence after ✗ (e.g. "✗ NewStore()")
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
**Styling:** Two CSS classes in the file's `<style>` block — `.g { color: #22863a; font-weight: bold; }` (green/pass) and `.r { color: #cb2431; font-weight: bold; }` (red/fail). Use `<span class="g">✓</span>` for pass and `<span class="r">✗</span>` for fail. Eval header scores use the same classes: `**<span class="g">4/4</span>**` or `**<span class="r">2/4</span>**` (red when score < max).
|
||||
|
||||
**Numbering:** `a.b` format — `a` is the eval number, `b` is the assertion within that eval (e.g., `4.3`, `11.2`). Eval header rows leave the `#` cell empty.
|
||||
|
||||
See `EVALUATIONS.md` for the canonical format.
|
||||
|
||||
After updating `EVALUATIONS.md` sum all the skill reports and update the table in `Skill evaluations` section of README.md.
|
||||
|
||||
Also update the **Summary table** at the top of `EVALUATIONS.md`: add a new row for the skill (or update the existing row if re-running), then recompute the **Total** row by summing all numerators and denominators across all skills. The table is ordered by Delta ascending (low → high). Populate the Concern column using these rules: "Low delta" (≤32pp), "High without" (Without ≥65%), "Low with-skill score" (With ≤90%) — combine when multiple apply. Use bold on Concern values to draw attention. The **Uplift** column shows `With / Without` rounded to 2 decimal places and suffixed with `×` (e.g. `1.64×`); recompute it for every row including the Total.
|
||||
|
||||
## Workflows
|
||||
|
||||
### After updating a skill
|
||||
|
||||
After making changes, suggest the following as next steps for the developer to run. Do NOT execute these automatically.
|
||||
|
||||
1. ~~Validate against the spec: `skills-ref validate ./skills/{name}`~~ (disabled — [skills-ref doesn't support `user-invocable` yet](https://github.com/agentskills/agentskills/issues/105))
|
||||
2. Reformat markdowns with `npx prettier --write *.md "**/*.md"` — run before measuring tokens, as formatting changes token counts
|
||||
3. Measure token counts:
|
||||
- **Description (tok)**: `awk 'NR==1 && /^---$/{found=1; next} found && /^---$/{exit} found && /^description:/{print}' skills/{name}/SKILL.md | npx tiktoken-cli`
|
||||
- **SKILL.md (tok)**: `npx tiktoken-cli skills/{name}/SKILL.md`
|
||||
- **Directory (tok)**: `npm exec -- tiktoken-cli --exclude "evals" skills/{name}/` (exclude `evals/` subdirectory)
|
||||
4. Update the README.md table with the measured token counts, update the total rows, and update the **Error rate gap** column (`Without - With`, expressed as a negative percentage, e.g. `-39%`)
|
||||
5. Increment `metadata.version` in the changed SKILL.md and the plugin version in `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json` and `gemini-extension.json` — all three plugin files MUST have the same version
|
||||
6. Run skill evaluation via `/skill-creator`: 10+ evals, run them with and without the skill via parallel subagents, grade with LLM-as-judge (no human in the loop), print results, suggest improvements if needed, and append/update the report to `EVALUATIONS.md` following the format in [Evaluation Reporting](#evaluation-reporting)
|
||||
7. Depending on evaluation final report, suggest improvements and loop
|
||||
|
||||
For initial evaluation of skills, use Human-as-Judge.
|
||||
|
||||
### README status icons
|
||||
|
||||
In the README tables, skill names are prefixed with status icons:
|
||||
|
||||
- **✅** — skill is complete and active
|
||||
- **👷** — skill is work in progress — **set all token counts to 0** for these rows and exclude them from totals
|
||||
- **❌** — skill is disabled/not yet started — **set all token counts to 0** for these rows and exclude them from totals
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
Plugin metadata is defined in `.claude-plugin/plugin.json`, `.cursor-plugin/plugin.json` and `gemini-extension.json`. Both files MUST have the same `version` value. Fields include:
|
||||
|
||||
- Plugin name, version, and description
|
||||
- Author and repository information
|
||||
- Keywords for discoverability
|
||||
|
||||
## Best Practice Sources
|
||||
|
||||
Skills:
|
||||
|
||||
- https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
|
||||
|
||||
Go language:
|
||||
|
||||
- https://go.dev/doc/effective_go
|
||||
- https://go.dev/ref/spec
|
||||
- https://go.dev/ref/mem
|
||||
- https://go.dev/blog/pipelines
|
||||
- https://go.dev/doc/faq
|
||||
- https://go-proverbs.github.io/
|
||||
- https://gobyexample.com/
|
||||
|
||||
Style guides:
|
||||
|
||||
- https://google.github.io/styleguide/go/guide
|
||||
- https://google.github.io/styleguide/go/decisions
|
||||
- https://google.github.io/styleguide/go/best-practices.html
|
||||
- https://github.com/uber-go/guide/blob/master/style.md
|
||||
- https://github.com/unknwon/go-code-convention/blob/main/en-US.md
|
||||
- https://go.dev/talks/2014/names.slide
|
||||
|
||||
Common mistakes:
|
||||
|
||||
- https://100go.co/
|
||||
- https://golang50shades.com/
|
||||
|
||||
Security:
|
||||
|
||||
- https://go.dev/doc/security/best-practices
|
||||
- https://docs.bearer.com/reference/rules/?lang-go=go_
|
||||
- https://docs.snyk.io/scan-with-snyk/snyk-code/snyk-code-security-rules/go-rules
|
||||
|
||||
Internals:
|
||||
|
||||
- https://research.swtch.com/godata
|
||||
- https://research.swtch.com/interfaces
|
||||
|
||||
Testing:
|
||||
|
||||
- https://testing.googleblog.com/2017/10/code-health-identifiernamingpostforworl.html
|
||||
- https://testing.googleblog.com/2013/03/testing-on-toilet-testing-state-vs.html
|
||||
- https://testing.googleblog.com/2014/05/testing-on-toilet-effective-testing.html
|
||||
- https://testing.googleblog.com/2014/05/testing-on-toilet-risk-driven-testing.html
|
||||
- https://testing.googleblog.com/2015/01/testing-on-toilet-change-detector-tests.html
|
||||
|
||||
## Formats
|
||||
|
||||
Write short sentences.
|
||||
|
||||
### Format 5: Imperative Prose (recommended by skill-creator)
|
||||
|
||||
```md
|
||||
## Writing Rules
|
||||
|
||||
Cut ruthlessly — every word must work. Remove filler words like "very", "really", "incredibly". Use active voice. Vary sentence length: 3-5 words for impact, then medium length for explanation.
|
||||
```
|
||||
|
||||
### Format 1: Categorized examples (Good / Bad)
|
||||
|
||||
```md
|
||||
## `errors.New` — static error messages
|
||||
|
||||
'''go // ✓ Good - {tell why} errors.New("unexpected error)
|
||||
|
||||
// ✗ Bad — {tell why} fmt.Errorf("unexpected error) '''
|
||||
```
|
||||
|
||||
### Format 2: Template / Example-Driven
|
||||
|
||||
```md
|
||||
## Commit Message Format
|
||||
|
||||
ALWAYS use this exact template:
|
||||
|
||||
''' <type>[optional scope]: <description> [optional body] '''
|
||||
|
||||
**Example 1:** Input: Added user authentication with JWT tokens Output: feat(auth): implement JWT-based authentication
|
||||
|
||||
**Example 2:** ...
|
||||
```
|
||||
|
||||
### Format 3: Categorized Bullet Lists (Do / Don't / Avoid)
|
||||
|
||||
```md
|
||||
**Formatting:**
|
||||
|
||||
- Mobile-first (58% on mobile)
|
||||
- Never more than 2 visual lines per paragraph on phone
|
||||
- Line breaks between most sentences
|
||||
|
||||
**Avoid:**
|
||||
|
||||
- Rhetorical questions
|
||||
- Empty words ("digital landscape", "incontournable")
|
||||
- Emoji abuse
|
||||
```
|
||||
|
||||
### Format 4: Numbered RFC-style Rules (MUST/MAY/SHOULD)
|
||||
|
||||
```md
|
||||
## Git conventions
|
||||
|
||||
1. Commits MUST be prefixed with a type
|
||||
2. The type `feat` MUST be used for new features
|
||||
3. A scope MAY be provided after a type, in parentheses
|
||||
4. A description MUST immediately follow the colon and space
|
||||
```
|
||||
+3887
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Samuel Berthe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Claude Code Skills for production-ready Golang projects
|
||||
|
||||
AI agent skills are reusable instruction sets that extend your coding assistant with domain-specific expertise — loaded on demand so they don't bloat your context. This repository covers **Go-specific** skills only (language, testing, security, observability, etc.); for dev workflow skills (git conventions, CI/CD, PR reviews) you'll want to add a separate skills plugin.
|
||||
|
||||
For generic skills, please visit [cc-skills](https://github.com/samber/cc-skills).
|
||||
|
||||
## 🚀 How to use
|
||||
|
||||
- **Fork it** to adapt the skills to your own company conventions (not recommended: [reference](#-overlap) this skill instead)
|
||||
- **Copy-paste it** to create your own skills based on this repository (not recommended)
|
||||
- **Install it** with the [skills CLI](https://skills.sh) or a tool-specific method below
|
||||
|
||||
**Install with [skills](https://skills.sh/) CLI** (universal — works with any [Agent Skills](https://agentskills.io)-compatible tool):
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/samber/cc-skills-golang
|
||||
# or a single skill:
|
||||
npx skills add https://github.com/samber/cc-skills-golang --skill golang-performance
|
||||
```
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
|
||||
<details>
|
||||
<summary>Claude Code</summary>
|
||||
|
||||
```bash
|
||||
/plugin marketplace add samber/cc
|
||||
/plugin install cc-skills-golang@samber
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Openclaw</summary>
|
||||
|
||||
Copy skills into the cross-client discovery directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.openclaw/skills/cc-skills-golang
|
||||
# or in workspace:
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.openclaw/workspace/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gemini CLI</summary>
|
||||
|
||||
```bash
|
||||
gemini extensions install https://github.com/samber/cc-skills-golang
|
||||
```
|
||||
|
||||
Update with `gemini extensions update cc-skills-golang`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Cursor</summary>
|
||||
|
||||
Copy skills into the cross-client discovery directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.cursor/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
Cursor auto-discovers skills from `.agents/skills/` and `.cursor/skills/`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Copilot</summary>
|
||||
|
||||
Copy skills into the cross-client discovery directory:
|
||||
|
||||
```bash
|
||||
/plugin install https://github.com/samber/cc-skills-golang
|
||||
# or
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.copilot/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
Copilot auto-discovers skills from `.copilot/skills/`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>OpenCode</summary>
|
||||
|
||||
Copy skills into the cross-client discovery directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.agents/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
OpenCode auto-discovers skills from `.agents/skills/`, `.opencode/skills/`, and `.claude/skills/`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Codex (OpenAI)</summary>
|
||||
|
||||
Clone into the cross-client discovery path:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.agents/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
Codex auto-discovers skills from `~/.agents/skills/` and `.agents/skills/`. Update with `cd ~/.agents/skills/cc-skills-golang && git pull`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Antigravity</summary>
|
||||
|
||||
Clone and symlink into the cross-client discovery path:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samber/cc-skills-golang.git ~/.antigravity/skills/cc-skills-golang
|
||||
```
|
||||
|
||||
Update with `cd ~/.antigravity/skills/cc-skills-golang && git pull`.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## 🧩 Skills
|
||||
|
||||
These skills are designed as **atomic, cross-referencing units**. A skill may reference conventions defined in another (e.g. error-handling rules that affect logging live in `golang-error-handling`, not `golang-observability`). Installing only a subset will give you a partial — and potentially inconsistent — view of the guidelines. For best results, install all general-purpose skills together.
|
||||
|
||||
Each skill lives in `skills/<name>/` with a `SKILL.md` entry point. The `SKILL.md` is kept small with internal references to advanced markdown files, so only the relevant content is loaded into context.
|
||||
|
||||
- **Description (tok)** — weight of the `description` field from YAML frontmatter, always loaded into Claude's context for skill triggering
|
||||
- **SKILL.md (tok)** — weight of the full `SKILL.md` file loaded when the skill triggers
|
||||
- **Directory (tok)** — weight of all files in the skill directory (SKILL.md + referenced markdown files)
|
||||
|
||||
Skills marked with ⭐️ are recommended as a starting point for most Go projects.
|
||||
|
||||
**General purpose:**
|
||||
|
||||
| | Skill | Name | Cmd | Ultrathink | Overridable | Error rate gap | Description (tok) | SKILL.md (tok) | Directory (tok) |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ⭐️ | ✅ Code style | `golang-code-style` | | | ⚙️ | -40% | 31 | 2,028 | 2,644 |
|
||||
| ⭐️ | ✅ Data structures | `golang-data-structures` | | | | -39% | 92 | 2,420 | 6,132 |
|
||||
| ⭐️ | ✅ Database | `golang-database` | | | ⚙️ | -38% | 112 | 2,681 | 7,166 |
|
||||
| ⭐️ | ✅ Design patterns | `golang-design-patterns` | | | ⚙️ | -37% | 66 | 2,567 | 9,273 |
|
||||
| ⭐️ | ✅ Documentation | `golang-documentation` | ⚡ | | ⚙️ | -53% | 73 | 2,636 | 10,507 |
|
||||
| ⭐️ | ✅ Error handling | `golang-error-handling` | | | ⚙️ | -26% | 90 | 1,477 | 4,351 |
|
||||
| ⭐️ | 👷 How-to | `golang-how-to` | | | | — | 0 | 0 | 0 |
|
||||
| ⭐️ | ✅ Modernize | `golang-modernize` | ⚡ | | | -61% | 113 | 2,434 | 7,557 |
|
||||
| ⭐️ | ✅ Naming | `golang-naming` | | | ⚙️ | -23% | 158 | 2,822 | 7,190 |
|
||||
| ⭐️ | ✅ Safety | `golang-safety` | | | | -58% | 85 | 2,413 | 5,183 |
|
||||
| ⭐️ | ✅ Testing | `golang-testing` | ⚡ | 🧠 | ⚙️ | -32% | 98 | 3,028 | 6,135 |
|
||||
| ⭐️ | ✅ Troubleshooting | `golang-troubleshooting` | ⚡ | 🧠 | | -32% | 106 | 2,633 | 15,799 |
|
||||
| ⭐️ | ✅ Security | `golang-security` | ⚡ | 🧠 | | -32% | 84 | 2,790 | 20,811 |
|
||||
| | ✅ Benchmark | `golang-benchmark` | ⚡ | 🧠 | | -50% | 92 | 2,030 | 29,114 |
|
||||
| | ✅ CLI | `golang-cli` | | | | -43% | 73 | 2,232 | 6,047 |
|
||||
| | ✅ Concurrency | `golang-concurrency` | | | ⚙️ | -39% | 71 | 1,831 | 6,296 |
|
||||
| | ✅ Context | `golang-context` | | | ⚙️ | -34% | 41 | 1,102 | 3,898 |
|
||||
| | ✅ Continuous integration | `golang-continuous-integration` | ⚡ | | | -59% | 105 | 2,745 | 6,387 |
|
||||
| | ✅ Dependency injection | `golang-dependency-injection` | | | ⚙️ | -47% | 104 | 2,800 | 5,071 |
|
||||
| | ✅ Dependency management | `golang-dependency-management` | | | | -54% | 94 | 1,794 | 4,874 |
|
||||
| | ✅ Structs & interfaces | `golang-structs-interfaces` | | | ⚙️ | -35% | 110 | 2,956 | 2,956 |
|
||||
| | ✅ Linter | `golang-linter` | | | | -41% | 119 | 1,639 | 5,418 |
|
||||
| | ✅ Observability | `golang-observability` | ⚡ | | ⚙️ | -37% | 144 | 2,851 | 18,358 |
|
||||
| | ✅ Performance | `golang-performance` | ⚡ | 🧠 | | -39% | 108 | 1,876 | 17,746 |
|
||||
| | ✅ Popular libraries | `golang-popular-libraries` | | | | -30% | 61 | 746 | 4,089 |
|
||||
| | ✅ Project layout | `golang-project-layout` | ⚡ | | | -38% | 66 | 1,468 | 5,676 |
|
||||
| | ✅ Stay up to date | `golang-stay-updated` | | | | -56% | 43 | 1,874 | 1,874 |
|
||||
|
||||
**Tools:**
|
||||
|
||||
| Skill | Name | Cmd | Ultrathink | Error rate gap | Description (tok) | SKILL.md (tok) | Directory (tok) |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ❌ google/wire | `golang-google-wire` | | | — | 0 | 0 | 0 |
|
||||
| ❌ GraphQL | `golang-graphql` | | | — | 0 | 0 | 0 |
|
||||
| ✅ gRPC | `golang-grpc` | | | -41% | 69 | 2,086 | 4,902 |
|
||||
| ❌ spf13/cobra | `golang-spf13-cobra` | | | — | 0 | 0 | 0 |
|
||||
| ❌ spf13/viper | `golang-spf13-viper` | | | — | 0 | 0 | 0 |
|
||||
| ❌ swaggo/swag | `golang-swagger` | | | — | 0 | 0 | 0 |
|
||||
| ❌ uber-go/dig | `golang-uber-dig` | | | — | 0 | 0 | 0 |
|
||||
| ❌ uber-go/fx | `golang-uber-fx` | | | — | 0 | 0 | 0 |
|
||||
| ✅ samber/do | `golang-samber-do` | | | -81% | 70 | 1,704 | 3,227 |
|
||||
| ❌ samber/hot | `golang-samber-hot` | | | — | 0 | 0 | 0 |
|
||||
| ❌ samber/lo | `golang-samber-lo` | | | — | 0 | 0 | 0 |
|
||||
| ❌ samber/mo | `golang-samber-mo` | | | — | 0 | 0 | 0 |
|
||||
| ✅ samber/oops | `golang-samber-oops` | | | -59% | 69 | 2,338 | 2,650 |
|
||||
| ❌ samber/ro | `golang-samber-ro` | | | — | 0 | 0 | 0 |
|
||||
| ❌ samber/slog | `golang-samber-slog` | | | — | 0 | 0 | 0 |
|
||||
| ❌ temporal | `golang-temporal` | | | — | 0 | 0 | 0 |
|
||||
| ✅ stretchr/testify | `golang-stretchr-testify` | | | -47% | 89 | 1,641 | 2,460 |
|
||||
|
||||
Token counts are measured with `npm exec -- tiktoken-cli --exclude "evals" skills/{name}/`. Description tokens extracted with:
|
||||
|
||||
```bash
|
||||
awk 'NR==1 && /^---$/{found=1; next} found && /^---$/{exit} found && /^description:/{print}' skills/<name>/SKILL.md | npx tiktoken-cli
|
||||
```
|
||||
|
||||
## 🧪 Skill evaluations
|
||||
|
||||
| | With Skill | Without Skill | Delta |
|
||||
| ----------- | ------------------- | ------------------- | --------- |
|
||||
| **Overall** | **2823/2895 (97%)** | **1574/2895 (54%)** | **+43pp** |
|
||||
|
||||
See [EVALUATIONS.md](./EVALUATIONS.md) for the full per-skill breakdown.
|
||||
|
||||
## 🎯 Tuning Skill Triggers
|
||||
|
||||
If a skill triggers too often or not often enough, please [open an issue](https://github.com/samber/cc-skills-golang/issues) suggesting a description change. The `description` field in SKILL.md frontmatter is the primary triggering mechanism — small wording adjustments can significantly improve trigger accuracy. Some `SKILL.md` might have `When to use` section which is another level of exclusion. Finally, `SKILL.md` are a entrypoint for lazy loading references with deep knowledge located in `references/`.
|
||||
|
||||
## 🔄 Overlap
|
||||
|
||||
Claude reports very little overlap between skills in this repo, thanks to cross-reference. I suggest enabling most of the skills and leverage lazy loading. The recommended ⭐️ skills load ~1,100 tokens of descriptions at startup; full skill content is only pulled in when relevant. Note:
|
||||
|
||||
- I estimate that 50% of `golang-naming` and `golang-code-style` overlap with linters (golangci-lint).
|
||||
- A large part of the security rules in `golang-security` have been distilled from Bearer (SAST) check list. The skill is still useful for methodology.
|
||||
- If your team has its own conventions, create a company skill and declare the override explicitly near the top of its body: `"This skill supersedes \`samber/cc-skills-golang@golang-naming\` skill for [company] projects."` Skills marked ⚙️ in the table above support this mechanism.
|
||||
|
||||
## ✍️ Contribute
|
||||
|
||||
- **100 tokens per skill description** - what ? when use this skill ?
|
||||
- **1.000–2.500 tokens per SKILL.md** — keep the main file focused on essentials
|
||||
- **Use secondary markdown files for depth** — reference them from SKILL.md with relative links (e.g., `[Logging](./logging.md)`). Claude reads these on demand when the topic is relevant, so they don't count against the context budget until needed
|
||||
- **Up to 10.000 tokens** for full skill and secondary files
|
||||
- **2–4 skills loaded simultaneously** in a typical session — design skills to coexist
|
||||
- **Stay below ~10k tokens of total loaded SKILL.md** anytime to avoid degrading response quality
|
||||
|
||||
For more guidelines, please check `CLAUDE.md`.
|
||||
|
||||
## 💫 Fuel the Revolution
|
||||
|
||||
- ⭐️ **Star this repo** - Your star powers the caffeine engine!
|
||||
- ☕️ **Buy me a coffee** - I'll literally use it to build more skills while drinking actual coffee
|
||||
|
||||
[](https://github.com/sponsors/samber)
|
||||
|
||||
## 📝 License
|
||||
|
||||
Copyright © 2026 [Samuel Berthe](https://github.com/samber).
|
||||
|
||||
This project is under [MIT](./LICENSE) license.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "cc-skills-golang",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Agent Skills for production-ready Go projects"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: golang-benchmark
|
||||
description: "Golang benchmarking, profiling, and performance measurement. Use when writing, running, or comparing Go benchmarks, profiling hot paths with pprof, interpreting CPU/memory/trace profiles, analyzing results with benchstat, setting up CI benchmark regression detection, or investigating production performance with Prometheus runtime metrics. Also use when the developer needs deep analysis on a specific performance indicator - this skill provides the measurement methodology, while golang-performance provides the optimization patterns."
|
||||
user-invocable: true
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch Bash(benchstat:*) Bash(benchdiff:*) Bash(cob:*) Bash(gobenchdata:*) Bash(curl:*) mcp__context7__resolve-library-id mcp__context7__query-docs WebSearch
|
||||
---
|
||||
|
||||
**Persona:** You are a Go performance measurement engineer. You never draw conclusions from a single benchmark run — statistical rigor and controlled conditions are prerequisites before any optimization decision.
|
||||
|
||||
**Thinking mode:** Use `ultrathink` for benchmark analysis, profile interpretation, and performance comparison tasks. Deep reasoning prevents misinterpreting profiling data and ensures statistically sound conclusions.
|
||||
|
||||
# Go Benchmarking & Performance Measurement
|
||||
|
||||
Performance improvement does not exist without measures — if you can measure it, you can improve it.
|
||||
|
||||
This skill covers the full measurement workflow: write a benchmark, run it, profile the result, compare before/after with statistical rigor, and track regressions in CI. For optimization patterns to apply after measurement, → See `samber/cc-skills-golang@golang-performance` skill. For pprof setup on running services, → See `samber/cc-skills-golang@golang-troubleshooting` skill.
|
||||
|
||||
## Writing Benchmarks
|
||||
|
||||
### `b.Loop()` (Go 1.24+) — preferred
|
||||
|
||||
`b.Loop()` prevents the compiler from optimizing away the code under test — without it, the compiler can detect dead results and eliminate them, producing misleadingly fast numbers. It also excludes setup code before the loop from timing automatically.
|
||||
|
||||
```go
|
||||
func BenchmarkParse(b *testing.B) {
|
||||
data := loadFixture("large.json") // setup — excluded from timing
|
||||
for b.Loop() {
|
||||
Parse(data) // compiler cannot eliminate this call
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Existing `for range b.N` benchmarks still work but should migrate to `b.Loop()` — the old pattern requires manual `b.ResetTimer()` and a package-level sink variable to prevent dead code elimination.
|
||||
|
||||
### Memory tracking
|
||||
|
||||
```go
|
||||
func BenchmarkAlloc(b *testing.B) {
|
||||
b.ReportAllocs() // or run with -benchmem flag
|
||||
for b.Loop() {
|
||||
_ = make([]byte, 1024)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`b.ReportMetric()` adds custom metrics (e.g., throughput):
|
||||
|
||||
```go
|
||||
b.ReportMetric(float64(totalBytes)/b.Elapsed().Seconds(), "bytes/s")
|
||||
```
|
||||
|
||||
### Sub-benchmarks and table-driven
|
||||
|
||||
```go
|
||||
func BenchmarkEncode(b *testing.B) {
|
||||
for _, size := range []int{64, 256, 4096} {
|
||||
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
|
||||
data := make([]byte, size)
|
||||
for b.Loop() {
|
||||
Encode(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
```bash
|
||||
go test -bench=BenchmarkEncode -benchmem -count=10 ./pkg/... | tee bench.txt
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
| ---------------------- | ----------------------------------------- |
|
||||
| `-bench=.` | Run all benchmarks (regexp filter) |
|
||||
| `-benchmem` | Report allocations (B/op, allocs/op) |
|
||||
| `-count=10` | Run 10 times for statistical significance |
|
||||
| `-benchtime=3s` | Minimum time per benchmark (default 1s) |
|
||||
| `-cpu=1,2,4` | Run with different GOMAXPROCS values |
|
||||
| `-cpuprofile=cpu.prof` | Write CPU profile |
|
||||
| `-memprofile=mem.prof` | Write memory profile |
|
||||
| `-trace=trace.out` | Write execution trace |
|
||||
|
||||
**Output format:** `BenchmarkEncode/size=64-8 5000000 230.5 ns/op 128 B/op 2 allocs/op` — the `-8` suffix is GOMAXPROCS, `ns/op` is time per operation, `B/op` is bytes allocated per op, `allocs/op` is heap allocation count per op.
|
||||
|
||||
## Profiling from Benchmarks
|
||||
|
||||
Generate profiles directly from benchmark runs — no HTTP server needed:
|
||||
|
||||
```bash
|
||||
# CPU profile
|
||||
go test -bench=BenchmarkParse -cpuprofile=cpu.prof ./pkg/parser
|
||||
go tool pprof cpu.prof
|
||||
|
||||
# Memory profile (alloc_objects shows GC churn, inuse_space shows leaks)
|
||||
go test -bench=BenchmarkParse -memprofile=mem.prof ./pkg/parser
|
||||
go tool pprof -alloc_objects mem.prof
|
||||
|
||||
# Execution trace
|
||||
go test -bench=BenchmarkParse -trace=trace.out ./pkg/parser
|
||||
go tool trace trace.out
|
||||
```
|
||||
|
||||
For full pprof CLI reference (all commands, non-interactive mode, profile interpretation), see [pprof Reference](./references/pprof.md). For execution trace interpretation, see [Trace Reference](./references/trace.md). For statistical comparison, see [benchstat Reference](./references/benchstat.md).
|
||||
|
||||
## Reference Files
|
||||
|
||||
- **[pprof Reference](./references/pprof.md)** — Interactive and non-interactive analysis of CPU, memory, and goroutine profiles. Full CLI commands, profile types (CPU vs alloc*objects vs inuse_space), web UI navigation, and interpretation patterns. Use this to dive deep into \_where* time and memory are being spent in your code.
|
||||
|
||||
- **[benchstat Reference](./references/benchstat.md)** — Statistical comparison of benchmark runs with rigorous confidence intervals and p-value tests. Covers output reading, filtering old benchmarks, interleaving results for visual clarity, and regression detection. Use this when you need to prove a change made a meaningful performance difference, not just a lucky run.
|
||||
|
||||
- **[Trace Reference](./references/trace.md)** — Execution tracer for understanding _when_ and _why_ code runs. Visualizes goroutine scheduling, garbage collection phases, network blocking, and custom span annotations. Use this when pprof (which shows _where_ CPU goes) isn't enough — you need to see the timeline of what happened.
|
||||
|
||||
- **[Diagnostic Tools](./references/tools.md)** — Quick reference for ancillary tools: fieldalignment (struct padding waste), GODEBUG (runtime logging flags), fgprof (frame graph profiles), race detector (concurrency bugs), and others. Use this when you have a specific symptom and need a focused diagnostic — don't reach for pprof if a simpler tool already answers your question.
|
||||
|
||||
- **[Compiler Analysis](./references/compiler-analysis.md)** — Low-level compiler optimization insights: escape analysis (when values move to the heap), inlining decisions (which function calls are eliminated), SSA dump (intermediate representation), and assembly output. Use this when benchmarks show allocations you didn't expect, or when you want to verify the compiler did what you intended.
|
||||
|
||||
- **[CI Regression Detection](./references/ci-regression.md)** — Automated performance regression gating in CI pipelines. Covers three tools (benchdiff for quick PR comparisons, cob for strict threshold-based gating, gobenchdata for long-term trend dashboards), noisy neighbor mitigation strategies (why cloud CI benchmarks vary 5-10% even on quiet machines), and self-hosted runner tuning to make benchmarks reproducible. Use this when you want to ensure pull requests don't silently slow down your codebase — detecting regressions early prevents shipping performance debt.
|
||||
|
||||
- **[Investigation Session](./references/investigation-session.md)** — Production performance troubleshooting workflow combining Prometheus runtime metrics (heap size, GC frequency, goroutine counts), PromQL queries to correlate metrics with code changes, runtime configuration flags (GODEBUG env vars to enable GC logging), and cost warnings (when you're hitting performance tax). Use this when production benchmarks look good but real traffic behaves differently.
|
||||
|
||||
- **[Prometheus Go Metrics Reference](./references/prometheus-go-metrics.md)** — Complete listing of Go runtime metrics actually exposed as Prometheus metrics by `prometheus/client_golang`. Covers 30 default metrics, 40+ optional metrics (Go 1.17+), process metrics, and common PromQL queries. Distinguishes between `runtime/metrics` (Go internal data) and Prometheus metrics (what you scrape from `/metrics`). Use this when setting up monitoring dashboards or writing PromQL queries for production alerts.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-performance` skill for optimization patterns to apply after measuring ("if X bottleneck, apply Y")
|
||||
- → See `samber/cc-skills-golang@golang-troubleshooting` skill for pprof setup on running services (enable, secure, capture), Delve debugger, GODEBUG flags, root cause methodology
|
||||
- → See `samber/cc-skills-golang@golang-observability` skill for everyday always-on monitoring, continuous profiling (Pyroscope), distributed tracing (OpenTelemetry)
|
||||
- → See `samber/cc-skills-golang@golang-testing` skill for general testing practices
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,389 @@
|
||||
# benchstat Reference
|
||||
|
||||
`benchstat` computes statistical summaries and A/B comparisons of Go benchmark results. A single benchmark run tells you nothing about variance — `benchstat` tells you whether the difference between two runs is real or noise.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go install golang.org/x/perf/cmd/benchstat@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
benchstat [flags] inputs...
|
||||
```
|
||||
|
||||
Each input is a file containing `go test -bench` output. Optionally label inputs with `label=path` syntax.
|
||||
|
||||
## Basic Workflow
|
||||
|
||||
### Step 0: Write benchmarks
|
||||
|
||||
Use the standard Go benchmark function signature in `*_test.go`:
|
||||
|
||||
### Step 1: Measure baseline
|
||||
|
||||
Run benchmarks with `-count=10` or more. Each run produces one data point — you need at least 10 to compute a meaningful confidence interval:
|
||||
|
||||
```bash
|
||||
go test -run='^$' -bench=BenchmarkParse -benchmem -count=10 ./pkg/parser | tee old.txt
|
||||
```
|
||||
|
||||
`-run='^$'` skips unit tests so only benchmarks run — avoids wasting time on tests during measurement sessions.
|
||||
|
||||
### Step 2: Make your change
|
||||
|
||||
Edit the code you want to optimize.
|
||||
|
||||
### Step 3: Measure again
|
||||
|
||||
Same command, same flags, same machine, same load conditions:
|
||||
|
||||
```bash
|
||||
go test -run='^$' -bench=BenchmarkParse -benchmem -count=10 ./pkg/parser | tee new.txt
|
||||
```
|
||||
|
||||
### Step 4: Compare
|
||||
|
||||
```bash
|
||||
benchstat old.txt new.txt
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: myapp/pkg/parser
|
||||
cpu: AMD Ryzen 9 5950X 16-Core Processor
|
||||
│ old.txt │ new.txt │
|
||||
│ sec/op │ sec/op vs base │
|
||||
Parse-32 4.592µ ± 2% 3.041µ ± 1% -33.78% (p=0.000 n=10)
|
||||
|
||||
│ old.txt │ new.txt │
|
||||
│ B/op │ B/op vs base │
|
||||
Parse-32 1.024Ki ± 0% 0.512Ki ± 0% -50.00% (p=0.000 n=10)
|
||||
|
||||
│ old.txt │ new.txt │
|
||||
│ allocs/op │ allocs/op vs base │
|
||||
Parse-32 12.00 ± 0% 6.000 ± 0% -50.00% (p=0.000 n=10)
|
||||
```
|
||||
|
||||
## Reading the Output
|
||||
|
||||
| Element | Meaning | What to look for |
|
||||
| --- | --- | --- |
|
||||
| **median** (e.g., `4.592µ`) | Central value across runs — more robust than mean because outliers don't skew it | The reference number for this benchmark |
|
||||
| **± N%** (e.g., `± 2%`) | Half-width of the 95% confidence interval as a percentage of the median | Low (≤2%) = stable measurement. High (>5%) = noisy — investigate noise sources before trusting results |
|
||||
| **vs base** (e.g., `-33.78%`) | Percentage change from the first input (base) to subsequent inputs | Negative = faster/smaller. Positive = slower/larger |
|
||||
| **p=N** (e.g., `p=0.000`) | p-value from Mann-Whitney U-test (non-parametric) | <0.05 = statistically significant. ≥0.05 = difference could be noise |
|
||||
| **n=N** (e.g., `n=10`) | Number of samples used in the comparison | Should match your `-count`. Lower means some samples were filtered as outliers |
|
||||
| **`~`** | No statistically significant difference detected | Do NOT claim improvement — the change might be zero |
|
||||
| **geomean** row | Geometric mean of changes across all benchmarks in the table | Overall proportional change; useful when comparing many benchmarks at once |
|
||||
|
||||
### Unit normalization
|
||||
|
||||
benchstat automatically normalizes units for display:
|
||||
|
||||
- `ns/op` → displayed as `sec/op` (with µ, m prefixes) to avoid nonsensical `µns/op`
|
||||
- `MB/s` → displayed as `B/s` (with K, M, G prefixes)
|
||||
|
||||
### When the `~` symbol appears
|
||||
|
||||
```
|
||||
Parse-32 4.592µ ± 8% 4.481µ ± 7% ~ (p=0.089 n=10)
|
||||
```
|
||||
|
||||
This means benchstat cannot distinguish the difference from random noise. The wide confidence intervals (±8%, ±7%) overlap. Do not claim improvement. Options:
|
||||
|
||||
- Increase `-count` to 20+ (narrower CI may reveal a real difference)
|
||||
- Reduce noise sources (close applications, plug in power, use dedicated machine)
|
||||
- Accept that the change has no measurable effect on this benchmark
|
||||
|
||||
## Flags Reference
|
||||
|
||||
### Projection flags
|
||||
|
||||
These flags control how benchmark results are grouped into tables, rows, and columns.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `-table KEYS` | `.config` | Group results into separate tables by these keys |
|
||||
| `-row KEYS` | `.fullname` | Group results into table rows by these keys |
|
||||
| `-col KEYS` | `.file` | Compare across columns with different values of these keys |
|
||||
| `-ignore KEYS` | (none) | Omit keys from grouping — suppresses "benchmarks vary" warnings |
|
||||
|
||||
**Available keys:**
|
||||
|
||||
| Key | Meaning | Example value |
|
||||
| --- | --- | --- |
|
||||
| `.name` | Base benchmark name (without sub-benchmark config) | `Parse` from `BenchmarkParse/size=4k-16` |
|
||||
| `.fullname` | Full name including sub-benchmark configuration | `Parse/size=4k-16` |
|
||||
| `.file` | Input file name or custom label | `old.txt` or `baseline` |
|
||||
| `.config` | All file-level configuration keys combined | `goos/goarch/pkg/cpu` |
|
||||
| `.unit` | Metric unit name | `sec/op`, `B/op`, `allocs/op` |
|
||||
| `/{name-key}` | Per-benchmark sub-name key | `/size` extracts `4k` from `Parse/size=4k` |
|
||||
| `/gomaxprocs` | GOMAXPROCS value — recognizes both `/gomaxprocs=N` and the `-N` suffix convention | `16` from `Parse-16` |
|
||||
| `goos` | Operating system (from benchmark output header) | `linux`, `darwin` |
|
||||
| `goarch` | Architecture (from benchmark output header) | `amd64`, `arm64` |
|
||||
| `pkg` | Package path (from benchmark output header) | `myapp/pkg/parser` |
|
||||
| `cpu` | CPU model (from benchmark output header) | `AMD Ryzen 9 5950X` |
|
||||
|
||||
**Sort order modifiers** — append to any key:
|
||||
|
||||
| Modifier | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `@alpha` | Alphabetic sort | `/format@alpha` |
|
||||
| `@num` | Numeric sort (understands prefixes: 2k, 1Mi) | `/size@num` |
|
||||
| `@(val1 val2 ...)` | Fixed order + filter (only listed values, in this order) | `/format@(gob json)` |
|
||||
|
||||
### Filter flag
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `-filter EXPR` | Filter which benchmarks are processed before grouping and comparison |
|
||||
|
||||
See [Filter Expression Syntax](#filter-expression-syntax) below for full details.
|
||||
|
||||
### Input labeling
|
||||
|
||||
Not a flag but a syntax feature — label input files for clearer column headers:
|
||||
|
||||
```bash
|
||||
# Default: file names become column headers
|
||||
benchstat old.txt new.txt
|
||||
|
||||
# Custom labels
|
||||
benchstat baseline=old.txt optimized=new.txt
|
||||
|
||||
# Multiple versions
|
||||
benchstat v1=v1.txt v2=v2.txt v3=v3.txt
|
||||
```
|
||||
|
||||
The first input is always the **base** for comparison. All subsequent inputs are compared against it.
|
||||
|
||||
## Filter Expression Syntax
|
||||
|
||||
Filters select which benchmarks to include before grouping and comparison. The syntax is:
|
||||
|
||||
### Matching operators
|
||||
|
||||
| Pattern | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `key:value` | Exact match | `goos:linux` |
|
||||
| `key:"value"` | Exact match with quoted value (allows spaces, special chars) | `pkg:"github.com/user/repo"` |
|
||||
| `key:/regexp/` | Regular expression match (Go regexp syntax) | `.name:/Parse\|Encode/` |
|
||||
| `key:(val1 OR val2)` | Match any of the listed values | `goos:(linux OR darwin)` |
|
||||
| `*` | Match everything (all benchmarks) | `*` |
|
||||
|
||||
### Logical operators
|
||||
|
||||
| Operator | Meaning | Example |
|
||||
| --- | --- | --- |
|
||||
| `x y` | AND — both must match (implicit) | `goos:linux goarch:amd64` |
|
||||
| `x AND y` | AND — explicit form | `goos:linux AND goarch:amd64` |
|
||||
| `x OR y` | OR — either must match | `goos:linux OR goos:darwin` |
|
||||
| `-x` | NOT — must not match | `-goos:windows` |
|
||||
| `(...)` | Grouping / subexpression | `(goos:linux OR goos:darwin) -pkg:/internal/` |
|
||||
|
||||
### Filter key types
|
||||
|
||||
| Key | What it matches | Example |
|
||||
| --- | --- | --- |
|
||||
| `.name` | Base benchmark name | `.name:Parse` |
|
||||
| `.fullname` | Full name with sub-benchmark config | `.fullname:/Parse\/size=4k/` |
|
||||
| `/{name-key}` | Sub-benchmark parameter | `/size:4k` |
|
||||
| `/gomaxprocs` | GOMAXPROCS value | `/gomaxprocs:16` |
|
||||
| `.file` | Input file label | `.file:old.txt` |
|
||||
| `.unit` | Metric unit | `.unit:sec/op` |
|
||||
| `goos` | OS from header | `goos:linux` |
|
||||
| `goarch` | Architecture from header | `goarch:amd64` |
|
||||
| `pkg` | Package from header | `pkg:/parser/` |
|
||||
|
||||
### Filter examples
|
||||
|
||||
```bash
|
||||
# Only Parse benchmarks
|
||||
benchstat -filter '.name:Parse' old.txt new.txt
|
||||
|
||||
# Only benchmarks with size=4096 sub-parameter
|
||||
benchstat -filter '/size:4096' old.txt new.txt
|
||||
|
||||
# Exclude Parallel benchmarks
|
||||
benchstat -filter '-.name:/Parallel/' old.txt new.txt
|
||||
|
||||
# Linux amd64 only
|
||||
benchstat -filter 'goos:linux goarch:amd64' old.txt new.txt
|
||||
|
||||
# Multiple benchmark names
|
||||
benchstat -filter '.name:(Parse OR Encode OR Decode)' old.txt new.txt
|
||||
|
||||
# Complex: Linux or Darwin, not internal packages, only sec/op metric
|
||||
benchstat -filter '(goos:linux OR goos:darwin) -pkg:/internal/ .unit:sec/op' old.txt new.txt
|
||||
|
||||
# Regex: all benchmarks starting with Bench
|
||||
benchstat -filter '.name:/^Bench/' old.txt new.txt
|
||||
```
|
||||
|
||||
## Projection Examples
|
||||
|
||||
### Default: before/after file comparison
|
||||
|
||||
```bash
|
||||
benchstat old.txt new.txt
|
||||
# Equivalent to:
|
||||
benchstat -table .config -row .fullname -col .file old.txt new.txt
|
||||
```
|
||||
|
||||
Creates one row per benchmark, one column per file.
|
||||
|
||||
### Compare sub-benchmark parameters within a single file
|
||||
|
||||
When a single benchmark file contains multiple sub-benchmarks (e.g., `BenchmarkEncode/format=json` and `BenchmarkEncode/format=gob`):
|
||||
|
||||
```bash
|
||||
benchstat -col /format bench.txt
|
||||
```
|
||||
|
||||
Creates columns for each value of `/format`, comparing them against each other.
|
||||
|
||||
### Simplify rows to base name only
|
||||
|
||||
```bash
|
||||
benchstat -col /format -row .name bench.txt
|
||||
```
|
||||
|
||||
Strips sub-benchmark configuration from row names, making the table more compact.
|
||||
|
||||
### Control column order
|
||||
|
||||
```bash
|
||||
# Force gob first, then json (instead of alphabetical)
|
||||
benchstat -col '/format@(gob json)' bench.txt
|
||||
```
|
||||
|
||||
### Group by GOMAXPROCS
|
||||
|
||||
```bash
|
||||
benchstat -col /gomaxprocs bench.txt
|
||||
```
|
||||
|
||||
Compares performance across different GOMAXPROCS values within the same file.
|
||||
|
||||
### Separate tables per package
|
||||
|
||||
```bash
|
||||
benchstat -table pkg old.txt new.txt
|
||||
```
|
||||
|
||||
Creates one table per package — useful when comparing benchmarks across multiple packages.
|
||||
|
||||
### Ignore a dimension
|
||||
|
||||
```bash
|
||||
# Suppress "benchmarks vary in /gomaxprocs" warning
|
||||
benchstat -row .name -ignore /gomaxprocs bench.txt
|
||||
```
|
||||
|
||||
### Compare three versions
|
||||
|
||||
```bash
|
||||
benchstat v1=v1.txt v2=v2.txt v3=v3.txt
|
||||
```
|
||||
|
||||
Shows v2 vs v1 and v3 vs v1 (first input is always the base).
|
||||
|
||||
### Cross-dimensional comparison
|
||||
|
||||
```bash
|
||||
# Rows = benchmark name, columns = OS, separate tables per architecture
|
||||
benchstat -row .name -col goos -table goarch results.txt
|
||||
```
|
||||
|
||||
## Unit Metadata
|
||||
|
||||
### `assume=exact`
|
||||
|
||||
For metrics that should not vary between runs (e.g., binary size, generated code size):
|
||||
|
||||
```
|
||||
BenchmarkSize 1 42 custom-bytes/op
|
||||
Unit custom-bytes/op assume=exact
|
||||
```
|
||||
|
||||
With `assume=exact`:
|
||||
|
||||
- Non-parametric statistics are disabled
|
||||
- benchstat warns if measured values vary
|
||||
- Shows comparisons even with a single before/after measurement (no `-count` needed)
|
||||
|
||||
### `assume=nothing` (default)
|
||||
|
||||
Standard behavior — uses non-parametric statistics (median + Mann-Whitney U-test). Requires multiple samples.
|
||||
|
||||
## Interleaving Runs
|
||||
|
||||
Sequential runs (all old, then all new) are vulnerable to **systematic bias** — thermal throttling builds up over time, background processes come and go, CPU frequency scaling adapts. Interleaving reduces this:
|
||||
|
||||
```bash
|
||||
# Pre-compile both versions to avoid measuring compilation time
|
||||
go test -c -o old.test ./pkg/parser
|
||||
# ... make your change ...
|
||||
go test -c -o new.test ./pkg/parser
|
||||
|
||||
# Interleave runs — alternating reduces systematic bias
|
||||
for i in $(seq 1 10); do
|
||||
./old.test -test.bench=BenchmarkParse -test.benchmem >> old.txt
|
||||
./new.test -test.bench=BenchmarkParse -test.benchmem >> new.txt
|
||||
done
|
||||
|
||||
benchstat old.txt new.txt
|
||||
```
|
||||
|
||||
Pre-compiling with `go test -c` is critical — without it, each `go test -bench` invocation includes compilation time, which varies and contaminates results.
|
||||
|
||||
## How Many Runs?
|
||||
|
||||
| Scenario | Minimum `-count` | Why |
|
||||
| --- | --- | --- |
|
||||
| Quick local check | 6 | Enough for a rough confidence interval; fast feedback loop |
|
||||
| Pre-merge comparison | 10 | Standard for detecting moderate (>5%) changes with confidence |
|
||||
| Detecting small changes (<5%) | 20-30 | More samples narrow the CI; needed when signal is small relative to noise |
|
||||
| Noisy CI environment | 20+ | Shared CI runners have higher variance; more runs compensate |
|
||||
|
||||
**Never "retry until significant"** — rerunning benchmarks until `~` goes away introduces selection bias (p-hacking). If 10 runs show `~`, the change is probably not meaningful. Increase run count **once** and accept the result.
|
||||
|
||||
At α=0.05, expect ~5% of benchmarks to randomly report significance with no real change (false positives). This is normal — don't chase them.
|
||||
|
||||
## Single-File Summary
|
||||
|
||||
Analyze variance of a single run without comparison:
|
||||
|
||||
```bash
|
||||
benchstat bench.txt
|
||||
```
|
||||
|
||||
Shows median and confidence interval for each benchmark. Use to:
|
||||
|
||||
- Check measurement stability before making code changes
|
||||
- Identify noisy benchmarks that need more runs or better isolation
|
||||
- Get a quick summary of current performance
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | Why it's wrong | Fix |
|
||||
| --- | --- | --- |
|
||||
| `-count=1` | Single run has no variance information; benchstat can't compute confidence | Always use `-count=6` minimum, prefer `-count=10` |
|
||||
| Running on a laptop on battery | CPU throttles to save power; variance explodes | Plug in, disable power saving, or use a desktop/server |
|
||||
| Running with browser/IDE open | Background processes steal CPU cycles; adds noise | Close unnecessary applications, or accept wider CIs |
|
||||
| Rerunning until `~` disappears | Selection bias (p-hacking) — you're cherry-picking runs that showed improvement | Run once with high `-count`, accept the result |
|
||||
| Comparing across machines | Different CPUs, memory, OS = incomparable baselines | Same machine, same conditions, both runs |
|
||||
| Not interleaving | Systematic bias from thermal throttling, background load drift | Pre-compile both versions with `go test -c`, alternate runs |
|
||||
| Measuring compilation time | `go test -bench` compiles first; startup overhead varies | Pre-compile with `go test -c`, run the binary directly |
|
||||
| Ignoring wide CI (± >5%) | Results look significant but variance is too high to be trustworthy | Fix the noise first, then compare; or increase `-count` |
|
||||
| Comparing different `-count` values | Unequal sample sizes bias the comparison | Use the same `-count` for all inputs |
|
||||
|
||||
## benchstat in CI
|
||||
|
||||
See [CI Regression Detection](./ci-regression.md) for integrating benchstat comparisons into CI pipelines with benchdiff, cob, and gobenchdata.
|
||||
@@ -0,0 +1,285 @@
|
||||
# CI Benchmark Regression Detection
|
||||
|
||||
> **Run these tools in CI only, not on local machines.** Local benchmark results are noisy due to background processes, thermal throttling, and inconsistent CPU frequency — regressions detected locally are unreliable and waste developer time. Even shared CI runners can produce significant variance (5-10%); use statistical methods like `benchstat` with multiple iterations and relative comparisons to filter noise, or invest in dedicated benchmark runners for critical paths.
|
||||
|
||||
## benchdiff
|
||||
|
||||
Runs Go benchmarks on two git refs and uses `benchstat` to display deltas. Caches results for non-worktree refs so re-runs are fast. Prevents macOS sleep during benchmarks.
|
||||
|
||||
```bash
|
||||
go install filippo.io/mostly-harmless/benchdiff@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
# Compare current worktree against HEAD (default)
|
||||
benchdiff -- -benchmem
|
||||
|
||||
# Compare two specific refs
|
||||
benchdiff -base-ref main -head-ref feature-branch
|
||||
|
||||
# Compare against a specific commit or tag
|
||||
benchdiff -base-ref v1.2.0
|
||||
|
||||
# Pass extra flags to go test — everything after -- goes to go test
|
||||
benchdiff -- -benchmem -count=10 -benchtime=3s
|
||||
|
||||
# Filter to specific benchmarks
|
||||
benchdiff -- -benchmem -count=10 -bench=BenchmarkParse
|
||||
|
||||
# Target a specific package
|
||||
benchdiff -- -benchmem -count=10 ./pkg/parser/...
|
||||
|
||||
# Clear cached results (useful after rebasing or when cache is stale)
|
||||
benchdiff -clear-cache
|
||||
|
||||
# Combine: compare main with 10 iterations, filtered to critical benchmarks
|
||||
benchdiff -base-ref main -- -benchmem -count=10 -bench='BenchmarkParse|BenchmarkEncode'
|
||||
```
|
||||
|
||||
Best for: quick PR-to-base comparisons in git-based workflows. Leverages `benchstat` for statistical rigor and caches non-worktree refs so re-runs only re-measure the worktree.
|
||||
|
||||
## cob
|
||||
|
||||
Compares benchmarks between HEAD and HEAD~1, failing the CI job if performance degrades beyond a configurable threshold (default 20%).
|
||||
|
||||
```bash
|
||||
go install github.com/knqyf263/cob@latest
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run with default 20% threshold — compares HEAD vs HEAD~1
|
||||
cob
|
||||
|
||||
# Stricter threshold for critical paths (10% regression = failure)
|
||||
cob -threshold 10
|
||||
|
||||
# Compare against a specific base commit
|
||||
cob -base main
|
||||
|
||||
# Only report regressions (ignore improvements)
|
||||
cob -only-degression
|
||||
|
||||
# Choose which metrics to compare (default: ns/op,B/op)
|
||||
cob -compare "ns/op,B/op,allocs/op"
|
||||
|
||||
# Custom go test arguments
|
||||
cob -bench-args "test -run '^$' -bench BenchmarkParse -benchmem ./pkg/parser/..."
|
||||
|
||||
# Increase benchmark duration for more stable results
|
||||
cob -bench-args "test -run '^$' -bench . -benchmem -benchtime=3s ./..."
|
||||
|
||||
# Skip cob for a specific commit: include [skip cob] in commit message
|
||||
```
|
||||
|
||||
**Caution:** `cob` uses `git reset` internally, which can cause data loss if uncommitted changes exist. Always commit your work before running. Additionally, `cob` requires all benchmarks to pass; it skips CI gating if any benchmark fails. For safety, run only in CI pipelines, not locally. Note that `cob` compares single runs without `benchstat`-style statistics, making it more susceptible to noise than `benchdiff`.
|
||||
|
||||
Best for: simple post-commit regression gating in CI where statistical rigor is less critical than fast feedback.
|
||||
|
||||
## gobenchdata
|
||||
|
||||
GitHub Action + CLI that collects benchmark results, publishes to gh-pages as JSON, and visualizes with an interactive web dashboard. Shows performance trends over time.
|
||||
|
||||
```bash
|
||||
go install go.bobheadxi.dev/gobenchdata@latest
|
||||
```
|
||||
|
||||
### CLI commands
|
||||
|
||||
```bash
|
||||
# Parse go test -bench output to JSON
|
||||
go test -bench=. -benchmem -count=5 ./... | gobenchdata --json bench.json
|
||||
|
||||
# Parse from a file
|
||||
gobenchdata --json bench.json < bench.txt
|
||||
|
||||
# Add a tag to the benchmark run (e.g., git commit)
|
||||
gobenchdata --json bench.json --tag "$(git rev-parse --short HEAD)" < bench.txt
|
||||
|
||||
# Evaluate regression checks against a checks config
|
||||
gobenchdata checks eval bench.txt --checks-config .gobenchdata-checks.yml
|
||||
|
||||
# Generate the web dashboard app (static Vue.js site)
|
||||
gobenchdata web generate ./dashboard-app
|
||||
|
||||
# Serve the dashboard locally for preview
|
||||
gobenchdata web serve ./dashboard-app
|
||||
|
||||
# Merge multiple benchmark JSON files
|
||||
gobenchdata merge old-bench.json new-bench.json > combined.json
|
||||
|
||||
# Prune old entries (keep last 30 runs)
|
||||
gobenchdata prune --count 30 bench.json
|
||||
```
|
||||
|
||||
### GitHub Action setup
|
||||
|
||||
```yaml
|
||||
# .github/workflows/benchmark.yml
|
||||
name: Benchmark
|
||||
on: [push]
|
||||
jobs:
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem -count=5 ./... | tee bench.txt
|
||||
- uses: bobheadxi/gobenchdata@v1
|
||||
with:
|
||||
PRUNE_COUNT: 30
|
||||
GO_TEST_PKGS: ./...
|
||||
BENCHMARKS_OUT: bench.txt
|
||||
PUBLISH: true
|
||||
PUBLISH_BRANCH: gh-pages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
### Regression gating on PRs
|
||||
|
||||
```yaml
|
||||
- name: Check for regressions
|
||||
run: gobenchdata checks eval bench.txt --checks-config .gobenchdata-checks.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .gobenchdata-checks.yml
|
||||
checks:
|
||||
- name: "No major regressions"
|
||||
package: ./...
|
||||
benchmarks: [".*"]
|
||||
thresholds:
|
||||
- metric: NsPerOp
|
||||
max: 1.2 # fail if >20% slower
|
||||
- metric: AllocedBytesPerOp
|
||||
max: 1.3 # fail if >30% more allocations
|
||||
- name: "Critical path stability"
|
||||
package: ./pkg/parser
|
||||
benchmarks: ["BenchmarkParse.*"]
|
||||
thresholds:
|
||||
- metric: NsPerOp
|
||||
max: 1.1 # stricter: fail if >10% slower
|
||||
```
|
||||
|
||||
### Dashboard configuration
|
||||
|
||||
```yaml
|
||||
# gobenchdata-web.yml — configure the Vue.js dashboard
|
||||
title: "My Project Benchmarks"
|
||||
description: "Performance tracking dashboard"
|
||||
chartGroups:
|
||||
- name: Parser
|
||||
charts:
|
||||
- name: Parse Performance
|
||||
package: myapp/pkg/parser
|
||||
benchmarks: ["BenchmarkParse.*"]
|
||||
metrics: [NsPerOp, AllocedBytesPerOp, AllocsPerOp]
|
||||
- name: Encoding
|
||||
charts:
|
||||
- name: Encode/Decode
|
||||
package: myapp/pkg/encoding
|
||||
benchmarks: ["Benchmark(Encode|Decode).*"]
|
||||
metrics: [NsPerOp, MBPerS]
|
||||
```
|
||||
|
||||
Best for: long-term trend tracking and visualization; complements benchdiff/cob for immediate gating.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Tool | Statistical rigor | Dashboard | Best for |
|
||||
| --- | --- | --- | --- |
|
||||
| **benchdiff** | High (uses benchstat) | No | Local dev + CI PR comparisons |
|
||||
| **cob** | Low (single comparison) | No | Quick CI gate, simple setup |
|
||||
| **gobenchdata** | Medium (configurable checks) | Yes (Vue.js on gh-pages) | Long-term trend tracking |
|
||||
| **benchstat** (raw) | High | No (CSV export) | Maximum control, custom workflows |
|
||||
|
||||
## Noisy Neighbor Mitigation
|
||||
|
||||
Cloud CI environments share hardware with other jobs. Expect 5-10% variance even on quiet machines.
|
||||
|
||||
### Why CI benchmarks are noisy
|
||||
|
||||
- **Shared CPU/memory** — other CI jobs compete for resources
|
||||
- **Thermal throttling** — sustained load reduces clock speed
|
||||
- **Different hardware across runs** — CI runners may have different specs
|
||||
- **Kernel scheduling** — context switches add unpredictable latency
|
||||
- **Disk I/O contention** — shared storage affects I/O-bound benchmarks
|
||||
|
||||
### Strategies
|
||||
|
||||
**Statistical rigor** — run with `-count=10` or more and compare with `benchstat`. A single run is meaningless. benchstat's p-value test filters out noise-induced false positives.
|
||||
|
||||
**Relative comparison in same job** — run both base and head benchmarks in the same CI job on the same machine, rather than comparing against historical absolute values. This cancels out machine-to-machine variation. Tools like `benchdiff` do this automatically by checking out both git refs.
|
||||
|
||||
**Dedicated benchmark runners** — for critical path benchmarks, use self-hosted CI runners with no other workloads. This eliminates noisy neighbors entirely but costs more infrastructure.
|
||||
|
||||
**Conservative thresholds** — set regression thresholds higher on shared CI (20%+) than on dedicated runners (10%). Tight thresholds on noisy environments produce false positives that erode trust. GitHub-hosted runners show ~2-3% coefficient of variation in the best case; to guarantee <1% false positive rate, you need a 7%+ performance gate.
|
||||
|
||||
**Never "retry until pass"** — rerunning benchmarks until they pass introduces selection bias. If a benchmark is flaky, fix the noise source (more iterations, dedicated runner, wider threshold) rather than retrying.
|
||||
|
||||
## System Tuning for Self-Hosted Runners
|
||||
|
||||
> **WARNING: These commands modify kernel and CPU settings. Apply them ONLY on dedicated CI runners, NEVER on developer machines or shared servers.**
|
||||
|
||||
When you control the CI hardware, these settings dramatically reduce benchmark variance by eliminating the main sources of non-determinism.
|
||||
|
||||
### Disable CPU frequency scaling
|
||||
|
||||
Variable CPU frequency makes benchmark times meaningless — the same code runs at different speeds depending on load and thermals:
|
||||
|
||||
```bash
|
||||
# Set all CPUs to "performance" governor (fixed maximum frequency)
|
||||
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||
```
|
||||
|
||||
### Disable Turbo Boost
|
||||
|
||||
Turbo Boost temporarily increases clock speed but throttles under sustained load, creating variance between the start and end of a benchmark run:
|
||||
|
||||
```bash
|
||||
# Intel
|
||||
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo
|
||||
|
||||
# AMD
|
||||
echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost
|
||||
```
|
||||
|
||||
### Pin benchmarks to specific CPU cores
|
||||
|
||||
Prevents the OS from migrating the benchmark process across cores, which causes cache thrashing (L1/L2 caches are per-core):
|
||||
|
||||
```bash
|
||||
# Pin to cores 2 and 3 (leave cores 0-1 for OS and other processes)
|
||||
taskset -c 2,3 go test -bench=. -count=10 ./...
|
||||
```
|
||||
|
||||
### Disable SMT (Hyper-Threading)
|
||||
|
||||
SMT shares execution units between logical cores on the same physical core, causing unpredictable contention:
|
||||
|
||||
```bash
|
||||
# Disable SMT system-wide
|
||||
echo off | sudo tee /sys/devices/system/cpu/smt/control
|
||||
|
||||
# Or disable individual sibling cores (check /sys/devices/system/cpu/cpu*/topology/thread_siblings_list)
|
||||
echo 0 | sudo tee /sys/devices/system/cpu/cpu1/online # if cpu0 and cpu1 are siblings
|
||||
```
|
||||
|
||||
### Combined CI setup script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# benchmark-setup.sh — run on self-hosted CI runner before benchmarks
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Configuring CPU for stable benchmarks ==="
|
||||
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||
echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null || true
|
||||
echo off | sudo tee /sys/devices/system/cpu/smt/control 2>/dev/null || true
|
||||
|
||||
echo "=== Running benchmarks on isolated cores ==="
|
||||
taskset -c 2,3 go test -bench=. -benchmem -count=10 ./... | tee bench.txt
|
||||
```
|
||||
@@ -0,0 +1,231 @@
|
||||
# Compiler Analysis Reference
|
||||
|
||||
The Go compiler provides diagnostic flags that reveal optimization decisions — escape analysis, inlining, SSA intermediate representation, and generated assembly. These are essential for understanding **why** a function allocates or **why** the compiler won't inline it.
|
||||
|
||||
Use compiler diagnostics when pprof shows a hot function and you need to understand the compiler's decisions about that function. These tools are free (no runtime overhead) — they analyze at compile time.
|
||||
|
||||
## Escape Analysis
|
||||
|
||||
Escape analysis determines whether a variable can live on the stack (cheap — freed when the function returns) or must be allocated on the heap (expensive — requires GC). "Moved to heap" means the compiler decided the variable might outlive the function.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Show escape decisions — one line per escaped variable
|
||||
go build -gcflags="-m" ./... 2>&1 | grep "escapes to heap"
|
||||
go build -gcflags="-m" ./... 2>&1 | grep "moved to heap"
|
||||
|
||||
# Verbose mode — shows the reason for each escape decision
|
||||
go build -gcflags="-m -m" ./...
|
||||
|
||||
# Filter to a specific package
|
||||
go build -gcflags="-m" ./pkg/parser 2>&1 | grep "escapes"
|
||||
|
||||
# Filter to a specific file
|
||||
go build -gcflags="-m" ./pkg/parser/parse.go 2>&1
|
||||
|
||||
# Apply to all dependencies too (usually too noisy, but useful for debugging)
|
||||
go build -gcflags="all=-m" ./...
|
||||
|
||||
# Combine with grep for a specific function
|
||||
go build -gcflags="-m" ./pkg/parser 2>&1 | grep "Parse"
|
||||
|
||||
# Combine with grep to see what stays on the stack (does NOT escape)
|
||||
go build -gcflags="-m" ./pkg/parser 2>&1 | grep "does not escape"
|
||||
```
|
||||
|
||||
### Reading the output
|
||||
|
||||
```
|
||||
./pkg/parser/parse.go:15:6: can inline Parse
|
||||
./pkg/parser/parse.go:42:13: &result escapes to heap
|
||||
./pkg/parser/parse.go:42:13: flow: ~r0 = &result:
|
||||
./pkg/parser/parse.go:42:13: from &result (address-of) at ./pkg/parser/parse.go:42:13
|
||||
./pkg/parser/parse.go:42:13: from return &result (return) at ./pkg/parser/parse.go:42:6
|
||||
```
|
||||
|
||||
The `-m -m` (verbose) output shows the **escape chain** — why the compiler decided the variable escapes. In this example: `result` has its address taken (`&result`), and that pointer is returned, so `result` must survive beyond the function — it escapes to heap.
|
||||
|
||||
### Common escape causes
|
||||
|
||||
| Cause | Example | Why it escapes |
|
||||
| --- | --- | --- |
|
||||
| **Returning a pointer to a local** | `return &result` | The local must outlive the function call — caller holds a reference |
|
||||
| **Interface boxing** | `var x any = myStruct` | Concrete type stored in `interface{}` allocates a copy on the heap |
|
||||
| **Closure capturing a local** | `go func() { use(localVar) }()` | The goroutine may run after the enclosing function returns |
|
||||
| **Slice append beyond capacity** | `s = append(s, item)` when len == cap | Triggers a new backing array allocation on the heap |
|
||||
| **Passing pointer to unanalyzable function** | `json.Marshal(&data)` | Compiler can't prove the pointer won't be retained across package boundary |
|
||||
| **Storing in a struct field that escapes** | `obj.Field = &local` | If `obj` is heap-allocated, anything it points to must also be on the heap |
|
||||
| **fmt.Sprintf and friends** | `fmt.Sprintf("%d", n)` | Arguments are boxed into `any` (interface boxing) + result string is heap-allocated |
|
||||
| **Sending pointer on channel** | `ch <- &data` | Channel receiver may be a different goroutine with a different lifetime |
|
||||
|
||||
**Not all escapes are problems.** Only investigate escapes in functions that pprof identifies as allocation-heavy. A function called once at startup can escape freely.
|
||||
|
||||
## Inlining Decisions
|
||||
|
||||
Inlining replaces a function call with the function body at the call site. This eliminates call overhead and enables further optimizations (escape analysis improves, dead code elimination, constant folding). Functions that aren't inlined in hot paths may benefit from simplification.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Show which functions CAN be inlined
|
||||
go build -gcflags="-m" ./... 2>&1 | grep "can inline"
|
||||
|
||||
# Show which functions CANNOT be inlined (with the reason)
|
||||
go build -gcflags="-m" ./... 2>&1 | grep "cannot inline"
|
||||
|
||||
# Show inlining decisions for a specific package
|
||||
go build -gcflags="-m" ./pkg/handler 2>&1 | grep "inline"
|
||||
|
||||
# Show where inlining was actually applied (function was inlined into caller)
|
||||
go build -gcflags="-m" ./... 2>&1 | grep "inlining call to"
|
||||
|
||||
# Verbose mode — shows the cost budget and why inlining was blocked
|
||||
go build -gcflags="-m -m" ./... 2>&1 | grep "inline"
|
||||
|
||||
# Filter to a specific function
|
||||
go build -gcflags="-m" ./pkg/handler 2>&1 | grep "HandleRequest"
|
||||
|
||||
# Show both inlining and escape analysis together (they interact)
|
||||
go build -gcflags="-m" ./pkg/handler 2>&1 | grep -E "(inline|escape|moved to heap)"
|
||||
```
|
||||
|
||||
### Reading the output
|
||||
|
||||
```
|
||||
./pkg/handler/handler.go:20:6: can inline validateInput
|
||||
./pkg/handler/handler.go:35:6: cannot inline HandleRequest: function too complex: cost 120 exceeds budget 80
|
||||
./pkg/handler/handler.go:42:19: inlining call to validateInput
|
||||
```
|
||||
|
||||
The inline cost budget is 80 (as of Go 1.22+). Functions with higher cost (more AST nodes, complex control flow) are not inlined.
|
||||
|
||||
### Common inlining blockers
|
||||
|
||||
| Blocker | Why it prevents inlining | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| **Function too complex** | Body cost exceeds budget (80) | Split into smaller functions; extract the cold path |
|
||||
| **`defer` statement** | Adds cleanup code that complicates inlining | Remove `defer` from tiny hot functions; call cleanup directly |
|
||||
| **`recover()` call** | Forces stack frame preservation | Move `recover()` to a wrapper function |
|
||||
| **`go` statement** | Goroutine launch has implicit complexity | Extract goroutine body into a separate function |
|
||||
| **Type switch / interface method call** | Dynamic dispatch can't be resolved at compile time | Use concrete types in hot paths |
|
||||
| **`select` statement** | Complex runtime interaction | Simplify channel patterns in hot functions |
|
||||
| **Large function body** | Many statements add up in cost | Break into smaller functions — the hot inner function may inline |
|
||||
|
||||
**Value receivers vs pointer receivers:** Value receivers enable full inlining of method chains. Pointer receivers add indirection that can block inlining for fluent APIs. Check with `-gcflags="-m"`.
|
||||
|
||||
## SSA Dump
|
||||
|
||||
The SSA (Static Single Assignment) dump shows the compiler's intermediate representation after each optimization pass — dead code elimination, bounds check removal, constant folding, register allocation. Use this when you need to understand exactly what the compiler generates.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Generate SSA dump for a specific function — creates ssa.html in current directory
|
||||
GOSSAFUNC=Parse go build ./pkg/parser
|
||||
# Open ssa.html in browser — shows each optimization pass side by side
|
||||
|
||||
# Generate for a method on a type
|
||||
GOSSAFUNC='(*Parser).Parse' go build ./pkg/parser
|
||||
|
||||
# Generate for a function in a specific package (when names collide)
|
||||
GOSSAFUNC=myapp/pkg/parser.Parse go build ./...
|
||||
|
||||
# Combine with a specific output directory
|
||||
GOSSAFUNC=Parse GOSSADIR=/tmp/ssa go build ./pkg/parser
|
||||
# Creates /tmp/ssa/ssa.html
|
||||
```
|
||||
|
||||
### Reading ssa.html
|
||||
|
||||
The HTML file shows the function's code at each compiler pass:
|
||||
|
||||
1. **Source** — original Go code
|
||||
2. **AST** — abstract syntax tree
|
||||
3. **Start** — initial SSA form
|
||||
4. **Opt** — after optimization passes (dead code, constant prop, bounds check elimination)
|
||||
5. **Lower** — architecture-specific lowering
|
||||
6. **Regalloc** — after register allocation
|
||||
7. **Genssa** — final generated code
|
||||
|
||||
Click on a value in any pass to highlight it across all passes — see how the compiler transforms it. Red values were eliminated (dead code). Green values are new (introduced by a pass).
|
||||
|
||||
**What to look for:**
|
||||
|
||||
- **Bounds checks remaining** — `IsInBounds` or `IsSliceInBounds` operations that weren't eliminated. Adding explicit bounds checks or using `_ = s[n-1]` hints can help
|
||||
- **Dead code not eliminated** — values computed but never used (should be eliminated; if not, check for side effects)
|
||||
- **Constant folding** — computations on constants should be resolved at compile time
|
||||
- **Register spills** — values moved to stack because not enough registers; indicates heavy register pressure
|
||||
|
||||
## Assembly Output
|
||||
|
||||
View the actual machine code the compiler generates. Use for verifying SIMD instructions, bounds checks, register allocation, and micro-optimization decisions.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Full assembly output for a package (very verbose)
|
||||
go build -gcflags="-S" ./pkg/parser 2>&1 | head -200
|
||||
|
||||
# Assembly for a specific function (grep for the function name)
|
||||
go build -gcflags="-S" ./pkg/parser 2>&1 | grep -A 50 '"".Parse'
|
||||
|
||||
# Assembly for all packages (including dependencies — very verbose)
|
||||
go build -gcflags="all=-S" ./... 2>&1 | grep -A 50 'myapp/pkg/parser.Parse'
|
||||
|
||||
# Disassemble a compiled binary (alternative to -gcflags="-S")
|
||||
go build -o myapp ./cmd/server
|
||||
go tool objdump -s Parse myapp
|
||||
|
||||
# Disassemble with source interleaving
|
||||
go tool objdump -S -s Parse myapp
|
||||
|
||||
# Disassemble a specific symbol
|
||||
go tool objdump -s 'myapp/pkg/parser.Parse' myapp
|
||||
|
||||
# Disassemble a specific text range (by address)
|
||||
go tool objdump -start 0x4a3b00 -end 0x4a3c00 myapp
|
||||
|
||||
# List all symbols in a binary
|
||||
go tool nm myapp | grep Parse
|
||||
|
||||
# Cross-compile and inspect assembly for a different architecture
|
||||
GOARCH=arm64 go build -gcflags="-S" ./pkg/parser 2>&1 | head -200
|
||||
```
|
||||
|
||||
### Reading assembly output
|
||||
|
||||
```asm
|
||||
"".Parse STEXT size=240 args=0x18 locals=0x48
|
||||
0x0000 MOVQ (TLS), CX ; goroutine stack check
|
||||
0x0009 LEAQ -64(SP), AX
|
||||
0x000e CMPQ AX, 16(CX) ; stack overflow check
|
||||
0x0012 JLS 228 ; jump to stack growth
|
||||
0x0018 SUBQ $72, SP ; allocate stack frame
|
||||
0x001c MOVQ BP, 64(SP) ; save base pointer
|
||||
0x0021 LEAQ 64(SP), BP ; set new base pointer
|
||||
; ... function body ...
|
||||
0x00e0 CALL runtime.makeslice(SB) ; heap allocation!
|
||||
```
|
||||
|
||||
**What to look for:**
|
||||
|
||||
- `CALL runtime.makeslice` or `CALL runtime.newobject` — heap allocations in the hot path
|
||||
- `CALL runtime.growslice` — slice capacity exceeded, triggering copy
|
||||
- `PCDATA` / `FUNCDATA` — GC metadata (ignore for performance analysis)
|
||||
- Bounds check sequences: `CMPQ` + `JCC` before array/slice access — can sometimes be eliminated
|
||||
- SIMD instructions: `VMOVDQU`, `VPSHUFB`, `VPADDB`, etc. — verify auto-vectorization or manual SIMD
|
||||
- `CALL runtime.morestack_noctxt` — stack growth (normal, but frequent calls indicate deep recursion)
|
||||
|
||||
### Comparing assembly before/after optimization
|
||||
|
||||
```bash
|
||||
# Before your change
|
||||
go build -gcflags="-S" ./pkg/parser 2>&1 > asm-before.txt
|
||||
|
||||
# After your change
|
||||
go build -gcflags="-S" ./pkg/parser 2>&1 > asm-after.txt
|
||||
|
||||
# Diff the assembly
|
||||
diff asm-before.txt asm-after.txt
|
||||
```
|
||||
@@ -0,0 +1,139 @@
|
||||
# Investigation Session Setup
|
||||
|
||||
Tools and techniques for **temporary deep-dive performance investigation** — not everyday monitoring. These are things you enable for hours or days while debugging a specific issue, then disable.
|
||||
|
||||
## Setting Up a Session
|
||||
|
||||
Before diving into profiles, set up the environment to collect high-resolution data:
|
||||
|
||||
1. **Reduce Prometheus scrape interval** to <=10s on the target instance (normally 15-30s). More data points during a short investigation window reveal patterns that 30s intervals miss. Revert after investigation.
|
||||
|
||||
2. **Enable pprof** via environment variable — no recompile needed:
|
||||
|
||||
```bash
|
||||
kubectl set env deployment/my-service PPROF_ENABLED=true
|
||||
kubectl rollout restart deployment/my-service
|
||||
```
|
||||
|
||||
3. **Enable continuous profiling** on the target instance only — not fleet-wide. Pyroscope/Parca on a single instance is manageable; on 50 replicas it overwhelms the backend.
|
||||
|
||||
```bash
|
||||
kubectl set env deployment/my-service PYROSCOPE_ENABLED=true
|
||||
kubectl rollout restart deployment/my-service
|
||||
```
|
||||
|
||||
4. **Enable debug logging** via env var if needed — but only on the target instance. Debug logging has significant throughput impact:
|
||||
```bash
|
||||
kubectl set env deployment/my-service LOG_LEVEL=debug
|
||||
kubectl rollout restart deployment/my-service
|
||||
```
|
||||
|
||||
**Key principle:** all costly debug features (pprof HTTP, continuous profiling, debug log level, trace collection) SHOULD be configurable via environment variables. This allows instant toggle without recompile. Design your application to support this from day one.
|
||||
|
||||
## Prometheus Go Runtime Collector
|
||||
|
||||
The `prometheus/client_golang` library automatically registers collectors that expose Go runtime metrics. These are invaluable during investigation sessions — they provide a time-series view of memory, GC, goroutines, and CPU that complements point-in-time profiles.
|
||||
|
||||
When using `prometheus/client_golang`, refer to the library's official documentation to verify collector setup and available options.
|
||||
|
||||
### Key Series
|
||||
|
||||
→ See [prometheus-go-metrics.md](./prometheus-go-metrics.md) for the **exhaustive reference** of all Go runtime metrics (verified from official sources). **Note:** runtime/metrics list varies by Go version — use `metrics.All()` at runtime for your specific Go version.
|
||||
|
||||
**Performance note:** `go_memstats_*` metrics internally call `runtime.ReadMemStats()`, which triggers a short stop-the-world pause. In Go 1.17+, the runtime/metrics collector (`collectors.NewGoCollector()`) uses `runtime/metrics` instead, which is cheaper. Prefer the modern collector in high-throughput services:
|
||||
|
||||
```go
|
||||
import "github.com/prometheus/client_golang/prometheus/collectors"
|
||||
|
||||
// Use runtime/metrics-based collector (lower overhead)
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectors.NewGoCollector(
|
||||
collectors.WithGoCollections(collectors.GoRuntimeMetricsCollection),
|
||||
))
|
||||
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
||||
```
|
||||
|
||||
## PromQL Deep-Dive Queries
|
||||
|
||||
Use these during investigation sessions with the reduced scrape interval. Each query includes what to look for and what the result means.
|
||||
|
||||
### GC pressure
|
||||
|
||||
| PromQL | What to look for |
|
||||
| --- | --- |
|
||||
| `rate(go_gc_duration_seconds_count[5m])` | GC cycles/s. >2/s sustained = excessive allocation rate. Reduce allocations per request. |
|
||||
| `rate(go_gc_duration_seconds_sum[5m]) / rate(go_gc_duration_seconds_count[5m])` | Average GC pause. Increasing trend = heap growing or too many pointers to scan. |
|
||||
| `go_gc_duration_seconds{quantile="1"}` | Worst-case GC pause. Spikes here cause tail latency (P99). |
|
||||
|
||||
### Memory leak detection
|
||||
|
||||
| PromQL | What to look for |
|
||||
| --- | --- |
|
||||
| `go_memstats_alloc_bytes` | Should be roughly stable under constant load. Continuous increase = memory leak. |
|
||||
| `rate(go_memstats_alloc_bytes_total[5m])` | Allocation rate (bytes/s). Compare before/after deploy — significant increase = new allocation pattern. |
|
||||
| `process_resident_memory_bytes - go_memstats_sys_bytes` | Gap = non-Go memory (cgo, mmap). Growing gap = non-Go leak. |
|
||||
|
||||
### Goroutine leak detection
|
||||
|
||||
| PromQL | What to look for |
|
||||
| --- | --- |
|
||||
| `go_goroutines` | Should correlate with load. Growing independently of traffic = leak. |
|
||||
| `delta(go_goroutines[1h])` | Net goroutine change over 1h. Positive without load increase = leak. |
|
||||
|
||||
### CPU saturation
|
||||
|
||||
| PromQL | What to look for |
|
||||
| --- | --- |
|
||||
| `rate(process_cpu_seconds_total[5m])` | CPU cores consumed. Compare to GOMAXPROCS. |
|
||||
| `rate(process_cpu_seconds_total[5m]) / <GOMAXPROCS>` | CPU utilization ratio. >0.8 sustained = CPU-saturated. |
|
||||
|
||||
### Post-deploy regression detection
|
||||
|
||||
| PromQL | What to look for |
|
||||
| --- | --- |
|
||||
| `rate(go_memstats_alloc_bytes_total[5m])` | Compare before/after deploy window. Significant increase = new allocation pattern introduced. |
|
||||
| `histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))` | P99 latency increase after deploy = performance regression. Requires app-level histogram. |
|
||||
|
||||
### Example alerting rules
|
||||
|
||||
```yaml
|
||||
# GC taking too much time
|
||||
- alert: HighGCPauseTime
|
||||
expr: rate(go_gc_duration_seconds_sum[5m]) / rate(go_gc_duration_seconds_count[5m]) > 0.01
|
||||
for: 10m
|
||||
annotations:
|
||||
summary: "Average GC pause >10ms — reduce allocations or tune GOGC"
|
||||
|
||||
# Goroutine leak
|
||||
- alert: GoroutineLeak
|
||||
expr: go_goroutines > 10000
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "Goroutine count >10K — check for leaked goroutines"
|
||||
|
||||
# Memory approaching container limit
|
||||
- alert: MemoryNearLimit
|
||||
expr: predict_linear(process_resident_memory_bytes[1h], 3600) > <container_limit_bytes>
|
||||
for: 15m
|
||||
annotations:
|
||||
summary: "RSS projected to exceed container limit within 1h"
|
||||
```
|
||||
|
||||
Adjust thresholds to your application — a data pipeline has different baselines than an API server.
|
||||
|
||||
## Host-Level Correlation
|
||||
|
||||
Go runtime metrics alone don't show the full picture. Host-level metrics reveal whether the problem is in your application or the infrastructure.
|
||||
|
||||
- **`node_exporter`** — host CPU, memory, disk I/O, network. Correlate with Go app metrics: high `node_cpu_seconds_total` with low `process_cpu_seconds_total` = noisy neighbor, not your app.
|
||||
- **`process-exporter`** — per-process metrics on Linux. Useful when multiple Go services share a host.
|
||||
|
||||
## Cost Warnings
|
||||
|
||||
**Profiles and traces are expensive to collect.** Keep them short-term and localized:
|
||||
|
||||
- **pprof CPU profiling** — CPU-intensive during the capture window. Don't run 30s profiles back-to-back in production. Space them out.
|
||||
- **Pyroscope continuous profiling** — ~2-5% CPU overhead **per instance, always-on**. At scale (hundreds of instances), this adds up in compute cost and backend storage. Enable on a subset of instances or on-demand via environment variable. → See `samber/cc-skills-golang@golang-observability` skill for Pyroscope setup.
|
||||
- **Execution traces** — generate large files quickly (MB/s). Capture 5-10s max. Longer traces are unwieldy and slow to analyze.
|
||||
- **Debug log level** — significant throughput impact due to allocation and I/O overhead. Never leave on permanently.
|
||||
- **All costly features** SHOULD be toggleable via environment variables for instant on/off without recompile. Design for this from day one.
|
||||
@@ -0,0 +1,844 @@
|
||||
# pprof Reference
|
||||
|
||||
`go tool pprof` is the primary tool for understanding where CPU time, memory, and contention go in Go programs. This file covers how to **use** the CLI and **interpret** the output. For enabling pprof endpoints on running services (net/http/pprof import, authentication, security), → See `samber/cc-skills-golang@golang-troubleshooting` skill.
|
||||
|
||||
## Profile Types
|
||||
|
||||
Each profile type answers a different performance question. Choosing the wrong profile type wastes investigation time — match the symptom to the profile before capturing.
|
||||
|
||||
| Profile | Flag / Endpoint | Use when | Why this profile and not another |
|
||||
| --- | --- | --- | --- |
|
||||
| **CPU** | `-cpuprofile` or `/debug/pprof/profile?seconds=30` | High CPU usage, slow functions | Samples which functions are on-CPU at 100Hz; misses off-CPU time (I/O, sleep) |
|
||||
| **Heap (alloc_objects)** | `-memprofile` then `pprof -alloc_objects` | GC pressure, too many allocations | Counts allocation events regardless of size — a 1-byte alloc counts the same as 1MB; reveals GC churn sources |
|
||||
| **Heap (alloc_space)** | `pprof -alloc_space` | Finding largest allocation sites by volume | Measures total bytes allocated; use when you need to reduce peak memory, not just GC frequency |
|
||||
| **Heap (inuse_space)** | `pprof -inuse_space` | Memory growing over time, suspected leaks | Shows currently live heap objects; compare two snapshots to isolate leak sources |
|
||||
| **Heap (inuse_objects)** | `pprof -inuse_objects` | Object count growth, suspected leak of small objects | Counts live objects regardless of size; useful when leak is many small objects not visible in inuse_space |
|
||||
| **Goroutine** | `/debug/pprof/goroutine` | Blocked I/O, goroutine leaks, pool exhaustion | Snapshots all goroutine stacks; look for goroutines piling up on the same call site |
|
||||
| **Mutex** | `/debug/pprof/mutex` | Lock contention between goroutines | Measures cumulative time goroutines waited to acquire mutexes. Must enable first: `runtime.SetMutexProfileFraction(5)` |
|
||||
| **Block** | `/debug/pprof/block` | Goroutines blocked on channels, mutexes, timers, select | Measures cumulative time goroutines spent blocked on synchronization primitives. Must enable first: `runtime.SetBlockProfileRate(1)` |
|
||||
| **Threadcreate** | `/debug/pprof/threadcreate` | Excessive OS thread creation | Shows stack traces that created new OS threads; typically from cgo calls or blocking syscalls that pin a thread |
|
||||
|
||||
### Choosing between alloc_objects and alloc_space
|
||||
|
||||
- **alloc_objects** — "where do I allocate the most often?" — use for reducing GC frequency (GC cares about object count, not size)
|
||||
- **alloc_space** — "where do I allocate the most bytes?" — use for reducing peak memory usage and RSS
|
||||
- In practice, start with `alloc_objects` because GC churn is the most common allocation-related bottleneck in Go.
|
||||
|
||||
### Choosing between inuse_space and alloc_space
|
||||
|
||||
- **alloc_space** is cumulative since program start — it includes objects already freed by GC
|
||||
- **inuse_space** is a point-in-time snapshot — only currently live objects
|
||||
- Use `alloc_space` to find allocation hot spots for optimization. Use `inuse_space` to debug memory leaks.
|
||||
|
||||
### Enabling mutex and block profiles
|
||||
|
||||
These profiles are disabled by default because they add overhead. Enable them before capturing:
|
||||
|
||||
```go
|
||||
import "runtime"
|
||||
|
||||
// Mutex profiling: fraction of mutex contention events recorded.
|
||||
// 5 means 1 out of 5 events is recorded. Higher = less overhead but less detail.
|
||||
runtime.SetMutexProfileFraction(5)
|
||||
|
||||
// Block profiling: nanosecond threshold.
|
||||
// 1 = record all blocking events. Higher values filter short blocks.
|
||||
// Use 1 for debugging, higher values (e.g. 1000000 = 1ms) for production.
|
||||
runtime.SetBlockProfileRate(1)
|
||||
```
|
||||
|
||||
Disable after investigation to eliminate overhead:
|
||||
|
||||
```go
|
||||
runtime.SetMutexProfileFraction(0)
|
||||
runtime.SetBlockProfileRate(0)
|
||||
```
|
||||
|
||||
## Generating Profiles
|
||||
|
||||
### From benchmarks (no HTTP server needed)
|
||||
|
||||
```bash
|
||||
# CPU profile — measures where compute time goes during benchmark execution
|
||||
go test -bench=BenchmarkParse -cpuprofile=cpu.prof ./pkg/parser
|
||||
|
||||
# Memory profile — captures allocation patterns during benchmark
|
||||
go test -bench=BenchmarkParse -memprofile=mem.prof ./pkg/parser
|
||||
|
||||
# Both at once — but be aware CPU profiling adds ~5% overhead which can skew memory results
|
||||
go test -bench=BenchmarkParse -cpuprofile=cpu.prof -memprofile=mem.prof ./pkg/parser
|
||||
```
|
||||
|
||||
### From running service
|
||||
|
||||
Requires `import _ "net/http/pprof"` (see `samber/cc-skills-golang@golang-troubleshooting` skill for secure setup):
|
||||
|
||||
```bash
|
||||
# CPU profile — captures 30 seconds of CPU samples
|
||||
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
|
||||
|
||||
# Heap profile — snapshots current heap state
|
||||
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap
|
||||
|
||||
# Goroutine profile — snapshots all goroutine stacks
|
||||
go tool pprof http://localhost:6060/debug/pprof/goroutine
|
||||
|
||||
# Mutex profile — contention data since last reset
|
||||
go tool pprof http://localhost:6060/debug/pprof/mutex
|
||||
|
||||
# Block profile — blocking data since last reset
|
||||
go tool pprof http://localhost:6060/debug/pprof/block
|
||||
```
|
||||
|
||||
### From code (programmatic)
|
||||
|
||||
```go
|
||||
import "runtime/pprof"
|
||||
|
||||
// CPU profile
|
||||
f, _ := os.Create("cpu.prof")
|
||||
pprof.StartCPUProfile(f)
|
||||
defer pprof.StopCPUProfile()
|
||||
|
||||
// Heap snapshot at a specific point
|
||||
f, _ := os.Create("heap.prof")
|
||||
pprof.WriteHeapProfile(f)
|
||||
f.Close()
|
||||
|
||||
// Named profile (goroutine, threadcreate, etc.)
|
||||
pprof.Lookup("goroutine").WriteTo(f, 0)
|
||||
```
|
||||
|
||||
## Interactive CLI Commands
|
||||
|
||||
Open a profile in interactive mode:
|
||||
|
||||
```bash
|
||||
go tool pprof cpu.prof
|
||||
# or from a URL:
|
||||
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
|
||||
```
|
||||
|
||||
### `top` — self time ranking (start here)
|
||||
|
||||
The first command to run. Shows functions ranked by the time (or allocations) spent in the function itself:
|
||||
|
||||
```
|
||||
(pprof) top
|
||||
Showing nodes accounting for 4.2s, 84% of 5s total
|
||||
flat flat% sum% cum cum%
|
||||
1.50s 30.00% 30.00% 2.80s 56.00% encoding/json.Marshal
|
||||
0.80s 16.00% 46.00% 0.80s 16.00% runtime.mallocgc
|
||||
0.60s 12.00% 58.00% 0.60s 12.00% runtime.memmove
|
||||
0.50s 10.00% 68.00% 0.50s 10.00% runtime.scanobject
|
||||
0.40s 8.00% 76.00% 1.90s 38.00% myapp/pkg/parser.Parse
|
||||
0.30s 6.00% 82.00% 0.30s 6.00% syscall.syscall
|
||||
0.10s 2.00% 84.00% 0.10s 2.00% runtime.futex
|
||||
```
|
||||
|
||||
| Column | Meaning | How to read it |
|
||||
| --- | --- | --- |
|
||||
| **flat** | Time spent in the function itself, excluding callees | High flat = the function's own code is expensive |
|
||||
| **flat%** | flat as percentage of total sample time | Quick way to see relative cost |
|
||||
| **sum%** | Running total of flat% going down the list | "The top 3 functions account for 58% of total time" |
|
||||
| **cum** | Time in function + all functions it calls (cumulative) | High cum with low flat = the function delegates to expensive callees |
|
||||
| **cum%** | cum as percentage of total | Compare with flat% — big gap means the cost is in callees |
|
||||
|
||||
**Limiting output:**
|
||||
|
||||
```
|
||||
(pprof) top 5 # show only top 5 functions
|
||||
(pprof) top -cum 10 # top 10 by cumulative time
|
||||
(pprof) top -flat 20 # top 20 by flat time (default sort)
|
||||
```
|
||||
|
||||
### `top -cum` — cumulative time ranking
|
||||
|
||||
Critical when `top` shows runtime functions (`runtime.mallocgc`, `runtime.memmove`, `runtime.scanobject`) dominating. These are symptoms, not causes. `top -cum` reveals which **application** functions trigger them:
|
||||
|
||||
```
|
||||
(pprof) top -cum
|
||||
flat flat% sum% cum cum%
|
||||
0.40s 8.00% 8.00% 3.80s 76.00% myapp/pkg/handler.HandleRequest
|
||||
0.10s 2.00% 10.00% 2.80s 56.00% myapp/pkg/handler.serializeResponse
|
||||
1.50s 30.00% 40.00% 2.80s 56.00% encoding/json.Marshal
|
||||
```
|
||||
|
||||
Now you can see that `HandleRequest` → `serializeResponse` → `json.Marshal` is the hot path. The optimization target is `serializeResponse`, not `runtime.mallocgc`.
|
||||
|
||||
### `list funcName` — annotated source
|
||||
|
||||
Shows the source code of a function with per-line cost annotations. This is how you pinpoint the **exact line** causing the bottleneck:
|
||||
|
||||
```
|
||||
(pprof) list serializeResponse
|
||||
Total: 5s
|
||||
ROUTINE ======================== myapp/pkg/handler.serializeResponse
|
||||
0.10s 2.80s (flat, cum) 56.00% of Total
|
||||
. . 38:func serializeResponse(w http.ResponseWriter, data any) {
|
||||
. 0.20s 39: w.Header().Set("Content-Type", "application/json")
|
||||
0.10s 2.60s 40: buf, err := json.Marshal(data)
|
||||
. . 41: if err != nil {
|
||||
. . 42: http.Error(w, err.Error(), 500)
|
||||
. . 43: return
|
||||
. . 44: }
|
||||
. 0.20s 45: w.Write(buf)
|
||||
. . 46:}
|
||||
```
|
||||
|
||||
- Left column = **flat** time (work done by this line itself)
|
||||
- Right column = **cumulative** time (this line + everything it calls)
|
||||
- Line 40 accounts for 2.60s cumulative because `json.Marshal` is expensive
|
||||
|
||||
**Use `list` with a regex** to find all matching functions:
|
||||
|
||||
```
|
||||
(pprof) list Parse.* # all functions starting with Parse
|
||||
(pprof) list \.Handle # all Handle methods across packages
|
||||
```
|
||||
|
||||
### `peek funcName` — callers and callees
|
||||
|
||||
Shows who calls a function and what it calls — the one-hop neighborhood in the call graph. Use to trace the responsibility chain when a function appears hot but you're unsure whether the problem is upstream (too many calls) or downstream (expensive callees):
|
||||
|
||||
```
|
||||
(pprof) peek json.Marshal
|
||||
Showing nodes accounting for 5s, 100% of 5s total
|
||||
----------------------------------------------+-------------
|
||||
| flat flat% sum% cum cum%
|
||||
myapp/pkg/handler.serializeResponse 2.60s |
|
||||
myapp/pkg/api.buildResponse 0.20s | 1.50s 30.00% 30.00% 2.80s 56.00% encoding/json.Marshal
|
||||
----------------------------------------------+-------------
|
||||
|
|
||||
reflect.Value.MapRange 0.40s |
|
||||
encoding/json.(*encodeState).marshal 0.30s |
|
||||
runtime.mallocgc 0.80s |
|
||||
```
|
||||
|
||||
Top section = callers (who calls json.Marshal). Bottom section = callees (what json.Marshal calls internally).
|
||||
|
||||
### `tree` — hierarchical call tree
|
||||
|
||||
Displays the full call tree with cumulative costs at each level. Useful when you need more context than `peek` provides:
|
||||
|
||||
```
|
||||
(pprof) tree
|
||||
0.40s 8.00% 8.00% 3.80s 76.00% myapp/pkg/handler.HandleRequest
|
||||
0.10s myapp/pkg/handler.serializeResponse
|
||||
1.50s encoding/json.Marshal
|
||||
0.80s runtime.mallocgc
|
||||
0.20s myapp/pkg/handler.validateInput
|
||||
0.10s myapp/pkg/handler.fetchData
|
||||
```
|
||||
|
||||
### `traces` — raw stack traces
|
||||
|
||||
Dumps all raw sample stack traces. Each stack trace shows what the program was doing at the moment it was sampled:
|
||||
|
||||
```
|
||||
(pprof) traces
|
||||
-----------+-------------------------------------------------------
|
||||
bytes: 1.5MB
|
||||
1.50s encoding/json.Marshal
|
||||
myapp/pkg/handler.serializeResponse
|
||||
myapp/pkg/handler.HandleRequest
|
||||
net/http.(*ServeMux).ServeHTTP
|
||||
-----------+-------------------------------------------------------
|
||||
```
|
||||
|
||||
Useful for spotting unexpected call paths (e.g., a function you didn't expect being called from a hot path).
|
||||
|
||||
### `web` / `svg` — graphical call graph
|
||||
|
||||
`web` opens a call graph in the browser. `svg` saves it to a file. Both require graphviz installed (`brew install graphviz` or `apt install graphviz`).
|
||||
|
||||
Visual encoding:
|
||||
|
||||
- **Thicker edges** = more time flows through that call
|
||||
- **Larger nodes** = more time spent in that function
|
||||
- **Red/dark nodes** = hot spots (high flat time)
|
||||
- **Edge labels** = time flowing through that call path
|
||||
|
||||
Use when the text commands don't reveal the full picture — the visual layout often reveals call patterns that are hard to see in text.
|
||||
|
||||
### `disasm funcName` — assembly-level
|
||||
|
||||
Shows generated assembly with per-instruction cost. Use for micro-optimization: verifying SIMD instructions, bounds check elimination, or inlining at the instruction level:
|
||||
|
||||
```
|
||||
(pprof) disasm Parse
|
||||
Total: 5s
|
||||
ROUTINE ======================== myapp/pkg/parser.Parse
|
||||
0.40s 1.90s (flat, cum) 38.00% of Total
|
||||
0.10s 0.10s 4a3b20: MOVQ 0x8(SP), AX ;parser.go:15
|
||||
0.20s 0.20s 4a3b28: CMPQ AX, $0x100 ;parser.go:16
|
||||
. 0.10s 4a3b2f: JGE 0x4a3b80 ;parser.go:16
|
||||
0.10s 1.50s 4a3b35: CALL runtime.makeslice(SB) ;parser.go:17
|
||||
```
|
||||
|
||||
### `weblist funcName` — annotated source in browser
|
||||
|
||||
Like `list` but opens the annotated source in a browser with color-coded cost highlighting. Each line is shaded from white (no cost) to red (hot). More visually immediate than the text version:
|
||||
|
||||
```
|
||||
(pprof) weblist serializeResponse
|
||||
```
|
||||
|
||||
Requires a browser. Falls back to `list` if no browser is available.
|
||||
|
||||
### `tags` — profile label breakdown
|
||||
|
||||
Shows tag values present in the profile. Go runtime profiles carry tags like `thread_id`; custom profiles can add arbitrary labels via `pprof.Do()`:
|
||||
|
||||
```go
|
||||
labels := pprof.Labels("request_type", "api", "endpoint", "/users")
|
||||
pprof.Do(ctx, labels, func(ctx context.Context) {
|
||||
handleRequest(ctx)
|
||||
})
|
||||
```
|
||||
|
||||
```
|
||||
(pprof) tags
|
||||
request_type: api (85%), batch (15%)
|
||||
endpoint: /users (40%), /orders (35%), /products (25%)
|
||||
```
|
||||
|
||||
### `tagroot` and `tagleaf` — group by labels
|
||||
|
||||
Group the profile data by tag values, creating a virtual call tree rooted on tag names:
|
||||
|
||||
```
|
||||
(pprof) tagroot request_type # group everything by request_type first
|
||||
(pprof) top # now shows breakdown per request_type
|
||||
(pprof) tagleaf endpoint # add endpoint as leaf grouping
|
||||
```
|
||||
|
||||
Useful for multi-tenant profiling or breaking down by request type without code changes.
|
||||
|
||||
### `granularity` — control grouping level
|
||||
|
||||
Changes how samples are aggregated:
|
||||
|
||||
```
|
||||
(pprof) granularity=functions # default — group by function name
|
||||
(pprof) granularity=filefunctions # group by file:function
|
||||
(pprof) granularity=files # group by file only
|
||||
(pprof) granularity=lines # group by exact source line
|
||||
(pprof) granularity=addresses # group by instruction address (most granular)
|
||||
```
|
||||
|
||||
`lines` is especially useful when a single function has multiple hot spots — it reveals which specific lines are expensive without needing `list`.
|
||||
|
||||
### `sort` — change sort order
|
||||
|
||||
```
|
||||
(pprof) sort=flat # sort by flat time (default for top)
|
||||
(pprof) sort=cum # sort by cumulative time (same as top -cum)
|
||||
```
|
||||
|
||||
### `source` — show source for matching regex
|
||||
|
||||
Similar to `list` but searches all functions matching a pattern and shows their annotated source:
|
||||
|
||||
```
|
||||
(pprof) source handler # show annotated source for all functions matching "handler"
|
||||
```
|
||||
|
||||
### `focus`, `ignore`, `hide`, `show` — filtering
|
||||
|
||||
Narrow the analysis to specific functions or exclude noise. These are stateful — they persist across commands until explicitly cleared:
|
||||
|
||||
```
|
||||
(pprof) focus=myapp # only show call paths that pass through "myapp"
|
||||
(pprof) ignore=runtime # remove runtime functions from display
|
||||
(pprof) hide=testing # hide testing framework noise from graphs
|
||||
(pprof) show=handler # only show functions matching "handler"
|
||||
(pprof) tagfocus=endpoint=/users # only show samples with this tag value
|
||||
(pprof) tagignore=request_type=batch # exclude samples with this tag value
|
||||
```
|
||||
|
||||
**Difference between `focus`, `show`, `hide`, and `ignore`:**
|
||||
|
||||
- `focus` — keeps only paths that contain a matching function; everything else is dropped
|
||||
- `ignore` — removes matching functions from the graph entirely; their costs are attributed to callers
|
||||
- `show` — like `focus` but only affects display, not cost accounting
|
||||
- `hide` — like `ignore` but only hides from display, not cost accounting
|
||||
|
||||
**Clear all filters:**
|
||||
|
||||
```
|
||||
(pprof) reset
|
||||
```
|
||||
|
||||
### `normalize` — normalize against a base profile
|
||||
|
||||
When comparing two profiles with `-base`, values are deltas by default. `normalize` scales the base profile to match the total of the main profile, making ratios comparable even if run durations differ:
|
||||
|
||||
```
|
||||
(pprof) normalize
|
||||
```
|
||||
|
||||
### `sample_index` — switch metric in multi-metric profiles
|
||||
|
||||
Heap profiles contain multiple metrics (alloc_objects, alloc_space, inuse_objects, inuse_space). Switch between them without reloading:
|
||||
|
||||
```
|
||||
(pprof) sample_index=alloc_objects
|
||||
(pprof) top # now shows allocation counts
|
||||
(pprof) sample_index=inuse_space
|
||||
(pprof) top # now shows live memory
|
||||
```
|
||||
|
||||
### `unit` — change display units
|
||||
|
||||
```
|
||||
(pprof) unit=ms # display time in milliseconds
|
||||
(pprof) unit=seconds # display in seconds
|
||||
(pprof) unit=MB # display memory in megabytes
|
||||
(pprof) unit=auto # automatic (default)
|
||||
```
|
||||
|
||||
### `callgrind` — export for KCachegrind
|
||||
|
||||
Exports the profile in callgrind format, which can be opened in KCachegrind or QCachegrind for advanced visualization:
|
||||
|
||||
```
|
||||
(pprof) callgrind
|
||||
Generating report in callgrind format
|
||||
```
|
||||
|
||||
### `proto` — save processed profile
|
||||
|
||||
Save the current profile (after filtering) in protobuf format for sharing or later analysis:
|
||||
|
||||
```
|
||||
(pprof) proto > filtered.pb.gz
|
||||
```
|
||||
|
||||
### `help` — list all commands
|
||||
|
||||
```
|
||||
(pprof) help # full command list with descriptions
|
||||
(pprof) help top # detailed help for a specific command
|
||||
```
|
||||
|
||||
### `show_from=regex` — trim callers above match
|
||||
|
||||
Hides all frames above the first matching function. Useful when you're only interested in a specific subsystem and want to remove framework/routing noise above it:
|
||||
|
||||
```
|
||||
(pprof) show_from=handler.Handle # start the graph from Handle, hide all callers above
|
||||
```
|
||||
|
||||
### `noinlines` — flatten inlined functions
|
||||
|
||||
Attributes inlined functions to their first out-of-line caller. Useful when inlined functions create confusing call chains in the graph:
|
||||
|
||||
```
|
||||
(pprof) noinlines
|
||||
```
|
||||
|
||||
### Full command reference
|
||||
|
||||
Every command below works both as a standalone shell command and inside the interactive `(pprof)` prompt. The interactive form omits `go tool pprof` and the profile path — e.g., `go tool pprof -top cpu.prof` becomes just `top` inside the prompt.
|
||||
|
||||
**Reporting commands:**
|
||||
|
||||
```bash
|
||||
# Top functions by self (flat) cost — the first command to run
|
||||
go tool pprof -top cpu.prof
|
||||
|
||||
# Top 20 functions by cumulative cost (self + callees)
|
||||
go tool pprof -cum -top -nodecount=20 cpu.prof
|
||||
|
||||
# Annotated source for a specific function — pinpoints the exact expensive line
|
||||
go tool pprof -list=json.Marshal cpu.prof
|
||||
|
||||
# Callers and callees of a function — trace the responsibility chain
|
||||
go tool pprof -peek=serializeResponse cpu.prof
|
||||
|
||||
# Hierarchical call tree with costs at each level
|
||||
go tool pprof -tree cpu.prof
|
||||
|
||||
# Raw sample stack traces — spot unexpected call paths
|
||||
go tool pprof -traces cpu.prof
|
||||
|
||||
# Per-instruction assembly cost — verify SIMD, bounds checks, inlining
|
||||
go tool pprof -disasm=Parse cpu.prof
|
||||
|
||||
# Annotated source for all functions matching a regex
|
||||
go tool pprof -source='handler\..*' cpu.prof
|
||||
|
||||
# Text output (flat table, alternative to -top)
|
||||
go tool pprof -text cpu.prof
|
||||
```
|
||||
|
||||
**Graph/export commands:**
|
||||
|
||||
```bash
|
||||
# SVG call graph (viewable in any browser, no graphviz server needed)
|
||||
go tool pprof -svg cpu.prof > cpu.svg
|
||||
|
||||
# SVG of only the subgraph matching a regex
|
||||
go tool pprof -svg -focus=handler cpu.prof > handler.svg
|
||||
|
||||
# PDF call graph
|
||||
go tool pprof -pdf cpu.prof > cpu.pdf
|
||||
|
||||
# PNG call graph
|
||||
go tool pprof -png cpu.prof > cpu.png
|
||||
|
||||
# GIF call graph
|
||||
go tool pprof -gif cpu.prof > cpu.gif
|
||||
|
||||
# DOT format (for custom graphviz processing: dot -Tsvg cpu.dot > cpu.svg)
|
||||
go tool pprof -dot cpu.prof > cpu.dot
|
||||
|
||||
# Callgrind format (open with KCachegrind / QCachegrind)
|
||||
go tool pprof -callgrind cpu.prof > cpu.callgrind
|
||||
|
||||
# Save current profile (with filters applied) in protobuf format
|
||||
go tool pprof -proto -focus=handler cpu.prof > handler-only.pb.gz
|
||||
|
||||
# Annotated source in browser with color-coded cost per line
|
||||
go tool pprof -weblist=serializeResponse cpu.prof
|
||||
```
|
||||
|
||||
**Filtering flags** — narrow analysis to relevant functions:
|
||||
|
||||
```bash
|
||||
# Focus: keep only call paths passing through matching functions
|
||||
go tool pprof -focus=myapp/pkg/handler -top cpu.prof
|
||||
|
||||
# Ignore: remove matching functions — their cost is attributed to callers
|
||||
go tool pprof -ignore=runtime -top cpu.prof
|
||||
|
||||
# Show: display only matching functions (display-only, does not change cost accounting)
|
||||
go tool pprof -show=handler -top cpu.prof
|
||||
|
||||
# Hide: hide matching functions from display (does not change cost accounting)
|
||||
go tool pprof -hide=testing -svg cpu.prof > clean.svg
|
||||
|
||||
# Show_from: trim all frames above the first match — hides framework/routing callers
|
||||
go tool pprof -show_from=handler.Handle -top cpu.prof
|
||||
|
||||
# Noinlines: attribute inlined functions to their first out-of-line caller
|
||||
go tool pprof -noinlines -top cpu.prof
|
||||
|
||||
# Combine multiple filters
|
||||
go tool pprof -cum -top -nodecount=10 -focus=handler -ignore=runtime cpu.prof
|
||||
```
|
||||
|
||||
**Tag-based filtering** — for profiles with labels (via `pprof.Do()`):
|
||||
|
||||
```bash
|
||||
# Show all tag keys and their value distributions
|
||||
go tool pprof -tags cpu.prof
|
||||
|
||||
# Keep only samples tagged with a specific key=value
|
||||
go tool pprof -tagfocus=endpoint=/users -top cpu.prof
|
||||
|
||||
# Exclude samples with a specific tag
|
||||
go tool pprof -tagignore=request_type=batch -top cpu.prof
|
||||
|
||||
# Group by tag — insert pseudo frames at root, breaking down by tag value
|
||||
go tool pprof -tagroot=request_type -top cpu.prof
|
||||
|
||||
# Group by tag as leaf — breaks down each function by tag value
|
||||
go tool pprof -tagleaf=endpoint -top cpu.prof
|
||||
|
||||
# Show/hide tags as annotations in graph output
|
||||
go tool pprof -tagshow=endpoint -svg cpu.prof > tagged.svg
|
||||
go tool pprof -taghide=thread_id -svg cpu.prof > clean.svg
|
||||
```
|
||||
|
||||
**Granularity and display control:**
|
||||
|
||||
```bash
|
||||
# Group by source line instead of function — reveals hot lines in multi-hot-spot functions
|
||||
go tool pprof -granularity=lines -top cpu.prof
|
||||
|
||||
# Group by file:function
|
||||
go tool pprof -granularity=filefunctions -top cpu.prof
|
||||
|
||||
# Group by file only
|
||||
go tool pprof -granularity=files -top cpu.prof
|
||||
|
||||
# Group by instruction address (most granular)
|
||||
go tool pprof -granularity=addresses -top cpu.prof
|
||||
|
||||
# Change display units
|
||||
go tool pprof -unit=ms -top cpu.prof
|
||||
|
||||
# Edge/node fraction cutoffs — hide small contributions from graphs
|
||||
go tool pprof -edgefraction=0.01 -nodefraction=0.005 -svg cpu.prof > clean.svg
|
||||
|
||||
# Disable trimming — show the full graph including tiny nodes
|
||||
go tool pprof -trim=false -svg cpu.prof > full.svg
|
||||
```
|
||||
|
||||
**Heap profile commands:**
|
||||
|
||||
```bash
|
||||
# Top allocation sites by object count — diagnose GC churn
|
||||
go tool pprof -top -alloc_objects mem.prof
|
||||
|
||||
# Top allocation sites by bytes — diagnose peak memory
|
||||
go tool pprof -top -alloc_space mem.prof
|
||||
|
||||
# Currently live objects — diagnose memory leaks
|
||||
go tool pprof -top -inuse_space mem.prof
|
||||
|
||||
# Currently live object count — diagnose leak of many small objects
|
||||
go tool pprof -top -inuse_objects mem.prof
|
||||
|
||||
# Annotated source showing allocation sites by object count
|
||||
go tool pprof -alloc_objects -list=Parse mem.prof
|
||||
|
||||
# SVG call graph colored by allocation objects
|
||||
go tool pprof -alloc_objects -svg mem.prof > allocs.svg
|
||||
|
||||
# Compare two heap snapshots — show only growth (memory leak detection)
|
||||
go tool pprof -top -base heap-baseline.prof heap-after.prof
|
||||
|
||||
# Diff with normalization — makes ratios comparable when capture durations differ
|
||||
go tool pprof -normalize -top -base heap-baseline.prof heap-after.prof
|
||||
|
||||
# Diff as SVG — visualize what grew
|
||||
go tool pprof -base heap-baseline.prof -svg heap-after.prof > leak.svg
|
||||
|
||||
# Diff with annotated source for a specific function
|
||||
go tool pprof -base heap-baseline.prof -list=handleRequest heap-after.prof
|
||||
```
|
||||
|
||||
**Fetching profiles from a running service:**
|
||||
|
||||
```bash
|
||||
# CPU profile — fetch 30 seconds of samples and open interactive mode
|
||||
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
|
||||
|
||||
# CPU profile — fetch and immediately generate SVG (no interactive mode)
|
||||
go tool pprof -svg http://localhost:6060/debug/pprof/profile?seconds=10 > cpu.svg
|
||||
|
||||
# CPU profile — fetch with a timeout
|
||||
go tool pprof -seconds=30 -timeout=60 http://localhost:6060/debug/pprof/profile
|
||||
|
||||
# Heap profile — fetch and show top allocation sites
|
||||
go tool pprof -top -alloc_objects http://localhost:6060/debug/pprof/heap
|
||||
|
||||
# Goroutine profile — fetch and show top goroutine stacks
|
||||
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
|
||||
|
||||
# Mutex profile — fetch contention data
|
||||
go tool pprof -top http://localhost:6060/debug/pprof/mutex
|
||||
|
||||
# Block profile — fetch blocking data
|
||||
go tool pprof -top http://localhost:6060/debug/pprof/block
|
||||
|
||||
# Fetch and save to a file without analysis (using curl)
|
||||
curl -o heap.prof http://localhost:6060/debug/pprof/heap
|
||||
|
||||
# Human-readable goroutine dump (no go tool pprof needed)
|
||||
curl http://localhost:6060/debug/pprof/goroutine?debug=1
|
||||
|
||||
# Goroutine dump with full stack traces, creation site, and labels
|
||||
curl http://localhost:6060/debug/pprof/goroutine?debug=2
|
||||
|
||||
# Human-readable heap stats
|
||||
curl http://localhost:6060/debug/pprof/heap?debug=1
|
||||
|
||||
# Fetch over TLS with client certificate
|
||||
go tool pprof -tls_cert=client.crt -tls_key=client.key -tls_ca=ca.crt https://myservice:6060/debug/pprof/profile?seconds=30
|
||||
|
||||
# Fetch over TLS skipping server certificate verification
|
||||
go tool pprof https+insecure://myservice:6060/debug/pprof/profile?seconds=30
|
||||
```
|
||||
|
||||
**Comparison commands (diff two profiles):**
|
||||
|
||||
```bash
|
||||
# Diff: subtract base from source — all values become deltas
|
||||
go tool pprof -base cpu-before.prof cpu-after.prof
|
||||
|
||||
# Diff base: percentages shown relative to base profile
|
||||
go tool pprof -diff_base=cpu-before.prof cpu-after.prof
|
||||
|
||||
# Diff with normalization — scale base to match source total
|
||||
go tool pprof -normalize -base heap-before.prof heap-after.prof
|
||||
|
||||
# Diff as top report
|
||||
go tool pprof -top -base cpu-before.prof cpu-after.prof
|
||||
|
||||
# Diff as SVG graph
|
||||
go tool pprof -svg -base cpu-before.prof cpu-after.prof > diff.svg
|
||||
```
|
||||
|
||||
**Web UI:**
|
||||
|
||||
```bash
|
||||
# Open interactive web UI with flamegraph, graph, source, and disassembly views
|
||||
go tool pprof -http=:8080 cpu.prof
|
||||
|
||||
# Open on a different port
|
||||
go tool pprof -http=:9090 mem.prof
|
||||
|
||||
# Open with a specific sample type pre-selected
|
||||
go tool pprof -http=:8080 -alloc_objects mem.prof
|
||||
|
||||
# Open with filters pre-applied
|
||||
go tool pprof -http=:8080 -focus=handler cpu.prof
|
||||
|
||||
# Open a diff view in the web UI
|
||||
go tool pprof -http=:8080 -base heap-baseline.prof heap-after.prof
|
||||
|
||||
# Open with no browser auto-launch (just start the server)
|
||||
go tool pprof -http=:8080 -no_browser cpu.prof
|
||||
```
|
||||
|
||||
**Symbolization flags:**
|
||||
|
||||
```bash
|
||||
# Disable symbolization (show raw addresses)
|
||||
go tool pprof -symbolize=none cpu.prof
|
||||
|
||||
# Only use local binaries for symbolization (don't contact remote)
|
||||
go tool pprof -symbolize=local cpu.prof
|
||||
|
||||
# Contact running service for symbol information
|
||||
go tool pprof -symbolize=remote http://localhost:6060/debug/pprof/profile?seconds=10
|
||||
|
||||
# Show mangled C++ names (relevant for cgo profiles)
|
||||
go tool pprof -symbolize=demangle=none cpu.prof
|
||||
|
||||
# Full demangling without simplification
|
||||
go tool pprof -symbolize=demangle=full cpu.prof
|
||||
```
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `PPROF_BINARY_PATH` | Search path for local binaries used in symbolization (default: `$HOME/pprof/binaries`). Set when profiling remote servers where binaries aren't in the default path. |
|
||||
| `PPROF_TOOLS` | Directory containing binutils tools (`addr2line`, `nm`, `objdump`). Set when these tools aren't in `$PATH`. |
|
||||
|
||||
## Graphical / Web UI
|
||||
|
||||
When CLI output is insufficient and you need interactive exploration:
|
||||
|
||||
```bash
|
||||
# Opens browser with interactive UI
|
||||
go tool pprof -http=:8080 cpu.prof
|
||||
|
||||
# Specify a different port if 8080 is taken
|
||||
go tool pprof -http=:9090 mem.prof
|
||||
|
||||
# Open with specific sample type pre-selected
|
||||
go tool pprof -http=:8080 -alloc_objects mem.prof
|
||||
|
||||
# Open with filters pre-applied
|
||||
go tool pprof -http=:8080 -focus=handler cpu.prof
|
||||
|
||||
# Compare two profiles — open with -base
|
||||
go tool pprof -http=:8080 -base heap-baseline.prof heap-after.prof
|
||||
```
|
||||
|
||||
The web UI provides:
|
||||
|
||||
- **Flamegraph** (most intuitive) — horizontal width proportional to cost; click to zoom into subtrees; inverted flamegraph available (icicle graph)
|
||||
- **Graph** — directed call graph with edge weights; nodes and edges sized/colored by cost; interactive zoom and click-to-focus
|
||||
- **Top** — same as `top` command but sortable columns, clickable to navigate to source
|
||||
- **Source** — annotated source with per-line cost; browsable across all functions
|
||||
- **Disassembly** — same as `disasm` but browsable across functions
|
||||
- **Peek** — interactive peek view with expandable callers/callees
|
||||
|
||||
Default to CLI commands for quick diagnosis — use the web UI when exploring unfamiliar call graphs, comparing profiles visually, or presenting findings to others.
|
||||
|
||||
## Comparing Profiles
|
||||
|
||||
### Memory leak detection with `-base`
|
||||
|
||||
Compare two heap profiles to isolate what grew between them:
|
||||
|
||||
```bash
|
||||
# Step 1: take a baseline snapshot
|
||||
curl http://localhost:6060/debug/pprof/heap > heap-baseline.prof
|
||||
|
||||
# Step 2: wait for the suspected leak to accumulate (minutes to hours)
|
||||
|
||||
# Step 3: take a second snapshot
|
||||
curl http://localhost:6060/debug/pprof/heap > heap-after.prof
|
||||
|
||||
# Step 4: diff — shows only what grew between the two snapshots
|
||||
go tool pprof -base heap-baseline.prof heap-after.prof
|
||||
# Then use top, list, peek as usual — all values are deltas
|
||||
```
|
||||
|
||||
### Comparing CPU profiles across code versions
|
||||
|
||||
```bash
|
||||
# Before your change
|
||||
go test -bench=BenchmarkParse -cpuprofile=cpu-before.prof ./pkg/parser
|
||||
|
||||
# After your change
|
||||
go test -bench=BenchmarkParse -cpuprofile=cpu-after.prof ./pkg/parser
|
||||
|
||||
# Compare visually — load both in separate browser tabs
|
||||
go tool pprof -http=:8080 cpu-before.prof
|
||||
go tool pprof -http=:8081 cpu-after.prof
|
||||
```
|
||||
|
||||
For statistical comparison of benchmark numbers (not profiles), use [benchstat](./benchstat.md) instead.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Learn to recognize these recurring shapes — they tell you what class of problem you're dealing with before you start fixing.
|
||||
|
||||
### Flat high + cum high
|
||||
|
||||
The function itself is the bottleneck. It does expensive work directly (tight loop, heavy computation, complex string processing). Optimize the function's own code — algorithm, data structure, or implementation.
|
||||
|
||||
### Flat low + cum high
|
||||
|
||||
The function calls slow things but does little work itself. It's a coordinator or dispatcher. Drill into callees with `list` or `peek`. The fix is usually in the called functions, or reducing how often they're called.
|
||||
|
||||
### `alloc_objects` high, `inuse_space` low
|
||||
|
||||
Short-lived allocations creating GC churn. Objects are allocated and freed rapidly — each one is cheap individually but the aggregate volume triggers frequent GC cycles. Common sources: `fmt.Errorf` in hot paths (allocates every call), interface boxing (`any` arguments), string-to-byte conversions, slice growth without preallocation. → See `samber/cc-skills-golang@golang-performance` skill for allocation reduction patterns.
|
||||
|
||||
### `inuse_space` growing over time
|
||||
|
||||
Memory leak. Take two heap snapshots minutes apart and compare with `-base` (see Comparing Profiles above). Growing types reveal the leak source. Common causes: unbounded caches, maps that never shrink (Go maps don't release bucket memory on delete), goroutine leaks holding references.
|
||||
|
||||
### Mutex/block profile hot
|
||||
|
||||
Contention, not CPU. The CPU is waiting, not working. The goroutines are all trying to acquire the same lock or read from the same channel. Reduce critical section scope, shard locks across multiple mutexes, or use lock-free structures (`sync/atomic`, `sync.Map` for read-heavy workloads). → See `samber/cc-skills-golang@golang-concurrency` skill.
|
||||
|
||||
### Many goroutines blocked on same channel/mutex
|
||||
|
||||
Serialization bottleneck. All work funnels through a single point. The throughput ceiling is the speed of that single point. Consider worker pools with multiple independent queues, sharding the work, or buffered channels to smooth bursts.
|
||||
|
||||
### `runtime.mallocgc` dominates CPU profile
|
||||
|
||||
Allocation rate is the bottleneck, not computation. The Go runtime is spending more time allocating and collecting garbage than running your code. Switch to the `alloc_objects` heap profile to find which functions allocate the most, then → See `samber/cc-skills-golang@golang-performance` skill for reduction patterns.
|
||||
|
||||
### `runtime.memmove` high in CPU profile
|
||||
|
||||
Large memory copies — usually from slice `append` growing beyond capacity, `copy()` of large slices, or string-to-byte conversions. Pre-allocate slices to final capacity, reuse buffers, or work with `[]byte` directly.
|
||||
|
||||
### `runtime.scanobject` high in CPU profile
|
||||
|
||||
GC pointer scanning. The heap contains many pointers that the GC must trace. Reduce pointer density: use value types instead of pointers in slices/maps, flatten nested structures, consider `[N]byte` arrays instead of `string` in hot structs.
|
||||
|
||||
## Which Profile for Which Symptom?
|
||||
|
||||
| Symptom | Profile | Flag/Command |
|
||||
| --- | --- | --- |
|
||||
| High CPU, slow function | CPU | `-cpuprofile` or `pprof/profile` |
|
||||
| Too many allocations (GC pressure) | Heap (alloc_objects) | `-memprofile` then `pprof -alloc_objects` |
|
||||
| Large allocations (memory usage) | Heap (alloc_space) | `pprof -alloc_space` |
|
||||
| Memory growing over time (leak) | Heap (inuse_space) | `pprof -inuse_space`, compare with `-base` |
|
||||
| Lock contention | Mutex | `pprof/mutex` (enable `SetMutexProfileFraction` first) |
|
||||
| Goroutines blocked on sync | Block | `pprof/block` (enable `SetBlockProfileRate` first) |
|
||||
| Too many goroutines / leak | Goroutine | `pprof/goroutine` |
|
||||
| High latency but low CPU | Goroutine + Block + Trace | Scheduling delays, I/O waits — see [Trace Reference](./trace.md) |
|
||||
| Excessive thread creation | Threadcreate | `pprof/threadcreate` |
|
||||
@@ -0,0 +1,299 @@
|
||||
# Prometheus Go Runtime Metrics Reference
|
||||
|
||||
Complete listing of Go runtime metrics **actually exposed as Prometheus metrics** by `prometheus/client_golang` library.
|
||||
|
||||
---
|
||||
|
||||
## Important Clarification
|
||||
|
||||
**`runtime/metrics` are NOT Prometheus metrics.** They're Go runtime data structures.
|
||||
|
||||
The Prometheus Go client library (`prometheus/client_golang`) **selectively converts some** `runtime/metrics` into Prometheus format. By default, it exposes only the traditional `go_memstats_*` and `go_gc_*` metrics to keep cardinality low.
|
||||
|
||||
**This document lists only Prometheus metrics** (the ones you actually scrape from `/metrics` endpoint).
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Metrics with Labels
|
||||
|
||||
| Metric | Label | Values |
|
||||
| ------------------------ | ---------- | --------------------- |
|
||||
| `go_gc_duration_seconds` | `quantile` | 0, 0.25, 0.5, 0.75, 1 |
|
||||
| `go_info` | `version` | e.g., "go1.21.3" |
|
||||
|
||||
### All Other Metrics
|
||||
|
||||
All other metrics have **no labels**.
|
||||
|
||||
---
|
||||
|
||||
## Default Go Metrics (Always Exposed)
|
||||
|
||||
These are exposed by default by `prometheus/client_golang`.
|
||||
|
||||
### Memory Allocation
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `go_memstats_alloc_bytes` | gauge | Current bytes allocated on heap |
|
||||
| `go_memstats_alloc_bytes_total` | counter | Cumulative bytes allocated |
|
||||
| `go_memstats_alloc_objects` | gauge | Current count of allocated objects |
|
||||
| `go_memstats_sys_bytes` | gauge | Total bytes requested from OS |
|
||||
|
||||
### Heap State
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --------------------------------- | ----- | --------------------------- |
|
||||
| `go_memstats_heap_alloc_bytes` | gauge | Allocated heap bytes |
|
||||
| `go_memstats_heap_idle_bytes` | gauge | Idle heap bytes |
|
||||
| `go_memstats_heap_inuse_bytes` | gauge | Heap bytes in use |
|
||||
| `go_memstats_heap_objects` | gauge | Count of heap objects |
|
||||
| `go_memstats_heap_released_bytes` | gauge | Heap bytes released to OS |
|
||||
| `go_memstats_heap_sys_bytes` | gauge | Heap bytes reserved from OS |
|
||||
|
||||
### Stack and Metadata
|
||||
|
||||
| Metric | Type | Description |
|
||||
| -------------------------------- | ----- | --------------------- |
|
||||
| `go_memstats_stack_inuse_bytes` | gauge | Stack in-use bytes |
|
||||
| `go_memstats_stack_sys_bytes` | gauge | Stack reserved bytes |
|
||||
| `go_memstats_mspan_inuse_bytes` | gauge | Mspan in-use bytes |
|
||||
| `go_memstats_mspan_sys_bytes` | gauge | Mspan reserved bytes |
|
||||
| `go_memstats_mcache_inuse_bytes` | gauge | Mcache in-use bytes |
|
||||
| `go_memstats_mcache_sys_bytes` | gauge | Mcache reserved bytes |
|
||||
| `go_memstats_other_sys_bytes` | gauge | Other runtime bytes |
|
||||
| `go_memstats_gc_sys_bytes` | gauge | GC internal bytes |
|
||||
|
||||
### Allocation and Free Counters
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --------------------------- | ------- | ------------------ |
|
||||
| `go_memstats_mallocs_total` | counter | Total malloc calls |
|
||||
| `go_memstats_frees_total` | counter | Total free calls |
|
||||
| `go_memstats_lookups_total` | counter | Total heap lookups |
|
||||
|
||||
### GC Pause Duration (with labels)
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `go_gc_duration_seconds` | summary | `quantile` (0, 0.25, 0.5, 0.75, 1) | GC pause durations with quantiles |
|
||||
| `go_gc_duration_seconds_count` | counter | — | GC pause count |
|
||||
| `go_gc_duration_seconds_sum` | counter | — | GC pause total time |
|
||||
|
||||
### GC Cycles
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `go_gc_cycles_automatic_gc_cycles_total` | counter | Automatic GC cycles (heap growth) |
|
||||
| `go_gc_cycles_forced_gc_cycles_total` | counter | Forced GC cycles (runtime.GC()) |
|
||||
|
||||
### Runtime State
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ----------------------- | ------- | -------------------------- |
|
||||
| `go_goroutines` | gauge | Current goroutine count |
|
||||
| `go_threads` | gauge | Current OS thread count |
|
||||
| `go_threadcreate_total` | counter | Total threads ever created |
|
||||
|
||||
### Version Information (with labels)
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
| --------- | ----- | --------- | ----------------- |
|
||||
| `go_info` | gauge | `version` | Go version string |
|
||||
|
||||
---
|
||||
|
||||
## Optional Go Metrics (Opt-in, Go 1.17+)
|
||||
|
||||
Enable via:
|
||||
|
||||
```go
|
||||
prometheus.NewRegistry().MustRegister(
|
||||
collectors.NewGoCollector(
|
||||
collectors.WithGoCollectorRuntimeMetrics(
|
||||
collectors.GoRuntimeMetricsAll,
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Additional Memory Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `go_gc_gogc_percent` | gauge | GOGC environment variable value |
|
||||
| `go_gc_gomemlimit_bytes` | gauge | GOMEMLIMIT environment variable value |
|
||||
| `go_gc_heap_allocs_bytes_total` | counter | Cumulative heap allocations (bytes) |
|
||||
| `go_gc_heap_allocs_objects_total` | counter | Cumulative heap allocations (count) |
|
||||
| `go_gc_heap_frees_bytes_total` | counter | Cumulative heap frees (bytes) |
|
||||
| `go_gc_heap_frees_objects_total` | counter | Cumulative heap frees (count) |
|
||||
| `go_gc_heap_goal_bytes` | gauge | Heap size target for next GC |
|
||||
| `go_gc_heap_live_bytes` | gauge | Live heap bytes |
|
||||
| `go_gc_heap_live_objects` | gauge | Live heap objects count |
|
||||
| `go_gc_heap_objects_total` | gauge | Total heap objects |
|
||||
| `go_gc_heap_trimmed_bytes` | gauge | Bytes trimmed and released |
|
||||
|
||||
### GC Pauses Distribution
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ---------------------------- | ------------ | ------------------ |
|
||||
| `go_gc_pauses_seconds_total` | distribution | GC pause durations |
|
||||
|
||||
### CPU Classes
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `go_cpu_classes_gc_mark_assist_seconds` | counter | GC mark assist CPU time |
|
||||
| `go_cpu_classes_gc_mark_dedicated_seconds` | counter | GC dedicated workers CPU time |
|
||||
| `go_cpu_classes_gc_mark_fractional_seconds` | counter | GC fractional workers CPU time |
|
||||
| `go_cpu_classes_gc_mark_idle_seconds` | counter | GC idle workers CPU time |
|
||||
| `go_cpu_classes_gc_pause_seconds` | counter | GC pause CPU time |
|
||||
| `go_cpu_classes_gc_total_seconds` | counter | Total GC CPU time |
|
||||
| `go_cpu_classes_idle_seconds` | counter | Idle CPU time |
|
||||
| `go_cpu_classes_other_seconds` | counter | Other CPU time |
|
||||
| `go_cpu_classes_user_seconds` | counter | User-mode CPU time |
|
||||
|
||||
### Memory Classes
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `go_memory_classes_heap_free_bytes` | gauge | Free heap memory |
|
||||
| `go_memory_classes_heap_objects_bytes` | gauge | Allocated heap objects |
|
||||
| `go_memory_classes_heap_released_bytes` | gauge | Heap released memory |
|
||||
| `go_memory_classes_heap_stacks_bytes` | gauge | Stack memory |
|
||||
| `go_memory_classes_heap_unused_bytes` | gauge | Unused heap |
|
||||
| `go_memory_classes_metadata_mcache_free_bytes` | gauge | Free mcache memory |
|
||||
| `go_memory_classes_metadata_mcache_inuse_bytes` | gauge | In-use mcache memory |
|
||||
| `go_memory_classes_metadata_mspan_free_bytes` | gauge | Free mspan memory |
|
||||
| `go_memory_classes_metadata_mspan_inuse_bytes` | gauge | In-use mspan memory |
|
||||
| `go_memory_classes_other_bytes` | gauge | Other memory |
|
||||
| `go_memory_classes_total_bytes` | gauge | Total memory |
|
||||
|
||||
### Scheduler Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ------------------------------ | ------------ | ---------------------------- |
|
||||
| `go_sched_goroutines_running` | gauge | Running goroutines |
|
||||
| `go_sched_goroutines_runnable` | gauge | Runnable goroutines waiting |
|
||||
| `go_sched_latencies_seconds` | distribution | Goroutine scheduling latency |
|
||||
|
||||
### Runtime Heap Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ---------------------------------- | ----- | -------------------- |
|
||||
| `go_runtime_max_stack_inuse_bytes` | gauge | Maximum stack in-use |
|
||||
| `go_runtime_mheap_alloc_bytes` | gauge | Heap alloc bytes |
|
||||
| `go_runtime_mheap_idle_bytes` | gauge | Heap idle bytes |
|
||||
| `go_runtime_mheap_inuse_bytes` | gauge | Heap in-use bytes |
|
||||
| `go_runtime_mheap_released_bytes` | gauge | Heap released bytes |
|
||||
| `go_runtime_mheap_sys_bytes` | gauge | Heap system bytes |
|
||||
| `go_runtime_stack_inuse_bytes` | gauge | Stack in-use bytes |
|
||||
|
||||
### CGO Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ---------------------------- | ------- | ------------------------ |
|
||||
| `go_cgo_go_to_c_calls_total` | counter | Total calls from Go to C |
|
||||
|
||||
---
|
||||
|
||||
## Process Metrics
|
||||
|
||||
Exposed by Prometheus `process` collector (not Go-specific):
|
||||
|
||||
### CPU and Memory
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `process_cpu_seconds_total` | counter | Total CPU time (user + system) |
|
||||
| `process_resident_memory_bytes` | gauge | RSS (physical memory used) |
|
||||
| `process_virtual_memory_bytes` | gauge | Virtual memory allocated |
|
||||
| `process_virtual_memory_max_bytes` | gauge | Maximum virtual memory allowed |
|
||||
|
||||
### File Descriptors
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ------------------ | ----- | -------------------------------- |
|
||||
| `process_open_fds` | gauge | Open file descriptors |
|
||||
| `process_max_fds` | gauge | Maximum file descriptors allowed |
|
||||
|
||||
### Process Information
|
||||
|
||||
| Metric | Type | Description |
|
||||
| ---------------------------- | ----- | ----------------------------------- |
|
||||
| `process_start_time_seconds` | gauge | Process start time (Unix timestamp) |
|
||||
|
||||
### Page Faults
|
||||
|
||||
| Metric | Type | Description |
|
||||
| --------------------------------- | ------- | ----------------- |
|
||||
| `process_page_faults_total` | counter | Total page faults |
|
||||
| `process_page_faults_minor_total` | counter | Minor page faults |
|
||||
| `process_page_faults_major_total` | counter | Major page faults |
|
||||
|
||||
---
|
||||
|
||||
## Common PromQL Queries
|
||||
|
||||
### Memory Leak Detection
|
||||
|
||||
```promql
|
||||
# Current heap allocation (should be stable under constant load)
|
||||
go_memstats_alloc_bytes
|
||||
|
||||
# Live heap bytes (optional metric)
|
||||
go_gc_heap_live_bytes
|
||||
|
||||
# Heap growth rate
|
||||
rate(go_memstats_alloc_bytes_total[5m])
|
||||
```
|
||||
|
||||
### GC Pressure
|
||||
|
||||
```promql
|
||||
# Worst-case GC pause (quantile 1 = max)
|
||||
go_gc_duration_seconds{quantile="1"}
|
||||
|
||||
# Average GC pause
|
||||
rate(go_gc_duration_seconds_sum[5m]) / rate(go_gc_duration_seconds_count[5m])
|
||||
|
||||
# GC frequency (cycles per second)
|
||||
rate(go_gc_duration_seconds_count[5m])
|
||||
```
|
||||
|
||||
### Goroutine Leaks
|
||||
|
||||
```promql
|
||||
# Current goroutine count
|
||||
go_goroutines
|
||||
|
||||
# Goroutine growth (leak indicator)
|
||||
delta(go_goroutines[1h])
|
||||
```
|
||||
|
||||
### CPU Usage
|
||||
|
||||
```promql
|
||||
# Total CPU time consumed
|
||||
rate(process_cpu_seconds_total[5m])
|
||||
|
||||
# CPU utilization ratio (0-1)
|
||||
rate(process_cpu_seconds_total[5m]) / <GOMAXPROCS>
|
||||
```
|
||||
|
||||
### File Descriptor Leaks
|
||||
|
||||
```promql
|
||||
# FD growth
|
||||
delta(process_open_fds[1h])
|
||||
|
||||
# FD saturation ratio
|
||||
process_open_fds / process_max_fds
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [prometheus/client_golang collectors](https://github.com/prometheus/client_golang/tree/main/prometheus/collectors)
|
||||
- [Go runtime/metrics package](https://pkg.go.dev/runtime/metrics)
|
||||
@@ -0,0 +1,53 @@
|
||||
# Diagnostic Tools Quick Reference
|
||||
|
||||
Use these tools to validate the root cause of a slowdown BEFORE applying any optimization. Do NOT use auto-fix flags (e.g. `--fix`) — let the coding agent interpret results and apply changes manually with explanatory comments.
|
||||
|
||||
For detailed usage of each tool, see the dedicated reference files:
|
||||
|
||||
- [pprof Reference](./pprof.md) — profiling (CPU, heap, goroutine, mutex, block)
|
||||
- [benchstat Reference](./benchstat.md) — statistical benchmark comparison
|
||||
- [Trace Reference](./trace.md) — execution tracer
|
||||
- [Compiler Analysis](./compiler-analysis.md) — escape analysis, inlining, SSA, assembly
|
||||
|
||||
## GC and Runtime Diagnostics
|
||||
|
||||
Configure via environment variables — no recompile needed.
|
||||
|
||||
| Command | Use for |
|
||||
| --- | --- |
|
||||
| `GODEBUG=gctrace=1 ./app` | GC frequency, pause times, heap sizes, CPU% — one line per GC cycle |
|
||||
| `GODEBUG=gcpacertrace=1 ./app` | Why GC triggers when it does — pacer decisions (trigger ratio, heap goal) |
|
||||
| `GODEBUG=schedtrace=1000 ./app` | Load balancing, goroutine distribution across Ps — prints every 1000ms |
|
||||
| `GODEBUG=schedtrace=1000,scheddetail=1 ./app` | Per-goroutine state detail on top of schedtrace |
|
||||
| `GODEBUG=allocfreetrace=1 ./app` | Individual allocation sites (very verbose — testing only) |
|
||||
| `GODEBUG=madvdontneed=1 ./app` | Force memory return to OS — useful for container RSS monitoring (default in Go 1.16+) |
|
||||
| `GODEBUG=gccheckmark=1 ./app` | GC correctness debugging (rare — only when suspecting GC bugs) |
|
||||
| `GOTRACEBACK=all ./app` | Full goroutine dumps on panic — all goroutines, not just the crashing one |
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-troubleshooting` skill for detailed GODEBUG usage and interpretation.
|
||||
|
||||
### Programmatic APIs
|
||||
|
||||
- **`runtime.ReadMemStats`** — heap size, NumGC, pause durations (PauseNs circular buffer), TotalAlloc (cumulative). Use for dashboards, alerting on heap growth.
|
||||
- **`debug.ReadGCStats`** — GC-specific statistics: pause percentiles, pause timeline, total pause duration. More focused than ReadMemStats.
|
||||
- **`runtime/metrics` (Go 1.16+)** — stable API, safe for concurrent reads, lower overhead than ReadMemStats. Keys: `/gc/cycles/total:gc-cycles`, `/gc/heap/allocs:bytes`, `/gc/pauses:seconds`, `/sched/latencies:seconds`, `/memory/classes/heap/released:bytes`.
|
||||
- **`debug.FreeOSMemory()`** — forces GC + returns memory to OS. One-off use after large temporary allocations (not for regular use — let the runtime manage this).
|
||||
- **`expvar`** — stdlib metrics at `/debug/vars` as JSON. `import _ "expvar"` auto-registers. Lightweight, no dependencies. Integrates with Netdata, Telegraf, or custom dashboards.
|
||||
|
||||
## Static Analysis
|
||||
|
||||
| Command | Use for |
|
||||
| --- | --- |
|
||||
| `fieldalignment ./...` | Detect suboptimal struct field ordering (padding waste). Do NOT use `-fix` flag — let the coding agent apply changes manually with explanatory comments. |
|
||||
| `unsafe.Sizeof` / `Alignof` / `Offsetof` | Inspect struct memory layout at compile time — compare before/after reordering to quantify savings. |
|
||||
| `go vet ./...` | Suspicious constructs: printf format mismatches, unreachable code, unused results, suspicious shifts. |
|
||||
| `staticcheck ./...` | Advanced linter: performance pitfalls (SA9003: empty branch, SA4006: unused value, SA1019: deprecated API). |
|
||||
| `go test -race ./...` | Data race detection at runtime — also useful for confirming false sharing. |
|
||||
|
||||
## Third-Party Profiling
|
||||
|
||||
| Tool | What it adds | When to use |
|
||||
| --- | --- | --- |
|
||||
| **fgprof** (`github.com/felixge/fgprof`) | Full goroutine profiler — captures both on-CPU and off-CPU (I/O wait) time in a single profile. Standard pprof CPU profiles only show on-CPU time. | pprof CPU profile shows low CPU% but latency is high. |
|
||||
| **Pyroscope / Parca** | Continuous profiling platforms — aggregate pprof profiles over time, compare across deployments, detect regressions. | Production performance monitoring, historical trend analysis. → See `samber/cc-skills-golang@golang-observability` skill for setup. |
|
||||
| **Linux perf** (`perf record -g ./app && perf report`) | Hardware performance counters: cache misses, branch mispredictions, TLB misses. Requires `perf_data_converter` for pprof format. | CPU microarchitecture-level analysis when pprof isn't granular enough. |
|
||||
@@ -0,0 +1,446 @@
|
||||
# Execution Trace Reference
|
||||
|
||||
`go tool trace` shows what pprof cannot: **scheduling delays**, GC stop-the-world phases, goroutine state transitions, and why goroutines are **not** running. pprof samples what's on-CPU; trace records every state transition at nanosecond precision.
|
||||
|
||||
Use the execution tracer when:
|
||||
|
||||
- pprof shows low CPU% but latency is high (goroutines waiting, not working)
|
||||
- You suspect GC pauses are causing tail latency spikes
|
||||
- You need to understand goroutine scheduling and contention
|
||||
- You want to see the wall-clock timeline of concurrent operations
|
||||
|
||||
## Generating Traces
|
||||
|
||||
### From benchmarks
|
||||
|
||||
```bash
|
||||
go test -bench=BenchmarkParse -trace=trace.out ./pkg/parser
|
||||
go tool trace trace.out
|
||||
```
|
||||
|
||||
### From running service
|
||||
|
||||
Requires `import _ "net/http/pprof"`:
|
||||
|
||||
```bash
|
||||
# Capture 5 seconds of trace data (adjust duration as needed)
|
||||
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
|
||||
go tool trace trace.out
|
||||
```
|
||||
|
||||
**Warning:** traces generate data at MB/s. Keep captures short — 5-10 seconds is typical. Longer traces are unwieldy, slow to parse, and may consume significant memory when opened.
|
||||
|
||||
### From tests
|
||||
|
||||
```bash
|
||||
go test -trace=trace.out ./pkg/parser
|
||||
go tool trace trace.out
|
||||
```
|
||||
|
||||
### From code (programmatic)
|
||||
|
||||
```go
|
||||
import "runtime/trace"
|
||||
|
||||
f, _ := os.Create("trace.out")
|
||||
trace.Start(f)
|
||||
defer trace.Stop()
|
||||
```
|
||||
|
||||
Or capture a region of interest:
|
||||
|
||||
```go
|
||||
import "runtime/trace"
|
||||
|
||||
// Start tracing only when needed
|
||||
f, _ := os.Create("trace.out")
|
||||
trace.Start(f)
|
||||
|
||||
doExpensiveWork()
|
||||
|
||||
trace.Stop()
|
||||
f.Close()
|
||||
```
|
||||
|
||||
## Full Command Reference
|
||||
|
||||
### Opening traces
|
||||
|
||||
```bash
|
||||
# Open trace in web browser (default — starts HTTP server, opens browser)
|
||||
go tool trace trace.out
|
||||
|
||||
# Open on a specific port
|
||||
go tool trace -http=:8080 trace.out
|
||||
|
||||
# Open on a specific host:port (e.g., for remote access)
|
||||
go tool trace -http=0.0.0.0:8080 trace.out
|
||||
```
|
||||
|
||||
### Extracting pprof profiles from traces
|
||||
|
||||
`go tool trace` can convert trace data into pprof-compatible profiles. This bridges the two tools — you capture with the tracer (nanosecond events) and analyze with pprof (statistical aggregation with `top`, `list`, `peek`):
|
||||
|
||||
```bash
|
||||
# Network blocking profile — where goroutines wait on network I/O
|
||||
go tool trace -pprof=net trace.out > net.prof
|
||||
go tool pprof -top net.prof
|
||||
|
||||
# Synchronization blocking profile — mutexes, channels, wait groups
|
||||
go tool trace -pprof=sync trace.out > sync.prof
|
||||
go tool pprof -top sync.prof
|
||||
|
||||
# Syscall blocking profile — system calls that block goroutines
|
||||
go tool trace -pprof=syscall trace.out > syscall.prof
|
||||
go tool pprof -top syscall.prof
|
||||
|
||||
# Scheduler latency profile — time between becoming runnable and actually running
|
||||
go tool trace -pprof=sched trace.out > sched.prof
|
||||
go tool pprof -top sched.prof
|
||||
```
|
||||
|
||||
You can chain with any pprof command — e.g., annotated source for a blocking function:
|
||||
|
||||
```bash
|
||||
go tool trace -pprof=sync trace.out > sync.prof
|
||||
go tool pprof -list=handleRequest sync.prof
|
||||
go tool pprof -svg sync.prof > sync-blocking.svg
|
||||
```
|
||||
|
||||
### Full capture-to-analysis workflows
|
||||
|
||||
```bash
|
||||
# Workflow 1: benchmark trace — capture, view, extract blocking profile
|
||||
go test -bench=BenchmarkParse -trace=trace.out ./pkg/parser
|
||||
go tool trace trace.out # visual timeline
|
||||
go tool trace -pprof=sync trace.out > sync.prof # extract sync blocking
|
||||
go tool pprof -top -cum sync.prof # find worst sync blockers
|
||||
go tool pprof -list=processOrder sync.prof # annotated source
|
||||
|
||||
# Workflow 2: production trace — capture from running service, analyze scheduling
|
||||
curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
|
||||
go tool trace trace.out # visual timeline
|
||||
go tool trace -pprof=sched trace.out > sched.prof # extract scheduling latency
|
||||
go tool pprof -top sched.prof # goroutines with worst scheduling delay
|
||||
go tool pprof -svg sched.prof > sched.svg # graph of scheduling bottlenecks
|
||||
|
||||
# Workflow 3: test trace — capture during test run
|
||||
go test -trace=trace.out -run=TestSlowIntegration ./pkg/api
|
||||
go tool trace trace.out # visual timeline
|
||||
go tool trace -pprof=net trace.out > net.prof # extract network blocking
|
||||
go tool pprof -top net.prof # find network wait sites
|
||||
```
|
||||
|
||||
### `go tool trace` flags summary
|
||||
|
||||
| Flag | Example | Purpose |
|
||||
| --- | --- | --- |
|
||||
| (none) | `go tool trace trace.out` | Open trace in web browser (default) |
|
||||
| `-http=:PORT` | `go tool trace -http=:9090 trace.out` | Set HTTP server address for the web UI |
|
||||
| `-pprof=TYPE` | `go tool trace -pprof=net trace.out > net.prof` | Extract pprof profile from trace. Types: `net`, `sync`, `syscall`, `sched` |
|
||||
|
||||
### HTTP endpoints served by the web UI
|
||||
|
||||
When `go tool trace trace.out` starts its HTTP server, it exposes these pages:
|
||||
|
||||
| Endpoint | What it shows |
|
||||
| --- | --- |
|
||||
| `/` | Index page with links to all views |
|
||||
| `/trace` | Interactive timeline viewer (Chrome trace viewer) — the main visualization |
|
||||
| `/goroutines` | Goroutine analysis — summary table of all goroutine types, counts, and execution stats |
|
||||
| `/goroutine/<id>` | Detailed view of a specific goroutine — its full lifecycle timeline |
|
||||
|
||||
From `/goroutines`, click on a goroutine type to see all instances and their execution statistics (total time, scheduled time, blocked time). Click an individual goroutine to see its timeline.
|
||||
|
||||
## Web UI
|
||||
|
||||
### Main views
|
||||
|
||||
The web UI (opened by `go tool trace trace.out`) shows a timeline where each horizontal lane represents a processor (P), goroutine, or system event:
|
||||
|
||||
- **Trace viewer** (`/trace`) — interactive timeline with:
|
||||
- **P lanes** — one per logical processor (GOMAXPROCS), showing which goroutine runs on each P at each moment
|
||||
- **Goroutine lanes** — each goroutine's lifecycle: created → runnable → running → waiting → running → …
|
||||
- **GC events** — mark phases, sweep, STW pauses shown as colored bands across all P lanes
|
||||
- **System events** — syscalls, network I/O, timer events
|
||||
- **User annotations** — tasks, regions, and log messages from `runtime/trace` API
|
||||
|
||||
- **Goroutine analysis** (`/goroutines`) — summary table:
|
||||
- Groups goroutines by creation stack trace (type)
|
||||
- Shows count, total execution time, total scheduling wait, total blocking time
|
||||
- Click a type to see individual goroutine statistics
|
||||
- Click an individual goroutine to see its timeline
|
||||
|
||||
### Navigating the trace viewer
|
||||
|
||||
The trace viewer uses the Chrome tracing UI (also used by Chrome DevTools):
|
||||
|
||||
| Key/Action | Effect |
|
||||
| --- | --- |
|
||||
| `W` / scroll up | Zoom in (time axis) |
|
||||
| `S` / scroll down | Zoom out (time axis) |
|
||||
| `A` | Pan left |
|
||||
| `D` | Pan right |
|
||||
| Click on event | Show details panel at bottom — goroutine ID, duration, stack trace |
|
||||
| `Shift+click` | Select a time range — highlights all events in that window |
|
||||
| `M` | Mark current selection |
|
||||
| `/` | Search for events by name |
|
||||
| `?` | Show keyboard shortcuts |
|
||||
|
||||
### Reading the timeline
|
||||
|
||||
**Color coding:**
|
||||
|
||||
- **Green bars** on P lanes = goroutine actively executing
|
||||
- **Blue bars** = syscall (goroutine pinned to OS thread)
|
||||
- **Orange/yellow marks** = scheduling events (goroutine becoming runnable)
|
||||
- **Red bands** across all P lanes = GC stop-the-world pause
|
||||
- **Light blue bands** = GC concurrent mark phase
|
||||
- **Purple** = user-defined regions (from `trace.WithRegion`)
|
||||
|
||||
**Gaps in P lanes** = the processor was idle (no runnable goroutines, or goroutines blocked). Many idle gaps with pending runnable goroutines suggests scheduling contention.
|
||||
|
||||
## What to Look For
|
||||
|
||||
### Goroutine states
|
||||
|
||||
The trace timeline color-codes goroutine states:
|
||||
|
||||
| Color | State | Meaning | What it indicates |
|
||||
| --- | --- | --- | --- |
|
||||
| **Green** | Running | Actively executing on a P | Normal — doing useful work |
|
||||
| **Yellow/Orange** | Runnable | Ready to run but waiting for a P | CPU-saturated — too many runnable goroutines competing for too few processors |
|
||||
| **Red/Pink** | Waiting | Blocked on I/O, channel, mutex, sleep, select | I/O-bound or contention — investigate what it's waiting on |
|
||||
| **Blue** | GC assist | Drafted by GC to help mark/sweep | GC pressure — too many allocations forcing goroutines to help the collector |
|
||||
|
||||
### GC phases
|
||||
|
||||
GC events appear as colored bands across all P lanes:
|
||||
|
||||
- **Mark assist** — goroutines drafted to help GC scan the heap. Visible as gaps in application goroutine execution. The runtime forces goroutines to assist with GC work in proportion to their allocation rate — heavy allocators get taxed more.
|
||||
- **STW (stop-the-world)** — brief phases where all goroutines are stopped (mark setup, mark termination). These cause latency spikes visible as vertical bands across all lanes.
|
||||
- **Sweep** — concurrent sweep of unreachable objects. Usually low overhead but can accumulate if the heap is large.
|
||||
|
||||
**Diagnosing GC issues from traces:**
|
||||
|
||||
- Frequent GC cycles with long mark assist = too many allocations (reduce allocation rate)
|
||||
- Long STW phases = too many pointers for the GC to scan (reduce pointer density)
|
||||
- GC cycles clustering after specific operations = those operations allocate heavily
|
||||
|
||||
### Scheduling latency
|
||||
|
||||
Time between a goroutine becoming **runnable** and actually **running**. High scheduling latency means:
|
||||
|
||||
- Too many goroutines competing for GOMAXPROCS processors
|
||||
- OS scheduling interference (noisy neighbors, CPU throttling)
|
||||
- Goroutines pinned to busy threads by cgo or long syscalls
|
||||
|
||||
**What to look for:**
|
||||
|
||||
- Yellow (runnable) gaps before green (running) segments — the longer the yellow gap, the higher the scheduling latency
|
||||
- Many goroutines in runnable state simultaneously — indicates CPU saturation
|
||||
- Uneven distribution across Ps — one P overloaded while others are idle suggests work imbalance
|
||||
|
||||
### Network/sync blocking
|
||||
|
||||
- **Long red/pink periods** on a goroutine = it's blocked waiting. Click the block event to see what it's waiting on (channel receive, mutex lock, network read, etc.)
|
||||
- **Many goroutines blocked on the same channel or mutex** = serialization bottleneck. All work funnels through one point.
|
||||
- **Goroutines blocked on network I/O** = external dependency latency. The Go code can't do anything faster — the bottleneck is upstream. Use `-pprof=net` to generate a pprof profile of network wait locations.
|
||||
|
||||
### Goroutine creation and destruction
|
||||
|
||||
The trace shows goroutine lifecycle events. Look for:
|
||||
|
||||
- **Goroutines created in a loop without bound** = potential goroutine leak
|
||||
- **Goroutines that are created but never finish** = leak — they accumulate over time
|
||||
- **Very short-lived goroutines created repeatedly** = high overhead from goroutine creation/scheduling (consider batching or worker pools)
|
||||
|
||||
## Custom Annotations
|
||||
|
||||
Add application-level context to traces so you can correlate runtime events with business operations.
|
||||
|
||||
### Tasks
|
||||
|
||||
A task represents a logical operation that may span multiple goroutines:
|
||||
|
||||
```go
|
||||
import "runtime/trace"
|
||||
|
||||
func processOrder(ctx context.Context, order Order) error {
|
||||
ctx, task := trace.NewTask(ctx, "processOrder")
|
||||
defer task.End()
|
||||
|
||||
// All trace events in this context are grouped under the task
|
||||
validate(ctx, order)
|
||||
charge(ctx, order)
|
||||
fulfill(ctx, order)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Tasks appear as named groups in the trace timeline. You can filter the trace view to show only events belonging to a specific task.
|
||||
|
||||
### Regions
|
||||
|
||||
A region represents a phase within a task or goroutine:
|
||||
|
||||
```go
|
||||
func validate(ctx context.Context, order Order) {
|
||||
trace.WithRegion(ctx, "validateAddress", func() {
|
||||
// this block is annotated as a region
|
||||
validateAddress(order.Address)
|
||||
})
|
||||
|
||||
trace.WithRegion(ctx, "validatePayment", func() {
|
||||
validatePayment(order.Payment)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Regions appear as labeled spans on the goroutine's timeline, making it easy to see which phase of processing takes the most wall-clock time.
|
||||
|
||||
### Log messages
|
||||
|
||||
Add point-in-time log messages to the trace:
|
||||
|
||||
```go
|
||||
trace.Log(ctx, "orderID", order.ID)
|
||||
trace.Log(ctx, "status", "payment_verified")
|
||||
```
|
||||
|
||||
Logs appear as markers on the timeline — useful for correlating trace events with specific data.
|
||||
|
||||
### When to use annotations
|
||||
|
||||
- **Always** in server request handlers — wrap each request in a task
|
||||
- **Performance-critical paths** — add regions to phases you want to measure wall-clock time for
|
||||
- **Debugging intermittent latency** — add logs at key decision points to see what happened in the slow trace
|
||||
|
||||
Annotations add negligible overhead when tracing is disabled (they check a flag and return immediately).
|
||||
|
||||
## Flight Recorder (Go 1.25+)
|
||||
|
||||
The flight recorder solves a fundamental problem with execution traces in long-running services: when a problem occurs (timeout, failed health check), it's already too late to call `trace.Start()`. The flight recorder keeps a circular buffer of recent trace data in memory, and you snapshot it to disk when something goes wrong — like an airplane's black box.
|
||||
|
||||
### Setup
|
||||
|
||||
```go
|
||||
import "runtime/trace"
|
||||
|
||||
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
|
||||
MinAge: 10 * time.Second, // keep at least 10s of data
|
||||
MaxBytes: 5 << 20, // cap at 5 MiB to limit memory usage
|
||||
})
|
||||
fr.Start()
|
||||
```
|
||||
|
||||
**Sizing guidance:**
|
||||
|
||||
- **MinAge** — set to ~2x your problem window. For 5-second timeout debugging, use 10 seconds. The runtime may retain more data than MinAge if MaxBytes allows.
|
||||
- **MaxBytes** — busy services generate ~1-10 MB/s of trace data. Start with 1-5 MiB and adjust. MaxBytes takes precedence over MinAge — when the buffer fills, older data is discarded regardless of age.
|
||||
|
||||
### Snapshot on error
|
||||
|
||||
Capture the trace buffer when something unexpected happens. Use `sync.Once` to prevent multiple snapshots overwriting each other:
|
||||
|
||||
```go
|
||||
var snapshotOnce sync.Once
|
||||
|
||||
func captureSnapshot(fr *trace.FlightRecorder) {
|
||||
snapshotOnce.Do(func() {
|
||||
f, err := os.Create("snapshot.trace")
|
||||
if err != nil {
|
||||
log.Printf("snapshot file: %v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := fr.WriteTo(f); err != nil {
|
||||
log.Printf("snapshot write: %v", err)
|
||||
return
|
||||
}
|
||||
fr.Stop()
|
||||
log.Printf("captured snapshot to %s", f.Name())
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger patterns
|
||||
|
||||
```go
|
||||
// Pattern 1: slow request detection
|
||||
http.HandleFunc("/api/order", func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
// ... handler logic ...
|
||||
|
||||
if fr.Enabled() && time.Since(start) > 100*time.Millisecond {
|
||||
go captureSnapshot(fr)
|
||||
}
|
||||
})
|
||||
|
||||
// Pattern 2: health check failure
|
||||
if !healthCheck() && fr.Enabled() {
|
||||
go captureSnapshot(fr)
|
||||
}
|
||||
|
||||
// Pattern 3: HTTP endpoint for on-demand capture
|
||||
http.HandleFunc("/debug/flightrecorder", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !fr.Enabled() {
|
||||
http.Error(w, "flight recorder not active", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=trace.out")
|
||||
fr.WriteTo(w)
|
||||
})
|
||||
```
|
||||
|
||||
### Analyzing a snapshot
|
||||
|
||||
```bash
|
||||
go tool trace snapshot.trace
|
||||
```
|
||||
|
||||
The snapshot contains the same data as a regular trace — use all the same analysis techniques (timeline viewer, goroutine analysis, pprof extraction). The flight recorder's flow events are particularly useful for diagnosing lock contention and goroutine stalls that caused the anomaly.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **At most one flight recorder** may be active at a time (this restriction may be relaxed in future Go versions)
|
||||
- A flight recorder **can run concurrently** with `trace.Start` — both can be active simultaneously
|
||||
- Only one goroutine may call `WriteTo` at a time — the `sync.Once` pattern handles this naturally
|
||||
- `Stop()` blocks until any concurrent `WriteTo` completes
|
||||
|
||||
### When to use flight recorder vs regular tracing
|
||||
|
||||
| Scenario | Tool | Why |
|
||||
| --- | --- | --- |
|
||||
| Investigating a known slow operation | `go test -trace` or `trace.Start`/`Stop` | You know when to start and stop |
|
||||
| Intermittent latency spikes in production | Flight recorder | You don't know when the spike will happen — the buffer captures it retroactively |
|
||||
| Post-mortem after a timeout or crash | Flight recorder | The problem already happened; regular tracing would miss it |
|
||||
| Continuous performance monitoring | `samber/cc-skills-golang@golang-observability` (Pyroscope) | Flight recorder is for one-shot diagnosis, not continuous collection |
|
||||
|
||||
## Overhead and Practical Limits
|
||||
|
||||
| Concern | Guidance |
|
||||
| --- | --- |
|
||||
| **Runtime overhead** | ~1-2% CPU during capture; negligible when not capturing |
|
||||
| **Data volume** | Traces generate MB/s of data. A 10-second trace of a busy service can be 50-100MB |
|
||||
| **Capture duration** | 5-10 seconds is typical. Longer traces are slow to open and hard to navigate |
|
||||
| **Memory to view** | `go tool trace` loads the entire trace into memory. Large traces may need 1GB+ RAM |
|
||||
| **Browser performance** | The web UI can struggle with traces >100MB. Use short captures. |
|
||||
| **Production use** | Safe for short captures on a single instance. Do not capture continuously. |
|
||||
|
||||
## Trace vs pprof: When to Use Which
|
||||
|
||||
| Question | Tool | Why |
|
||||
| --- | --- | --- |
|
||||
| Where does CPU time go? | pprof CPU profile | Statistical sampling, low overhead, good for aggregate view |
|
||||
| Why is latency high but CPU low? | go tool trace | Shows goroutine waiting states — I/O, channels, mutexes |
|
||||
| Where do allocations happen? | pprof heap profile | Per-function allocation counts and sizes |
|
||||
| Why are GC pauses long? | go tool trace | Shows STW phases, mark assist, GC timeline |
|
||||
| Is there lock contention? | pprof mutex/block + trace | pprof quantifies it; trace shows the timeline |
|
||||
| Are goroutines leaking? | pprof goroutine + trace | pprof shows the stack; trace shows creation/lifecycle |
|
||||
| Which goroutines compete for CPU? | go tool trace | Shows runnable vs running states across all Ps |
|
||||
| What's the wall-clock breakdown of a request? | go tool trace (with annotations) | Timeline view with tasks and regions |
|
||||
|
||||
When in doubt, start with pprof (lower overhead, simpler output). Use trace when pprof doesn't explain the latency or when you need the wall-clock timeline view.
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: golang-cli
|
||||
description: Golang CLI application development. Use when building, modifying, or reviewing a Go CLI tool — especially for command structure, flag handling, configuration layering, version embedding, exit codes, I/O patterns, signal handling, shell completion, argument validation, and CLI unit testing. Also triggers when code uses cobra, viper, or urfave/cli.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
**Persona:** You are a Go CLI engineer. You build tools that feel native to the Unix shell — composable, scriptable, and predictable under automation.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Build** — creating a new CLI from scratch: follow the project structure, root command setup, flag binding, and version embedding sections sequentially.
|
||||
- **Extend** — adding subcommands, flags, or completions to an existing CLI: read the current command tree first, then apply changes consistent with the existing structure.
|
||||
- **Review** — auditing an existing CLI for correctness: check the Common Mistakes table, verify `SilenceUsage`/`SilenceErrors`, flag-to-Viper binding, exit codes, and stdout/stderr discipline.
|
||||
|
||||
# Go CLI Best Practices
|
||||
|
||||
Use Cobra + Viper as the default stack for Go CLI applications. Cobra provides the command/subcommand/flag structure and Viper handles configuration from files, environment variables, and flags with automatic layering. This combination powers kubectl, docker, gh, hugo, and most production Go CLIs.
|
||||
|
||||
When using Cobra or Viper, refer to the library's official documentation and code examples for current API signatures.
|
||||
|
||||
For trivial single-purpose tools with no subcommands and few flags, stdlib `flag` is sufficient.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Concern | Package / Tool |
|
||||
| ------------------- | ------------------------------------ |
|
||||
| Commands & flags | `github.com/spf13/cobra` |
|
||||
| Configuration | `github.com/spf13/viper` |
|
||||
| Flag parsing | `github.com/spf13/pflag` (via Cobra) |
|
||||
| Colored output | `github.com/fatih/color` |
|
||||
| Table output | `github.com/olekukonez/tablewriter` |
|
||||
| Interactive prompts | `github.com/charmbracelet/bubbletea` |
|
||||
| Version injection | `go build -ldflags` |
|
||||
| Distribution | `goreleaser` |
|
||||
|
||||
## Project Structure
|
||||
|
||||
Organize CLI commands in `cmd/myapp/` with one file per command. Keep `main.go` minimal — it only calls `Execute()`.
|
||||
|
||||
```
|
||||
myapp/
|
||||
├── cmd/
|
||||
│ └── myapp/
|
||||
│ ├── main.go # package main, only calls Execute()
|
||||
│ ├── root.go # Root command + Viper init
|
||||
│ ├── serve.go # "serve" subcommand
|
||||
│ ├── migrate.go # "migrate" subcommand
|
||||
│ └── version.go # "version" subcommand
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
`main.go` should be minimal — see [assets/examples/main.go](assets/examples/main.go).
|
||||
|
||||
## Root Command Setup
|
||||
|
||||
The root command initializes Viper configuration and sets up global behavior via `PersistentPreRunE`. See [assets/examples/root.go](assets/examples/root.go).
|
||||
|
||||
Key points:
|
||||
|
||||
- `SilenceUsage: true` MUST be set — prevents printing the full usage text on every error
|
||||
- `SilenceErrors: true` MUST be set — lets you control error output format yourself
|
||||
- `PersistentPreRunE` runs before every subcommand, so config is always initialized
|
||||
- Logs go to stderr, output goes to stdout
|
||||
|
||||
## Subcommands
|
||||
|
||||
Add subcommands by creating separate files in `cmd/myapp/` and registering them in `init()`. See [assets/examples/serve.go](assets/examples/serve.go) for a complete subcommand example including command groups.
|
||||
|
||||
## Flags
|
||||
|
||||
See [assets/examples/flags.go](assets/examples/flags.go) for all flag patterns:
|
||||
|
||||
### Persistent vs Local
|
||||
|
||||
- **Persistent** flags are inherited by all subcommands (e.g., `--config`)
|
||||
- **Local** flags only apply to the command they're defined on (e.g., `--port`)
|
||||
|
||||
### Required Flags
|
||||
|
||||
Use `MarkFlagRequired`, `MarkFlagsMutuallyExclusive`, and `MarkFlagsOneRequired` for flag constraints.
|
||||
|
||||
### Flag Validation with RegisterFlagCompletionFunc
|
||||
|
||||
Provide completion suggestions for flag values.
|
||||
|
||||
### Always Bind Flags to Viper
|
||||
|
||||
This ensures `viper.GetInt("port")` returns the flag value, env var `MYAPP_PORT`, or config file value — whichever has highest precedence.
|
||||
|
||||
## Argument Validation
|
||||
|
||||
Cobra provides built-in validators for positional arguments. See [assets/examples/args.go](assets/examples/args.go) for both built-in and custom validation examples.
|
||||
|
||||
| Validator | Description |
|
||||
| --------------------------- | ------------------------------------ |
|
||||
| `cobra.NoArgs` | Fails if any args provided |
|
||||
| `cobra.ExactArgs(n)` | Requires exactly n args |
|
||||
| `cobra.MinimumNArgs(n)` | Requires at least n args |
|
||||
| `cobra.MaximumNArgs(n)` | Allows at most n args |
|
||||
| `cobra.RangeArgs(min, max)` | Requires between min and max |
|
||||
| `cobra.ExactValidArgs(n)` | Exactly n args, must be in ValidArgs |
|
||||
|
||||
## Configuration with Viper
|
||||
|
||||
Viper resolves configuration values in this order (highest to lowest precedence):
|
||||
|
||||
1. **CLI flags** (explicit user input)
|
||||
2. **Environment variables** (deployment config)
|
||||
3. **Config file** (persistent settings)
|
||||
4. **Defaults** (set in code)
|
||||
|
||||
See [assets/examples/config.go](assets/examples/config.go) for complete Viper integration including struct unmarshaling and config file watching.
|
||||
|
||||
### Example Config File (.myapp.yaml)
|
||||
|
||||
```yaml
|
||||
port: 8080
|
||||
host: localhost
|
||||
log-level: info
|
||||
database:
|
||||
dsn: postgres://localhost:5432/myapp
|
||||
max-conn: 25
|
||||
```
|
||||
|
||||
With the setup above, these are all equivalent:
|
||||
|
||||
- Flag: `--port 9090`
|
||||
- Env var: `MYAPP_PORT=9090`
|
||||
- Config file: `port: 9090`
|
||||
|
||||
## Version and Build Info
|
||||
|
||||
Version SHOULD be embedded at compile time using `ldflags`. See [assets/examples/version.go](assets/examples/version.go) for the version command and build instructions.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
Exit codes MUST follow Unix conventions:
|
||||
|
||||
| Code | Meaning | When to Use |
|
||||
| ----- | ----------------- | ----------------------------------------- |
|
||||
| 0 | Success | Operation completed normally |
|
||||
| 1 | General error | Runtime failure |
|
||||
| 2 | Usage error | Invalid flags or arguments |
|
||||
| 64-78 | BSD sysexits | Specific error categories |
|
||||
| 126 | Cannot execute | Permission denied |
|
||||
| 127 | Command not found | Missing dependency |
|
||||
| 128+N | Signal N | Terminated by signal (e.g., 130 = SIGINT) |
|
||||
|
||||
See [assets/examples/exit_codes.go](assets/examples/exit_codes.go) for a pattern mapping errors to exit codes.
|
||||
|
||||
## I/O Patterns
|
||||
|
||||
See [assets/examples/output.go](assets/examples/output.go) for all I/O patterns:
|
||||
|
||||
- **stdout vs stderr**: NEVER write diagnostic output to stdout — stdout is for program output (pipeable), stderr for logs/errors/diagnostics
|
||||
- **Detecting pipe vs terminal**: check `os.ModeCharDevice` on stdout
|
||||
- **Machine-readable output**: support `--output` flag for table/json/plain formats
|
||||
- **Colors**: use `fatih/color` which auto-disables when output is not a terminal
|
||||
|
||||
## Signal Handling
|
||||
|
||||
Signal handling MUST use `signal.NotifyContext` to propagate cancellation through context. See [assets/examples/signal.go](assets/examples/signal.go) for graceful HTTP server shutdown.
|
||||
|
||||
## Shell Completions
|
||||
|
||||
Cobra generates completions for bash, zsh, fish, and PowerShell automatically. See [assets/examples/completion.go](assets/examples/completion.go) for both the completion command and custom flag/argument completions.
|
||||
|
||||
## Testing CLI Commands
|
||||
|
||||
Test commands by executing them programmatically and capturing output. See [assets/examples/cli_test.go](assets/examples/cli_test.go).
|
||||
|
||||
Use `cmd.OutOrStdout()` and `cmd.ErrOrStderr()` in commands (instead of `os.Stdout` / `os.Stderr`) so output can be captured in tests.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Writing to `os.Stdout` directly | Tests can't capture output. Use `cmd.OutOrStdout()` which tests can redirect to a buffer |
|
||||
| Calling `os.Exit()` inside `RunE` | Cobra's error handling, deferred functions, and cleanup code never run. Return an error, let `main()` decide |
|
||||
| Not binding flags to Viper | Flags won't be configurable via env/config. Call `viper.BindPFlag` for every configurable flag |
|
||||
| Missing `viper.SetEnvPrefix` | `PORT` collides with other tools. Use a prefix (`MYAPP_PORT`) to namespace env vars |
|
||||
| Logging to stdout | Unix pipes chain stdout — logs corrupt the data stream for the next program. Logs go to stderr |
|
||||
| Printing usage on every error | Full help text on every error is noise. Set `SilenceUsage: true`, save full usage for `--help` |
|
||||
| Config file required | Users without a config file get a crash. Ignore `viper.ConfigFileNotFoundError` — config should be optional |
|
||||
| Not using `PersistentPreRunE` | Config initialization must happen before any subcommand. Use root's `PersistentPreRunE` |
|
||||
| Hardcoded version string | Version gets out of sync with tags. Inject via `ldflags` at build time from git tags |
|
||||
| Not supporting `--output` format | Scripts can't parse human-readable output. Add JSON/table/plain for machine consumption |
|
||||
|
||||
## Related Skills
|
||||
|
||||
See `samber/cc-skills-golang@golang-project-layout`, `samber/cc-skills-golang@golang-dependency-injection`, `samber/cc-skills-golang@golang-testing`, `samber/cc-skills-golang@golang-design-patterns` skills.
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Cobra provides built-in validators for positional arguments.
|
||||
// See the table in SKILL.md for all available validators.
|
||||
var deployCmd = &cobra.Command{
|
||||
Use: "deploy [environment]",
|
||||
Short: "Deploy to an environment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env := args[0]
|
||||
_ = env
|
||||
// deploy...
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Custom validation example:
|
||||
var deployWithValidationCmd = &cobra.Command{
|
||||
Use: "deploy [environment]",
|
||||
Short: "Deploy to an environment",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("expected exactly 1 argument, got %d", len(args))
|
||||
}
|
||||
valid := map[string]bool{"dev": true, "staging": true, "prod": true}
|
||||
if !valid[args[0]] {
|
||||
return fmt.Errorf("invalid environment %q, must be one of: dev, staging, prod", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// deploy...
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Test commands by executing them programmatically and capturing output.
|
||||
// Use cmd.OutOrStdout() and cmd.ErrOrStderr() in commands (instead of
|
||||
// os.Stdout / os.Stderr) so output can be captured in tests.
|
||||
|
||||
func executeCommand(root *cobra.Command, args ...string) (string, error) {
|
||||
buf := new(bytes.Buffer)
|
||||
root.SetOut(buf)
|
||||
root.SetErr(buf)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestServeCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default port",
|
||||
args: []string{"serve"},
|
||||
want: "listening on :8080\n",
|
||||
},
|
||||
{
|
||||
name: "custom port",
|
||||
args: []string{"serve", "--port", "9090"},
|
||||
want: "listening on :9090\n",
|
||||
},
|
||||
{
|
||||
name: "missing required flag",
|
||||
args: []string{"serve", "--host", ""},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := executeCommand(rootCmd, tt.args...)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Errorf("output = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// === Shell Completion Command ===
|
||||
// Cobra generates completions for bash, zsh, fish, and PowerShell automatically.
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
Args: cobra.ExactValidArgs(1),
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
return rootCmd.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
return rootCmd.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
return rootCmd.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// === Custom Completions ===
|
||||
// Add custom completions for flags and arguments.
|
||||
|
||||
func customCompletionExamples() {
|
||||
deployCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{
|
||||
"dev\tDevelopment environment",
|
||||
"staging\tStaging environment",
|
||||
"prod\tProduction environment",
|
||||
}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// Dynamic argument completion
|
||||
deployCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
if len(args) != 0 {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return getAvailableServices(), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
}
|
||||
|
||||
func getAvailableServices() []string {
|
||||
// fetch available services dynamically
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// === Complete Cobra + Viper Integration ===
|
||||
|
||||
func initConfigComplete() error {
|
||||
// 1. Config file
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile) // explicit path
|
||||
} else {
|
||||
home, _ := os.UserHomeDir()
|
||||
viper.AddConfigPath(home) // search $HOME
|
||||
viper.AddConfigPath(".") // search current dir
|
||||
viper.SetConfigName(".myapp")
|
||||
viper.SetConfigType("yaml")
|
||||
}
|
||||
|
||||
// 2. Environment variables
|
||||
viper.SetEnvPrefix("MYAPP") // MYAPP_PORT, MYAPP_LOG_LEVEL
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) // log-level → MYAPP_LOG_LEVEL
|
||||
viper.AutomaticEnv() // bind all env vars automatically
|
||||
|
||||
// 3. Read config file (ignore "not found")
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Unmarshaling into Structs ===
|
||||
|
||||
type Config struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Host string `mapstructure:"host"`
|
||||
LogLevel string `mapstructure:"log-level"`
|
||||
Database struct {
|
||||
DSN string `mapstructure:"dsn"`
|
||||
MaxConn int `mapstructure:"max-conn"`
|
||||
} `mapstructure:"database"`
|
||||
}
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("unmarshaling config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// === Watching Config File Changes ===
|
||||
// For long-running CLIs (servers, daemons):
|
||||
|
||||
func watchConfig() {
|
||||
viper.OnConfigChange(func(e fsnotify.Event) {
|
||||
slog.Info("config file changed", "file", e.Name)
|
||||
// re-read and apply config
|
||||
})
|
||||
viper.WatchConfig()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/you/myapp/cmd"
|
||||
)
|
||||
|
||||
// Pattern for mapping errors to exit codes.
|
||||
func mainWithExitCodes() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
// Cobra already printed the error via RunE
|
||||
var exitErr *ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
os.Exit(exitErr.Code)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string { return e.Err.Error() }
|
||||
func (e *ExitError) Unwrap() error { return e.Err }
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func flagExamples() {
|
||||
// === Persistent vs Local ===
|
||||
|
||||
// Persistent — inherited by all subcommands
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path")
|
||||
|
||||
// Local — only for this command
|
||||
serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
|
||||
|
||||
// === Required Flags ===
|
||||
|
||||
serveCmd.Flags().String("host", "", "hostname to bind to")
|
||||
serveCmd.MarkFlagRequired("host")
|
||||
|
||||
// Mutually exclusive flags
|
||||
rootCmd.MarkFlagsMutuallyExclusive("json", "yaml")
|
||||
|
||||
// At least one required
|
||||
rootCmd.MarkFlagsOneRequired("output-file", "stdout")
|
||||
|
||||
// === Flag Validation with RegisterFlagCompletionFunc ===
|
||||
|
||||
serveCmd.Flags().String("env", "dev", "environment (dev, staging, prod)")
|
||||
serveCmd.RegisterFlagCompletionFunc("env", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"dev", "staging", "prod"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
|
||||
// === Always Bind Flags to Viper ===
|
||||
// This ensures viper.GetInt("port") returns the flag value, env var MYAPP_PORT,
|
||||
// or config file value — whichever has highest precedence.
|
||||
|
||||
serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
|
||||
viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// cmd/myapp/main.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// === stdout vs stderr ===
|
||||
// stdout: Program output (data, results). This is what gets piped.
|
||||
// stderr: Logs, progress, errors, diagnostics. Not piped by default.
|
||||
|
||||
func outputExample(cmd *cobra.Command, result string, err error) {
|
||||
// Output data to stdout (pipeable)
|
||||
fmt.Fprintln(cmd.OutOrStdout(), result)
|
||||
|
||||
// Logs and errors to stderr (use slog)
|
||||
// slog.Error("operation failed", "error", err)
|
||||
}
|
||||
|
||||
// === Detecting Pipe vs Terminal ===
|
||||
|
||||
func isTerminal() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// === Machine-Readable Output ===
|
||||
// Support --output flag for different output formats.
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
func printUsers(cmd *cobra.Command, users []User) error {
|
||||
format, _ := cmd.Flags().GetString("output")
|
||||
switch format {
|
||||
case "json":
|
||||
enc := json.NewEncoder(cmd.OutOrStdout())
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(users)
|
||||
case "plain":
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\n", u.ID, u.Name)
|
||||
}
|
||||
default: // "table"
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME")
|
||||
for _, u := range users {
|
||||
fmt.Fprintf(w, "%s\t%s\n", u.ID, u.Name)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Colors ===
|
||||
// Use fatih/color — it auto-disables when output is not a terminal.
|
||||
|
||||
func colorExamples(cmd *cobra.Command, env string, err error) {
|
||||
color.Green("Success: deployed to %s", env)
|
||||
color.Red("Error: %v", err)
|
||||
|
||||
// Or for reusable styles
|
||||
success := color.New(color.FgGreen, color.Bold).SprintFunc()
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s deployed\n", success("v1.2.3"))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// cmd/myapp/root.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "myapp",
|
||||
Short: "A brief description of your application",
|
||||
Long: "A longer description with usage examples.",
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return initConfig()
|
||||
},
|
||||
SilenceUsage: true, // don't print usage on errors from RunE
|
||||
SilenceErrors: true, // handle error printing yourself
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.myapp.yaml)")
|
||||
rootCmd.PersistentFlags().String("log-level", "info", "log level (debug, info, warn, error)")
|
||||
viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level"))
|
||||
}
|
||||
|
||||
func initConfig() error {
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding home directory: %w", err)
|
||||
}
|
||||
viper.AddConfigPath(home)
|
||||
viper.AddConfigPath(".")
|
||||
viper.SetConfigName(".myapp")
|
||||
viper.SetConfigType("yaml")
|
||||
}
|
||||
|
||||
viper.SetEnvPrefix("MYAPP")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up logging based on config
|
||||
level := slog.LevelInfo
|
||||
switch strings.ToLower(viper.GetString("log-level")) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// cmd/myapp/serve.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the HTTP server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
port := viper.GetInt("port")
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "listening on :%d\n", port)
|
||||
// start server...
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
|
||||
viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
|
||||
}
|
||||
|
||||
// For command groups, use AddGroup and set GroupID on commands:
|
||||
//
|
||||
// rootCmd.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"})
|
||||
// serveCmd.GroupID = "management"
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Use signal.NotifyContext to propagate cancellation through context.
|
||||
var serveWithSignalCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
srv := &http.Server{Addr: ":8080"}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
slog.Info("server starting", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return fmt.Errorf("server failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// cmd/myapp/version.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Set via ldflags
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "myapp %s (commit: %s, built: %s)\n", version, commit, date)
|
||||
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "go: %s\n", info.GoVersion)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
// Build with:
|
||||
//
|
||||
// go build -ldflags "-X github.com/you/myapp/cmd/myapp.version=1.2.3 \
|
||||
// -X github.com/you/myapp/cmd/myapp.commit=$(git rev-parse --short HEAD) \
|
||||
// -X github.com/you/myapp/cmd/myapp.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
// -o bin/myapp ./cmd/myapp
|
||||
@@ -0,0 +1,342 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "minimal-main-and-execute",
|
||||
"description": "Tests that main.go is minimal and only calls Execute(), with os.Exit handled in main not inside commands",
|
||||
"prompt": "Create the entry point for a Go CLI application called 'deploy' using Cobra. The app should have a root command and a 'push' subcommand. Write main.go and root.go.",
|
||||
"trap": "Model puts configuration logic, flag parsing, or complex setup directly in main.go instead of keeping it minimal. May also call os.Exit inside RunE instead of returning errors.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "1.1",
|
||||
"text": "main.go only calls Execute() (or rootCmd.Execute()) and os.Exit on error — no configuration, flag setup, or business logic in main()"
|
||||
},
|
||||
{
|
||||
"id": "1.2",
|
||||
"text": "The root command sets SilenceUsage: true to prevent printing full usage text on every error"
|
||||
},
|
||||
{
|
||||
"id": "1.3",
|
||||
"text": "The root command sets SilenceErrors: true to control error output format"
|
||||
},
|
||||
{
|
||||
"id": "1.4",
|
||||
"text": "Subcommands do NOT call os.Exit() inside RunE — they return errors and let main() decide the exit code"
|
||||
},
|
||||
{
|
||||
"id": "1.5",
|
||||
"text": "The push subcommand is registered via rootCmd.AddCommand() in an init() function or setup function"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "viper-config-layering",
|
||||
"description": "Tests proper Viper configuration precedence: flags > env > config file > defaults, with env prefix and config-file-not-required",
|
||||
"prompt": "I'm building a Go CLI server tool with Cobra. It needs a --port flag (default 3000) that can also be set via the MYSERVER_PORT env var or a config file at ~/.myserver.yaml. Write the configuration setup code.",
|
||||
"trap": "Model doesn't bind flags to Viper (so flags and env/config are disconnected), forgets SetEnvPrefix (causing env var collisions), or crashes when no config file exists instead of ignoring ConfigFileNotFoundError.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "2.1",
|
||||
"text": "Calls viper.BindPFlag to bind the port flag to Viper, ensuring viper.GetInt('port') returns the flag value when set"
|
||||
},
|
||||
{
|
||||
"id": "2.2",
|
||||
"text": "Sets an env prefix with viper.SetEnvPrefix('MYSERVER' or similar) to namespace env vars and avoid collisions"
|
||||
},
|
||||
{
|
||||
"id": "2.3",
|
||||
"text": "Calls viper.AutomaticEnv() to enable automatic env var binding"
|
||||
},
|
||||
{
|
||||
"id": "2.4",
|
||||
"text": "Handles viper.ConfigFileNotFoundError gracefully (ignores it) — config file is optional, not crashing when absent"
|
||||
},
|
||||
{
|
||||
"id": "2.5",
|
||||
"text": "The precedence order is correct: CLI flags > environment variables > config file > defaults"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "persistent-pre-run-config-init",
|
||||
"description": "Tests that configuration initialization happens in PersistentPreRunE on the root command",
|
||||
"prompt": "My Go CLI has a root command and three subcommands (serve, migrate, status). All of them need access to a database DSN from config. Where should I initialize the configuration so all subcommands have access? Write the code.",
|
||||
"trap": "Model initializes config inside each subcommand's RunE (duplicating logic), or uses a global init() function instead of PersistentPreRunE, or puts it in cobra.OnInitialize without connecting it to the command tree properly.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "3.1",
|
||||
"text": "Configuration initialization happens in PersistentPreRunE on the root command — this ensures it runs before every subcommand"
|
||||
},
|
||||
{
|
||||
"id": "3.2",
|
||||
"text": "Config init is NOT duplicated inside each subcommand's RunE — it happens once in the root"
|
||||
},
|
||||
{
|
||||
"id": "3.3",
|
||||
"text": "The --config flag (or equivalent) is a persistent flag on the root command so all subcommands inherit it"
|
||||
},
|
||||
{
|
||||
"id": "3.4",
|
||||
"text": "Environment variables use a replacer (SetEnvKeyReplacer) to handle hyphens-to-underscores mapping (e.g., log-level becomes LOG_LEVEL)"
|
||||
},
|
||||
{
|
||||
"id": "3.5",
|
||||
"text": "Logging is configured to write to stderr, not stdout"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "stdout-vs-stderr-separation",
|
||||
"description": "Tests that program output goes to stdout and diagnostics/errors/logs go to stderr",
|
||||
"prompt": "Write a Go CLI command 'list-users' using Cobra that fetches users from a database and prints them. It should log progress messages, handle errors, and support being piped to other commands (e.g., `myapp list-users | grep admin`). Write the RunE function.",
|
||||
"trap": "Model writes log messages, error messages, or progress indicators to stdout (using fmt.Println) instead of stderr, which would corrupt piped output. May also use os.Stdout directly instead of cmd.OutOrStdout().",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "4.1",
|
||||
"text": "Program output (the user list) goes to stdout via cmd.OutOrStdout() or fmt.Fprint(cmd.OutOrStdout(), ...) — NOT os.Stdout directly"
|
||||
},
|
||||
{
|
||||
"id": "4.2",
|
||||
"text": "Log messages, progress indicators, or diagnostic output go to stderr (via slog, log, or fmt.Fprint(os.Stderr, ...)) — NOT stdout"
|
||||
},
|
||||
{
|
||||
"id": "4.3",
|
||||
"text": "Error messages go to stderr (via cmd.ErrOrStderr() or os.Stderr), not mixed with program output on stdout"
|
||||
},
|
||||
{
|
||||
"id": "4.4",
|
||||
"text": "Uses cmd.OutOrStdout() instead of os.Stdout directly, enabling test capture"
|
||||
},
|
||||
{
|
||||
"id": "4.5",
|
||||
"text": "The function returns an error from RunE rather than calling os.Exit() or log.Fatal() on failure"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "version-ldflags-injection",
|
||||
"description": "Tests that version info is injected at build time via ldflags, not hardcoded",
|
||||
"prompt": "Add a 'version' command to my Go CLI app that shows the version, git commit, and build date. How should I handle the version string?",
|
||||
"trap": "Model hardcodes the version string as a constant (const version = \"1.0.0\") instead of using ldflags injection. May also not include the build command example.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "5.1",
|
||||
"text": "Version, commit, and date are package-level variables (var, not const) with placeholder defaults like 'dev' or 'unknown'"
|
||||
},
|
||||
{
|
||||
"id": "5.2",
|
||||
"text": "Shows or explains the -ldflags '-X ...' build command for injecting values at compile time"
|
||||
},
|
||||
{
|
||||
"id": "5.3",
|
||||
"text": "The version command uses cmd.OutOrStdout() for output, not fmt.Println or os.Stdout directly"
|
||||
},
|
||||
{
|
||||
"id": "5.4",
|
||||
"text": "Version is NOT hardcoded as a const string that would get out of sync with git tags"
|
||||
},
|
||||
{
|
||||
"id": "5.5",
|
||||
"text": "Optionally includes runtime/debug.ReadBuildInfo() as a fallback or supplement for Go module version info"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "exit-code-conventions",
|
||||
"description": "Tests proper Unix exit code mapping — 0 for success, 1 for general error, 2 for usage errors",
|
||||
"prompt": "My Go CLI tool needs to report different exit codes for different failure types: invalid arguments, missing config file, network timeout, and successful completion. Write the error handling and exit code logic in main.go.",
|
||||
"trap": "Model uses the same exit code (1) for all errors, or calls os.Exit deep inside command handlers instead of in main(). May also use non-standard exit codes.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "6.1",
|
||||
"text": "Exit code 0 for success, exit code 1 for general runtime errors, exit code 2 for usage/argument errors — follows Unix conventions"
|
||||
},
|
||||
{
|
||||
"id": "6.2",
|
||||
"text": "os.Exit() is called only in main(), not inside RunE functions or command handlers"
|
||||
},
|
||||
{
|
||||
"id": "6.3",
|
||||
"text": "Uses a typed error or error wrapping pattern (like ExitError with a Code field) to propagate exit codes from commands to main"
|
||||
},
|
||||
{
|
||||
"id": "6.4",
|
||||
"text": "Errors are returned from commands, not swallowed with os.Exit() calls that skip deferred cleanup"
|
||||
},
|
||||
{
|
||||
"id": "6.5",
|
||||
"text": "Different error categories map to different exit codes, not all errors producing exit code 1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "signal-handling-with-context",
|
||||
"description": "Tests that signal handling uses signal.NotifyContext for context-based cancellation",
|
||||
"prompt": "My Go CLI has a long-running 'serve' command that starts an HTTP server. I need graceful shutdown when the user presses Ctrl+C. Write the signal handling code.",
|
||||
"trap": "Model uses a raw signal channel with signal.Notify instead of signal.NotifyContext, missing context propagation. May also not handle the shutdown timeout or forget SIGTERM.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "7.1",
|
||||
"text": "Uses signal.NotifyContext to propagate cancellation through context — NOT a raw channel with signal.Notify and manual select"
|
||||
},
|
||||
{
|
||||
"id": "7.2",
|
||||
"text": "Handles both os.Interrupt (Ctrl+C / SIGINT) and syscall.SIGTERM (container orchestrators)"
|
||||
},
|
||||
{
|
||||
"id": "7.3",
|
||||
"text": "Creates a shutdown timeout context (e.g., 10-30 seconds) for graceful shutdown, not blocking indefinitely"
|
||||
},
|
||||
{
|
||||
"id": "7.4",
|
||||
"text": "Calls srv.Shutdown(ctx) for graceful HTTP server shutdown, not srv.Close() which drops in-flight requests"
|
||||
},
|
||||
{
|
||||
"id": "7.5",
|
||||
"text": "Distinguishes http.ErrServerClosed (normal shutdown) from unexpected server errors"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "flag-binding-and-constraints",
|
||||
"description": "Tests flag patterns: persistent vs local, required flags, mutual exclusion, and Viper binding",
|
||||
"prompt": "My Go CLI 'deploy' command needs these flags:\n- --env (required, must be one of: dev, staging, prod)\n- --tag (required, the docker image tag)\n- --dry-run and --force (mutually exclusive)\n- --verbose (available on all commands, not just deploy)\n\nWrite the flag setup code using Cobra.",
|
||||
"trap": "Model makes --verbose a local flag instead of persistent, doesn't use MarkFlagsMutuallyExclusive for dry-run/force, or forgets to bind flags to Viper.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "8.1",
|
||||
"text": "--verbose is a persistent flag (PersistentFlags) on the root command, not a local flag on deploy — it needs to be available on all commands"
|
||||
},
|
||||
{
|
||||
"id": "8.2",
|
||||
"text": "Uses MarkFlagRequired for --env and --tag flags"
|
||||
},
|
||||
{
|
||||
"id": "8.3",
|
||||
"text": "Uses MarkFlagsMutuallyExclusive for --dry-run and --force"
|
||||
},
|
||||
{
|
||||
"id": "8.4",
|
||||
"text": "Uses RegisterFlagCompletionFunc to provide completion values for --env (dev, staging, prod)"
|
||||
},
|
||||
{
|
||||
"id": "8.5",
|
||||
"text": "Binds configurable flags to Viper with viper.BindPFlag so they can be set via env vars or config file"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "argument-validation",
|
||||
"description": "Tests use of Cobra's built-in argument validators instead of manual validation in RunE",
|
||||
"prompt": "I have three Cobra commands:\n1. 'status' — takes no arguments\n2. 'deploy' — takes exactly one argument (the service name)\n3. 'scale' — takes 2-3 arguments (service, replica count, optional region)\n\nHow should I validate the arguments for each command?",
|
||||
"trap": "Model manually validates len(args) inside RunE instead of using Cobra's declarative validators (cobra.NoArgs, cobra.ExactArgs, cobra.RangeArgs). May also use custom validation where built-in validators suffice.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "9.1",
|
||||
"text": "Uses cobra.NoArgs for the status command — not manual len(args) == 0 check"
|
||||
},
|
||||
{
|
||||
"id": "9.2",
|
||||
"text": "Uses cobra.ExactArgs(1) for the deploy command — not manual len(args) != 1 check"
|
||||
},
|
||||
{
|
||||
"id": "9.3",
|
||||
"text": "Uses cobra.RangeArgs(2, 3) for the scale command — not manual len(args) < 2 || len(args) > 3 check"
|
||||
},
|
||||
{
|
||||
"id": "9.4",
|
||||
"text": "Validators are set on the Args field of the command struct, not implemented inside RunE"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "cli-testing-pattern",
|
||||
"description": "Tests that CLI commands are tested by executing programmatically with captured output",
|
||||
"prompt": "Write tests for a Cobra CLI command 'greet' that takes a --name flag and prints a greeting. Test the default behavior and a custom name. Show the test helper and test function.",
|
||||
"trap": "Model tests by running os.exec on the compiled binary instead of executing commands programmatically. May also not capture output via cmd.SetOut/cmd.SetErr buffers.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "10.1",
|
||||
"text": "Creates an executeCommand helper that sets up a buffer, calls cmd.SetOut(buf) and cmd.SetErr(buf), sets args, and executes"
|
||||
},
|
||||
{
|
||||
"id": "10.2",
|
||||
"text": "Tests are table-driven with multiple cases (at least default and custom name)"
|
||||
},
|
||||
{
|
||||
"id": "10.3",
|
||||
"text": "Uses cmd.SetArgs() to pass arguments programmatically — not os/exec.Command"
|
||||
},
|
||||
{
|
||||
"id": "10.4",
|
||||
"text": "Captures output via a bytes.Buffer set on the command — not by redirecting os.Stdout"
|
||||
},
|
||||
{
|
||||
"id": "10.5",
|
||||
"text": "Tests check both the output string and the error return value"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "machine-readable-output-format",
|
||||
"description": "Tests support for --output flag with multiple formats (json/table/plain) for scriptability",
|
||||
"prompt": "My Go CLI command 'list-services' shows running services. I need it to support both human-readable and machine-parseable output for scripting. What's the best approach?",
|
||||
"trap": "Model only supports a single output format, or adds a --json boolean flag instead of a flexible --output format flag. May also not use tabwriter for table output.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "11.1",
|
||||
"text": "Supports an --output flag with at least json and table formats (not just a --json boolean toggle)"
|
||||
},
|
||||
{
|
||||
"id": "11.2",
|
||||
"text": "JSON output uses encoding/json encoder writing to cmd.OutOrStdout()"
|
||||
},
|
||||
{
|
||||
"id": "11.3",
|
||||
"text": "Table output uses text/tabwriter or similar for aligned columns — not ad-hoc spacing"
|
||||
},
|
||||
{
|
||||
"id": "11.4",
|
||||
"text": "The default format is human-readable (table), with JSON/plain as opt-in machine formats"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "shell-completion-setup",
|
||||
"description": "Tests proper shell completion command and custom completions for flags",
|
||||
"prompt": "Add shell completion support to my Go CLI built with Cobra. I want users to be able to run 'myapp completion bash' to generate a completion script. Also, the --env flag should suggest 'dev', 'staging', 'prod' during tab completion.",
|
||||
"trap": "Model implements completion from scratch instead of using Cobra's built-in generators. May forget the ValidArgs field or RegisterFlagCompletionFunc for custom completions.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "12.1",
|
||||
"text": "Creates a 'completion' subcommand that supports bash, zsh, fish, and powershell as arguments"
|
||||
},
|
||||
{
|
||||
"id": "12.2",
|
||||
"text": "Uses Cobra's built-in completion generators (GenBashCompletionV2, GenZshCompletion, GenFishCompletion, GenPowerShellCompletionWithDesc) — not custom completion scripts"
|
||||
},
|
||||
{
|
||||
"id": "12.3",
|
||||
"text": "Uses RegisterFlagCompletionFunc for the --env flag to suggest dev/staging/prod values"
|
||||
},
|
||||
{
|
||||
"id": "12.4",
|
||||
"text": "Uses cobra.ExactValidArgs or ValidArgs to validate completion arguments for the completion command itself"
|
||||
},
|
||||
{
|
||||
"id": "12.5",
|
||||
"text": "Returns cobra.ShellCompDirectiveNoFileComp for flags that don't accept file paths"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: golang-code-style
|
||||
description: Golang code style, formatting and conventions. Use when writing code, reviewing style, configuring linters, writing comments, or establishing project standards.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-code-style` skill takes precedence.
|
||||
|
||||
# Go Code Style
|
||||
|
||||
Style rules that require human judgment — linters handle formatting, this skill handles clarity. For naming see `samber/cc-skills-golang@golang-naming` skill; for design patterns see `samber/cc-skills-golang@golang-design-patterns` skill; for struct/interface design see `samber/cc-skills-golang@golang-structs-interfaces` skill.
|
||||
|
||||
> "Clear is better than clever." — Go Proverbs
|
||||
|
||||
To ignore a rule, just add a comment to the code.
|
||||
|
||||
## Line Length & Breaking
|
||||
|
||||
No rigid line limit, but lines beyond ~120 characters MUST be broken. Break at **semantic boundaries**, not arbitrary column counts. Function calls with 4+ arguments MUST use one argument per line — even when the prompt asks for single-line code:
|
||||
|
||||
```go
|
||||
// Good — each argument on its own line, closing paren separate
|
||||
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleUsers(
|
||||
w,
|
||||
r,
|
||||
serviceName,
|
||||
cfg,
|
||||
logger,
|
||||
authMiddleware,
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
When a function signature is too long, the real fix is often **fewer parameters** (use an options struct) rather than better line wrapping. For multi-line signatures, put each parameter on its own line.
|
||||
|
||||
## Variable Declarations
|
||||
|
||||
SHOULD use `:=` for non-zero values, `var` for zero-value initialization. The form signals intent: `var` means "this starts at zero."
|
||||
|
||||
```go
|
||||
var count int // zero value, set later
|
||||
name := "default" // non-zero, := is appropriate
|
||||
var buf bytes.Buffer // zero value is ready to use
|
||||
```
|
||||
|
||||
### Slice & Map Initialization
|
||||
|
||||
Slices and maps MUST be initialized explicitly, never nil. Nil maps panic on write; nil slices serialize to `null` in JSON (vs `[]` for empty slices), surprising API consumers.
|
||||
|
||||
```go
|
||||
users := []User{} // always initialized
|
||||
m := map[string]int{} // always initialized
|
||||
users := make([]User, 0, len(ids)) // preallocate when capacity is known
|
||||
m := make(map[string]int, len(items)) // preallocate when size is known
|
||||
```
|
||||
|
||||
Do not preallocate speculatively — `make([]T, 0, 1000)` wastes memory when the common case is 10 items.
|
||||
|
||||
### Composite Literals
|
||||
|
||||
Composite literals MUST use field names — positional fields break when the type adds or reorders fields:
|
||||
|
||||
```go
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
```
|
||||
|
||||
## Control Flow
|
||||
|
||||
### Reduce Nesting
|
||||
|
||||
Errors and edge cases MUST be handled first (early return). Keep the happy path at minimal indentation:
|
||||
|
||||
```go
|
||||
func process(data []byte) (*Result, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty data")
|
||||
}
|
||||
|
||||
parsed, err := parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing: %w", err)
|
||||
}
|
||||
|
||||
return transform(parsed), nil
|
||||
}
|
||||
```
|
||||
|
||||
### Eliminate Unnecessary `else`
|
||||
|
||||
When the `if` body ends with `return`/`break`/`continue`, the `else` MUST be dropped. Use default-then-override for simple assignments — assign a default, then override with independent conditions or a `switch`:
|
||||
|
||||
```go
|
||||
// Good — default-then-override with switch (cleanest for mutually exclusive overrides)
|
||||
level := slog.LevelInfo
|
||||
switch {
|
||||
case debug:
|
||||
level = slog.LevelDebug
|
||||
case verbose:
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
|
||||
// Bad — else-if chain hides that there's a default
|
||||
if debug {
|
||||
level = slog.LevelDebug
|
||||
} else if verbose {
|
||||
level = slog.LevelWarn
|
||||
} else {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Conditions & Init Scope
|
||||
|
||||
When an `if` condition has 3+ operands, MUST extract into named booleans — a wall of `||` is unreadable and hides business logic. Keep expensive checks inline for short-circuit benefit. [Details](./references/details.md)
|
||||
|
||||
```go
|
||||
// Good — named booleans make intent clear
|
||||
isAdmin := user.Role == RoleAdmin
|
||||
isOwner := resource.OwnerID == user.ID
|
||||
isPublicVerified := resource.IsPublic && user.IsVerified
|
||||
if isAdmin || isOwner || isPublicVerified || permissions.Contains(PermOverride) {
|
||||
allow()
|
||||
}
|
||||
```
|
||||
|
||||
Scope variables to `if` blocks when only needed for the check:
|
||||
|
||||
```go
|
||||
if err := validate(input); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### Switch Over If-Else Chains
|
||||
|
||||
When comparing the same variable multiple times, prefer `switch`:
|
||||
|
||||
```go
|
||||
switch status {
|
||||
case StatusActive:
|
||||
activate()
|
||||
case StatusInactive:
|
||||
deactivate()
|
||||
default:
|
||||
panic(fmt.Sprintf("unexpected status: %d", status))
|
||||
}
|
||||
```
|
||||
|
||||
## Function Design
|
||||
|
||||
- Functions SHOULD be **short and focused** — one function, one job.
|
||||
- Functions SHOULD have **≤4 parameters**. Beyond that, use an options struct (see `samber/cc-skills-golang@golang-design-patterns` skill).
|
||||
- **Parameter order**: `context.Context` first, then inputs, then output destinations.
|
||||
- Naked returns help in very short functions (1-3 lines) where return values are obvious, but become confusing when readers must scroll to find what's returned — name returns explicitly in longer functions.
|
||||
|
||||
```go
|
||||
func FetchUser(ctx context.Context, id string) (*User, error)
|
||||
func SendEmail(ctx context.Context, msg EmailMessage) error // grouped into struct
|
||||
```
|
||||
|
||||
### Prefer `range` for Iteration
|
||||
|
||||
SHOULD use `range` over index-based loops. Use `range n` (Go 1.22+) for simple counting.
|
||||
|
||||
```go
|
||||
for _, user := range users {
|
||||
process(user)
|
||||
}
|
||||
```
|
||||
|
||||
## Value vs Pointer Arguments
|
||||
|
||||
Pass small types (`string`, `int`, `bool`, `time.Time`) by value. Use pointers when mutating, for large structs (~128+ bytes), or when nil is meaningful. [Details](./references/details.md)
|
||||
|
||||
## Code Organization Within Files
|
||||
|
||||
- **Group related declarations**: type, constructor, methods together
|
||||
- **Order**: package doc, imports, constants, types, constructors, methods, helpers
|
||||
- **One primary type per file** when it has significant methods
|
||||
- **Blank imports** (`_ "pkg"`) register side effects (init functions). Restricting them to `main` and test packages makes side effects visible at the application root, not hidden in library code
|
||||
- **Dot imports** pollute the namespace and make it impossible to tell where a name comes from — never use in library code
|
||||
- **Unexport aggressively** — you can always export later; unexporting is a breaking change
|
||||
|
||||
## String Handling
|
||||
|
||||
Use `strconv` for simple conversions (faster), `fmt.Sprintf` for complex formatting. Use `%q` in error messages to make string boundaries visible. Use `strings.Builder` for loops, `+` for simple concatenation.
|
||||
|
||||
## Type Conversions
|
||||
|
||||
Prefer explicit, narrow conversions. Use generics over `any` when a concrete type will do:
|
||||
|
||||
```go
|
||||
func Contains[T comparable](slice []T, target T) bool // not []any
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **"A little copying is better than a little dependency"**
|
||||
- **Use `slices` and `maps` standard packages**; for filter/group-by/chunk, use `github.com/samber/lo`
|
||||
- **"Reflection is never clear"** — avoid `reflect` unless necessary
|
||||
- **Don't abstract prematurely** — extract when the pattern is stable
|
||||
- **Minimize public surface** — every exported name is a commitment
|
||||
|
||||
## Parallelizing Code Style Reviews
|
||||
|
||||
When reviewing code style across a large codebase, use up to 5 parallel sub-agents (via the Agent tool), each targeting an independent style concern (e.g. control flow, function design, variable declarations, string handling, code organization).
|
||||
|
||||
## Enforce with Linters
|
||||
|
||||
Many rules are enforced automatically: `gofmt`, `gofumpt`, `goimports`, `gocritic`, `revive`, `wsl_v5`. → See the `samber/cc-skills-golang@golang-linter` skill.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See the `samber/cc-skills-golang@golang-naming` skill for identifier naming conventions
|
||||
- → See the `samber/cc-skills-golang@golang-structs-interfaces` skill for pointer vs value receivers, interface design
|
||||
- → See the `samber/cc-skills-golang@golang-design-patterns` skill for functional options, builders, constructors
|
||||
- → See the `samber/cc-skills-golang@golang-linter` skill for automated formatting enforcement
|
||||
@@ -0,0 +1,550 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "zero-value-implicit",
|
||||
"description": "Zero-value fields not explicitly initialized; non-zero uses :=",
|
||||
"prompt": "Write a Go file `cache.go` in package `cache`. Create a Cache struct with fields for max size, current count, hit count, and miss count (all integers). Add a constructor that takes max size. Add a Get method that increments hit or miss count and a Set method. Initialize a default TTL duration variable and a done channel in the constructor.",
|
||||
"trap": "Model explicitly initializes zero-value fields (currentCount := 0) or uses var for non-zero assignments, obscuring the distinction between intentional zero and meaningful value",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "1.1",
|
||||
"text": "Zero-value integer fields (currentCount, hitCount, missCount) are NOT explicitly initialized in the constructor — Go zero values are relied upon, or if local variables are declared separately, they use var (not :=) to signal zero-value intent"
|
||||
},
|
||||
{
|
||||
"id": "1.2",
|
||||
"text": "Non-zero assignments (like maxSize from parameter, or default TTL = 5*time.Minute) use := short declaration form"
|
||||
},
|
||||
{
|
||||
"id": "1.3",
|
||||
"text": "The done channel is created with make(), not left as nil"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "empty-slice-map-not-nil",
|
||||
"description": "Empty collections initialized as []T{} or make(), never nil",
|
||||
"prompt": "Write a Go file `handler.go` in package `api`. Create a Handler struct. Add a method ListUsers that returns a slice of User structs (define User with Name and Email fields). Add a method GetTags that returns a map[string]string. Both methods should return empty collections when there's no data. Add a method BuildResponse that takes a slice of results and a map of metadata and processes them.",
|
||||
"trap": "Model returns nil slices/maps or uses uninitialized var declarations for empty collections, causing nil != empty issues in JSON serialization and caller code",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "2.1",
|
||||
"text": "Empty slice return uses []User{} or make([]User, 0) — never returns nil or an uninitialized var declaration without assignment"
|
||||
},
|
||||
{
|
||||
"id": "2.2",
|
||||
"text": "Empty map return uses map[string]string{} or make(map[string]string) — never returns nil or an uninitialized var declaration without assignment"
|
||||
},
|
||||
{
|
||||
"id": "2.3",
|
||||
"text": "When capacity is known (e.g., from len(input)), make() with capacity hint is used for preallocating slices or maps"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "named-struct-fields",
|
||||
"description": "All struct literals use named fields, not positional",
|
||||
"prompt": "Write a Go file `server.go` in package `server`. Create an HTTP server using net/http. Configure it with address ':8080', read timeout 5 seconds, write timeout 10 seconds, idle timeout 120 seconds, and max header bytes 1MB. Also create a tls.Config with min TLS version 1.2 and a list of cipher suites.",
|
||||
"trap": "Model uses positional struct literals like http.Server{\":8080\", 5*time.Second, 10*time.Second, ...}, which silently breaks when struct fields are reordered",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "3.1",
|
||||
"text": "http.Server literal uses named fields (Addr:, ReadTimeout:, WriteTimeout:, IdleTimeout:, MaxHeaderBytes:) — not positional arguments"
|
||||
},
|
||||
{
|
||||
"id": "3.2",
|
||||
"text": "tls.Config literal uses named fields (MinVersion:, CipherSuites:) — not positional arguments"
|
||||
},
|
||||
{
|
||||
"id": "3.3",
|
||||
"text": "No struct literal in the file uses positional field syntax"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "early-return-not-nested",
|
||||
"description": "Validation uses early returns; happy path at minimal indentation despite prompt asking for nesting",
|
||||
"prompt": "Write a Go function ProcessOrder in package `orders` that takes an order struct (define it with fields: ID string, Items []Item, Status string, CustomerID string). The function should validate that the ID is not empty, that there is at least one item, that the status is 'pending', that the customer exists (simulate with a lookup function), compute the total price, apply a discount if total > 100, and return the final total with an error. Write it with deeply nested if-else blocks.",
|
||||
"trap": "Model follows the prompt's instruction to use deeply nested if-else blocks, burying the happy path inside 4+ levels of indentation",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "4.1",
|
||||
"text": "Validation checks (empty ID, no items, wrong status) use early return pattern — each check returns an error immediately rather than nesting the rest of the function inside an else block"
|
||||
},
|
||||
{
|
||||
"id": "4.2",
|
||||
"text": "The happy path (compute total, apply discount, return) is at the top level of the function body (indentation level 1), not nested inside multiple if blocks"
|
||||
},
|
||||
{
|
||||
"id": "4.3",
|
||||
"text": "The function has at most 2 levels of indentation for the main logic (excluding the error-check if blocks which return early)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "no-else-after-return",
|
||||
"description": "No else after return; default-then-override for simple assignments",
|
||||
"prompt": "Write a Go function GetUserRole in package `auth` that takes a user struct with IsAdmin bool, IsModerator bool, and IsVerified bool fields. Return a role string: if admin return 'admin', else if moderator return 'moderator', else if verified return 'member', else return 'guest'. Also write a function SetLogLevel that takes a verbose bool and a debug bool, and sets the log level appropriately using slog.",
|
||||
"trap": "Model uses else-if chains after return statements, adding unnecessary indentation and cognitive load",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "5.1",
|
||||
"text": "GetUserRole does NOT use else or else-if after a return statement — either uses early returns (if isAdmin { return 'admin' }; if isModerator { return 'moderator' }) or a switch statement"
|
||||
},
|
||||
{
|
||||
"id": "5.2",
|
||||
"text": "SetLogLevel uses the default-then-override pattern: assigns a default level first, then conditionally overrides with if (not if-else chains)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "switch-over-if-else-chain",
|
||||
"description": "Multi-branch comparisons use switch statements, not if-else chains",
|
||||
"prompt": "Write a Go function HandleEvent in package `events` that takes an event with a Type string field. The type can be 'click', 'scroll', 'keypress', 'hover', 'focus', or 'blur'. Each type should call a different handler function. Also write a function MapStatusCode that takes an int HTTP status code and returns a human-readable string for 200, 201, 204, 400, 401, 403, 404, 500, 502, 503.",
|
||||
"trap": "Model uses if-else chains for multi-branch string/int comparisons instead of switch statements",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "6.1",
|
||||
"text": "HandleEvent uses a switch statement on event.Type, not an if-else chain"
|
||||
},
|
||||
{
|
||||
"id": "6.2",
|
||||
"text": "MapStatusCode uses a switch statement on the status code, not an if-else chain"
|
||||
},
|
||||
{
|
||||
"id": "6.3",
|
||||
"text": "Both switch statements include a default case"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "options-struct-not-many-params",
|
||||
"description": "Groups params into options struct, context.Context first",
|
||||
"prompt": "Write a Go function SendNotification in package `notify` that sends a notification. It needs these parameters: ctx context.Context, userID string, message string, channel string (email/sms/push), priority int, retryCount int, dryRun bool, templateID string, metadata map[string]string, callback func(error). Put all parameters directly in the function signature.",
|
||||
"trap": "Model follows the prompt's instruction to put all 10 parameters directly in the function signature, creating an unusable API",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "7.1",
|
||||
"text": "context.Context is the first parameter in the function signature"
|
||||
},
|
||||
{
|
||||
"id": "7.2",
|
||||
"text": "The function uses an options struct (or similar grouping) to reduce the parameter count to 4 or fewer in the main function signature — not all 10 parameters listed individually"
|
||||
},
|
||||
{
|
||||
"id": "7.3",
|
||||
"text": "The options struct groups related configuration (channel, priority, retryCount, dryRun, templateID, metadata, callback) into a single parameter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "value-vs-pointer-params",
|
||||
"description": "Value params for small types; pointers only for mutation — not pointer everything",
|
||||
"prompt": "Write Go functions in package `users`: (1) FormatName that takes a first name string and last name string and returns the formatted full name, (2) UpdateAge that modifies a User struct's age field, (3) FindUser that takes a user ID string and returns a User pointer, (4) CompareUsers that checks if two User structs (each about 32 bytes with Name string and Age int) are equal. Use pointer parameters for all functions.",
|
||||
"trap": "Model follows the prompt's instruction to use pointer parameters for all functions, including small value types like string and int where pointers only add indirection",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "8.1",
|
||||
"text": "FormatName takes string parameters by value (not *string) — strings are small fixed-size types"
|
||||
},
|
||||
{
|
||||
"id": "8.2",
|
||||
"text": "UpdateAge takes a *User pointer parameter because it mutates the struct"
|
||||
},
|
||||
{
|
||||
"id": "8.3",
|
||||
"text": "CompareUsers takes User structs by value (not *User) because it only reads them and they are small (<128 bytes)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "file-declaration-order",
|
||||
"description": "Interface before struct, constructor before methods, helpers after exported methods",
|
||||
"prompt": "Write a Go file `repository.go` in package `repo`. Include: a Repository interface with methods Get, List, Create, Delete; a PostgresRepository struct implementing it; a constructor NewPostgresRepository; all four methods on PostgresRepository; two helper functions (buildQuery and scanRow); constants for default page size and max page size; and the necessary imports. Organize everything in a single file.",
|
||||
"trap": "Model scatters declarations in arbitrary order, mixing helpers between methods and placing structs before interfaces",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "9.1",
|
||||
"text": "File ordering follows: package clause, imports, constants, interface type, struct type, constructor, exported methods, unexported helpers — not randomly scattered"
|
||||
},
|
||||
{
|
||||
"id": "9.2",
|
||||
"text": "The interface (Repository) is declared before the struct (PostgresRepository) that implements it"
|
||||
},
|
||||
{
|
||||
"id": "9.3",
|
||||
"text": "Helper functions (buildQuery, scanRow) appear after the main type's methods, not before or interleaved between them"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "strconv-for-int-conversion",
|
||||
"description": "strconv.Itoa for int-to-string, strings.Builder for loop concatenation, fmt.Sprintf for complex formatting",
|
||||
"prompt": "Write a Go function GenerateReport in package `report` that: (1) converts an integer count to a string for a header, (2) converts a float64 percentage to a string with 2 decimal places, (3) builds a CSV-like output by concatenating 1000 rows in a loop where each row has name, age, and city columns, (4) formats a simple greeting with a name string variable.",
|
||||
"trap": "Model uses fmt.Sprintf(\"%d\") for simple int conversions and += string concatenation in the loop, missing the performance-correct alternatives",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "10.1",
|
||||
"text": "Integer-to-string conversion uses strconv.Itoa() or strconv.FormatInt() — not fmt.Sprintf('%d', n)"
|
||||
},
|
||||
{
|
||||
"id": "10.2",
|
||||
"text": "Loop-based string concatenation uses strings.Builder (or bytes.Buffer) — not repeated string concatenation with +"
|
||||
},
|
||||
{
|
||||
"id": "10.3",
|
||||
"text": "Complex formatting (like the percentage with specific format or the greeting) uses fmt.Sprintf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "named-boolean-conditions",
|
||||
"description": "Complex conditions extracted to named booleans before the if statement",
|
||||
"prompt": "Write a Go function CanAccessResource in package `authz` that checks if a user can access a resource. The conditions are: (1) user.Role == 'admin', OR (2) user.ID == resource.OwnerID, OR (3) resource.IsPublic && user.IsVerified, OR (4) user has a specific permission in their permission set (permissions.Contains('resource.access')). Write it as a single if statement with all conditions inline.",
|
||||
"trap": "Model follows the prompt's instruction to inline all conditions, making the if statement a wall of boolean logic that obscures the business intent",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "11.1",
|
||||
"text": "At least 2 of the conditions are extracted into named boolean variables (e.g., isAdmin, isOwner, isPublicAndVerified) before the if statement"
|
||||
},
|
||||
{
|
||||
"id": "11.2",
|
||||
"text": "The named booleans have descriptive names that explain the business meaning (not generic names like cond1, cond2)"
|
||||
},
|
||||
{
|
||||
"id": "11.3",
|
||||
"text": "If the permissions.Contains() call is kept inline (not extracted), the code preserves short-circuit evaluation benefit for the expensive operation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "line-breaks-at-semantic-boundaries",
|
||||
"description": "Long function calls broken at argument boundaries; closing paren on own line",
|
||||
"prompt": "Write a Go function RegisterRoutes in package `router` that takes an http.ServeMux and registers 6 routes. Each route handler calls a function with these parameters: the response writer, request, a long service name string, a config struct, a logger, and an auth middleware function. Write each handler registration as a single long line.",
|
||||
"trap": "Model follows the prompt's instruction to write each handler as a single long line, making diffs unreadable and code hard to scan",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "12.1",
|
||||
"text": "Function calls with many arguments are broken across multiple lines, with each argument (or logical group) on its own line"
|
||||
},
|
||||
{
|
||||
"id": "12.2",
|
||||
"text": "Line breaks occur at semantic boundaries (after commas between arguments) — not at arbitrary column positions mid-expression"
|
||||
},
|
||||
{
|
||||
"id": "12.3",
|
||||
"text": "Closing parentheses for multi-line function calls appear on their own line (Go trailing-comma style)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "never-nil-return-for-collection",
|
||||
"description": "Returns initialized empty collections, never nil, despite prompt saying nil is safe",
|
||||
"prompt": "Write a Go function GetUsers in package `api` that returns all users from a database. When there are no users, return nil. Also write GetMetadata that returns a map[string]string — return nil when empty. The caller will check for nil before using the result, so returning nil is safe and avoids an unnecessary allocation.",
|
||||
"trap": "Model follows the prompt's explicit instruction to return nil for empty collections, causing nil-slice JSON serialization (null instead of []) and nil map write panics",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "13.1",
|
||||
"text": "GetUsers returns []User{} or make([]User, 0) for the empty case — NOT nil — because nil slices serialize to null in JSON"
|
||||
},
|
||||
{
|
||||
"id": "13.2",
|
||||
"text": "GetMetadata returns map[string]string{} or make(map[string]string) for the empty case — NOT nil — because nil maps panic on write"
|
||||
},
|
||||
{
|
||||
"id": "13.3",
|
||||
"text": "Neither function contains 'return nil' as a success path (nil is only acceptable paired with an error)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "strconv-builder-override-prompt",
|
||||
"description": "Uses strconv.Itoa and strings.Builder even when prompt explicitly instructs fmt.Sprintf and +=",
|
||||
"prompt": "Write a Go function BuildReport in package `report` that takes a count int, a list of names []string, and a separator string. Use fmt.Sprintf(\"%d\", count) to convert the count to a string. Use result += name + separator inside a for loop to build the final string. This is simple and readable — don't over-engineer it.",
|
||||
"trap": "Model follows the prompt's explicit fmt.Sprintf and += instructions, choosing simplicity over correctness",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "14.1",
|
||||
"text": "Integer-to-string uses strconv.Itoa(count) — NOT fmt.Sprintf(\"%d\", count) — because strconv is faster for simple conversions"
|
||||
},
|
||||
{
|
||||
"id": "14.2",
|
||||
"text": "Loop concatenation uses strings.Builder (WriteString) — NOT result += in a loop — because += allocates a new string each iteration"
|
||||
},
|
||||
{
|
||||
"id": "14.3",
|
||||
"text": "The function does NOT contain 'result +=' or 'result = result +' inside any loop body"
|
||||
},
|
||||
{
|
||||
"id": "14.4",
|
||||
"text": "strings.Builder or bytes.Buffer is declared before the loop and result is obtained via .String() after"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "named-fields-override-positional-prompt",
|
||||
"description": "Uses named struct fields even when prompt explicitly requests positional syntax",
|
||||
"prompt": "Write Go structs in package `config` using positional field syntax for brevity. Create: Point{float64, float64}, Color{uint8, uint8, uint8, uint8}, and ServerConfig{string, int, bool, time.Duration, time.Duration, *tls.Config}. Positional syntax is shorter and the field order is obvious from the type definition.",
|
||||
"trap": "Model follows the prompt's positional syntax instruction, creating brittle literals that silently break when struct fields are added or reordered",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "15.1",
|
||||
"text": "Point literal uses named fields (X:, Y: or similar) — NOT Point{1.0, 2.0}"
|
||||
},
|
||||
{
|
||||
"id": "15.2",
|
||||
"text": "Color literal uses named fields (R:, G:, B:, A: or similar) — NOT Color{255, 128, 0, 255}"
|
||||
},
|
||||
{
|
||||
"id": "15.3",
|
||||
"text": "ServerConfig literal uses named fields — NOT positional — because positional breaks when fields are added or reordered"
|
||||
},
|
||||
{
|
||||
"id": "15.4",
|
||||
"text": "No struct literal in the file uses positional field syntax"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"name": "early-return-override-nested-prompt",
|
||||
"description": "Uses early returns despite prompt explicitly requesting nested if-else validation pattern",
|
||||
"prompt": "Write a Go function ValidateAndProcess in package `pipeline` that validates an input struct (check: non-empty Name, Age > 0, valid Email containing '@', Status is 'active' or 'pending', non-nil Permissions slice, at least one Permission). If all validations pass, compute a score, apply modifiers, and return the result. Use the traditional if-else pattern: if valid { if next_valid { if next { ... } else { error } } else { error } } else { error }. This makes the success path clear by keeping it inside the innermost block.",
|
||||
"trap": "Model follows the prompt's explicit nested if-else pattern, nesting the success path 5+ levels deep",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "16.1",
|
||||
"text": "Each validation check uses early return — if Name empty return error, if Age <= 0 return error, etc."
|
||||
},
|
||||
{
|
||||
"id": "16.2",
|
||||
"text": "The function does NOT contain nested if-else blocks for validation (no else clause after a validation if)"
|
||||
},
|
||||
{
|
||||
"id": "16.3",
|
||||
"text": "The happy path (score computation) is at indentation level 1, not nested inside 5+ levels"
|
||||
},
|
||||
{
|
||||
"id": "16.4",
|
||||
"text": "Maximum indentation depth for the main logic is 2 (function body + one loop or if)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"name": "context-first-options-struct",
|
||||
"description": "Moves ctx to first position, groups remaining params into options struct",
|
||||
"prompt": "Write a Go function CreateUser in package `service` with these parameters in this exact order: db *sql.DB, name string, email string, age int, role string, isActive bool, permissions []string, metadata map[string]string, avatar []byte, notifyOnCreate bool, ctx context.Context, logger *slog.Logger. Keep all parameters in the function signature exactly as listed — do not change the order or group them.",
|
||||
"trap": "Model keeps all 13 parameters in the specified order with ctx buried at position 11, following the prompt's explicit instruction",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "17.1",
|
||||
"text": "context.Context is the FIRST parameter, not buried in position 11"
|
||||
},
|
||||
{
|
||||
"id": "17.2",
|
||||
"text": "The function has at most 4 parameters in its signature (ctx + db + maybe 1-2 others + options struct)"
|
||||
},
|
||||
{
|
||||
"id": "17.3",
|
||||
"text": "An options struct groups the remaining parameters (name, email, age, role, isActive, permissions, metadata, avatar, notifyOnCreate)"
|
||||
},
|
||||
{
|
||||
"id": "17.4",
|
||||
"text": "The logger is either in the options struct or is a field on a Service struct, not a standalone parameter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"name": "value-types-not-pointer-params",
|
||||
"description": "Value params and returns for small types, overriding pointer-everything prompt",
|
||||
"prompt": "Write Go functions in package `math` that all use pointer parameters for maximum performance and zero-copy semantics: (1) Add(a *int, b *int) *int, (2) IsEven(n *int) *bool, (3) FormatDuration(d *time.Duration) *string, (4) Max(a *float64, b *float64) *float64, (5) Concat(a *string, b *string) *string. Pointers avoid copying values onto the stack.",
|
||||
"trap": "Model follows the prompt's pointer-everything instruction, adding pointer indirection overhead for types smaller than a pointer",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "18.1",
|
||||
"text": "Add takes (a int, b int) int — NOT pointer params — because int is 8 bytes, cheaper to copy than dereference"
|
||||
},
|
||||
{
|
||||
"id": "18.2",
|
||||
"text": "IsEven takes (n int) bool — NOT *int/*bool — because both are small value types"
|
||||
},
|
||||
{
|
||||
"id": "18.3",
|
||||
"text": "FormatDuration takes (d time.Duration) string — NOT *time.Duration/*string — Duration is an int64 alias"
|
||||
},
|
||||
{
|
||||
"id": "18.4",
|
||||
"text": "Concat takes (a string, b string) string — NOT *string — string is already a (pointer, length) pair internally"
|
||||
},
|
||||
{
|
||||
"id": "18.5",
|
||||
"text": "No function in the file uses pointer parameters for int, bool, string, float64, or time.Duration types"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"name": "named-conditions-override-inline-prompt",
|
||||
"description": "Extracts complex conditions to named booleans despite prompt explicitly saying NOT to",
|
||||
"prompt": "Write a Go function AssignPermission in package `rbac` that determines a user's effective permission level. Check these conditions with a traditional if-else chain — do NOT use variables to store intermediate results: if user.Role == 'superadmin' && !user.IsSuspended, or if user.Role == 'admin' && user.Department == resource.Department && user.TenantID == resource.TenantID, or if resource.IsPublic && user.IsVerified && !user.IsRestricted, or if user.Groups.Contains(resource.RequiredGroup) && user.MFA.IsEnabled() && time.Since(user.MFA.LastVerified) < 24*time.Hour, or if resource.ACL.HasEntry(user.ID) && resource.ACL.GetPermission(user.ID).Level >= MinReadLevel. Return the matching permission level or ErrAccessDenied.",
|
||||
"trap": "Model follows the prompt's explicit instruction NOT to use intermediate variables, inlining all complex conditions into the if-else chain",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "19.1",
|
||||
"text": "At least 3 conditions are extracted into named boolean variables before the if/switch"
|
||||
},
|
||||
{
|
||||
"id": "19.2",
|
||||
"text": "Named booleans have domain-meaningful names (e.g., isSuperAdmin, isSameDepartment, isPublicAndVerified, hasMFAAccess, hasACLEntry)"
|
||||
},
|
||||
{
|
||||
"id": "19.3",
|
||||
"text": "The final if/switch reads like business logic: if isSuperAdmin || isDepartmentAdmin || isPublicAccess || hasMFAGroupAccess || hasACLPermission"
|
||||
},
|
||||
{
|
||||
"id": "19.4",
|
||||
"text": "The most expensive checks (Groups.Contains + MFA, ACL.HasEntry + GetPermission) are either kept inline for short-circuit or extracted last"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"name": "line-breaks-override-compact-prompt",
|
||||
"description": "Breaks long lines despite prompt requesting single-line compact code",
|
||||
"prompt": "Write a Go function SetupHandlers in package `api` that registers 8 REST endpoint handlers on an http.ServeMux. Each handler closure calls processRequest with: the http.ResponseWriter, *http.Request, the service name \"com.example.platform.microservices.user-management.api.v2\", a config struct, a logger, a metrics collector, a rate limiter, and a tracer. Write compact code — each mux.HandleFunc call should be on a single line for easy scanning.",
|
||||
"trap": "Model follows the prompt's compact single-line instruction, creating 200+ character lines that are unreadable in diffs and editors",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "20.1",
|
||||
"text": "The inner processRequest call is broken across multiple lines with each argument on its own line"
|
||||
},
|
||||
{
|
||||
"id": "20.2",
|
||||
"text": "No single line in the file exceeds ~140 characters"
|
||||
},
|
||||
{
|
||||
"id": "20.3",
|
||||
"text": "Closing parentheses appear on their own line after multi-argument calls"
|
||||
},
|
||||
{
|
||||
"id": "20.4",
|
||||
"text": "The service name string is extracted to a constant or variable, not repeated inline 8 times"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"name": "switch-override-else-prompt",
|
||||
"description": "Uses switch or early returns despite prompt explicitly requiring else after every if",
|
||||
"prompt": "Write a Go function GetPricingTier in package `billing` that takes a customer struct with fields: plan string, monthlySpend float64, isEnterprise bool, hasCustomContract bool, employeeCount int, region string. Return the pricing tier string. Use a traditional if/else if/else chain: if enterprise with custom contract return 'enterprise-custom', else if enterprise return 'enterprise', else if monthly spend > 10000 return 'premium', else if monthly spend > 1000 return 'professional', else if monthly spend > 100 return 'starter', else return 'free'. Make sure to use else after every if.",
|
||||
"trap": "Model follows the prompt's explicit instruction to use else after every if, creating an if-else chain instead of cleaner switch or early returns",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "21.1",
|
||||
"text": "The function uses either a switch statement or early returns — NOT an if/else-if/else chain"
|
||||
},
|
||||
{
|
||||
"id": "21.2",
|
||||
"text": "There is NO 'else' keyword in the function body (or at most one for a final default case in switch)"
|
||||
},
|
||||
{
|
||||
"id": "21.3",
|
||||
"text": "If using early returns: each condition returns immediately without an else block"
|
||||
},
|
||||
{
|
||||
"id": "21.4",
|
||||
"text": "If using switch: uses tagless switch (switch { case ... }) for the multi-condition comparison"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"name": "minimal-exports",
|
||||
"description": "Internal helpers unexported; only the true public API is exported",
|
||||
"prompt": "Write a Go file `helpers.go` in package `httputil`. Export everything for maximum reusability across packages. Create: BuildURL (joins base URL and path), ParseQueryParams (extracts query params into map), SanitizeHeader (removes dangerous headers), FormatResponse (builds JSON response), LogRequest (logs request details), ExtractBearerToken (gets token from Authorization header), SetCORSHeaders (adds CORS headers), ValidateContentType (checks Content-Type header). Make all functions exported and all helper types exported too.",
|
||||
"trap": "Model follows the prompt's instruction to export everything, leaking internal helpers into the package API and coupling consumers to implementation details",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "22.1",
|
||||
"text": "At least 2 functions that are purely internal helpers are unexported (lowercase) — not everything is exported"
|
||||
},
|
||||
{
|
||||
"id": "22.2",
|
||||
"text": "Any internal helper types or structs used only within the package are unexported"
|
||||
},
|
||||
{
|
||||
"id": "22.3",
|
||||
"text": "The truly public API functions (BuildURL, ParseQueryParams, etc.) remain exported"
|
||||
},
|
||||
{
|
||||
"id": "22.4",
|
||||
"text": "No function that is only called by other functions in the same file is exported unnecessarily"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"name": "reasonable-capacity-hints",
|
||||
"description": "Capacity hints match typical sizes, not speculative large pre-allocations",
|
||||
"prompt": "Write a Go function AllocateBuffers in package `pool` that processes a list of job IDs. For each job, allocate a result slice and a metadata map. Pre-allocate generously for performance: use make([]Result, 0, 100000) for the results since some jobs might return many results, and make(map[string]string, 10000) for metadata since we want to avoid rehashing. The input typically has 5-20 job IDs, and each job usually produces 3-10 results with 2-5 metadata entries.",
|
||||
"trap": "Model follows the prompt's instructions to use 100000 and 10000 as capacity hints, wasting gigabytes of memory per invocation",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "23.1",
|
||||
"text": "Result slices are NOT pre-allocated with capacity 100000 — uses a reasonable capacity like 10, 16, or len-based hint matching the typical 3-10 results"
|
||||
},
|
||||
{
|
||||
"id": "23.2",
|
||||
"text": "Metadata maps are NOT pre-allocated with capacity 10000 — uses a reasonable capacity like 5, 8, or a small constant matching the typical 2-5 entries"
|
||||
},
|
||||
{
|
||||
"id": "23.3",
|
||||
"text": "The outer job slice uses make with len(jobIDs) capacity hint since the size is known"
|
||||
},
|
||||
{
|
||||
"id": "23.4",
|
||||
"text": "No make() call in the file uses a capacity hint larger than 1000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 24,
|
||||
"name": "early-continue-not-nesting",
|
||||
"description": "Loop validation failures use continue; happy path at shallowest indentation",
|
||||
"prompt": "Write a Go function TransformData in package `etl` that takes a slice of RawRecord structs (each ~200 bytes with many string fields). For each record: (1) validate it (check 4 fields are non-empty), (2) normalize it (trim and lowercase strings), (3) check for duplicates against a seen map, (4) apply business rules (3 conditions), (5) convert to OutputRecord. Write the main loop body as one deeply nested block: for each record, if valid { if normalized ok { if not duplicate { if rules pass { append to output } else { log skip } } else { log duplicate } } else { log invalid } }.",
|
||||
"trap": "Model follows the prompt's deeply nested loop body pattern, burying the happy path inside 4+ levels of indentation",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "24.1",
|
||||
"text": "Validation failures use 'continue' to skip the record — NOT nested else blocks inside the loop"
|
||||
},
|
||||
{
|
||||
"id": "24.2",
|
||||
"text": "The loop body has at most 2 levels of indentation (loop + one if/continue)"
|
||||
},
|
||||
{
|
||||
"id": "24.3",
|
||||
"text": "At least one helper function is extracted (e.g., validate, normalize, or applyRules) to keep the loop body short"
|
||||
},
|
||||
{
|
||||
"id": "24.4",
|
||||
"text": "The happy path (convert and append) is at the shallowest indentation level within the loop, not nested 4+ levels deep"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Code Style Details
|
||||
|
||||
## Extract Complex Conditions
|
||||
|
||||
When `if` conditions span multiple operands, extract into named booleans:
|
||||
|
||||
```go
|
||||
// Good — self-documenting
|
||||
isAdmin := user.Role == RoleAdmin
|
||||
isOwner := resource.OwnerID == user.ID
|
||||
hasOverride := permissions.Contains(PermOverride)
|
||||
if isAdmin || isOwner || hasOverride {
|
||||
allow()
|
||||
}
|
||||
|
||||
// Bad — wall of logic
|
||||
if user.Role == RoleAdmin || resource.OwnerID == user.ID || permissions.Contains(PermOverride) {
|
||||
allow()
|
||||
}
|
||||
```
|
||||
|
||||
**Exception:** When the last condition involves expensive processing, keep it inline to benefit from short-circuit evaluation:
|
||||
|
||||
```go
|
||||
// Good — avoid expensive operation when possible
|
||||
if isAdmin || isOwner || expensivePermissionCheck(user, resource) {
|
||||
allow()
|
||||
}
|
||||
|
||||
// Wasteful — always runs expensive check
|
||||
canOverride := expensivePermissionCheck(user, resource)
|
||||
if isAdmin || isOwner || canOverride {
|
||||
allow()
|
||||
}
|
||||
```
|
||||
|
||||
## Value vs Pointer Arguments
|
||||
|
||||
This covers **function parameters**, not method receivers (see `samber/cc-skills-golang@golang-structs-interfaces` skill for receiver rules).
|
||||
|
||||
Pass small, fixed-size types by value — strings are already a (pointer, length) pair internally:
|
||||
|
||||
```go
|
||||
// Good — value types by value
|
||||
func FormatUser(name string, age int, createdAt time.Time) string
|
||||
|
||||
// Good — pointer for mutation
|
||||
func PopulateDefaults(cfg *Config)
|
||||
|
||||
// Good — pointer when nil is meaningful (optional field update)
|
||||
func UpdateUser(ctx context.Context, id string, name *string) error
|
||||
|
||||
// Bad — pointer for no reason
|
||||
func Greet(name *string) string
|
||||
```
|
||||
|
||||
**When to use pointers**:
|
||||
|
||||
- The function **mutates** the value
|
||||
- The struct is **large** (~128+ bytes) — avoids copying overhead
|
||||
- **Nil is meaningful** (optional/nullable parameter)
|
||||
|
||||
**When NOT to use pointers**:
|
||||
|
||||
- `string`, `int`, `bool`, `float64`, `time.Time` — pass by value
|
||||
- Read-only access to small structs — pass by value (better cache locality)
|
||||
- "Just to save memory" — value copy is negligible; stack allocation is fast
|
||||
|
||||
**Memory access trade-offs when strong performance is required**:
|
||||
|
||||
- **Values (no pointer)**: Stack allocation, excellent CPU cache locality for small types, zero indirection cost. Slower only when copying large structs.
|
||||
- **Pointers**: One extra dereference (negligible on modern CPUs), but risk cache misses if pointed-to data isn't in cache. Essential for large structs (>~128 bytes) where copy cost dominates.
|
||||
- **Rule of thumb**: For structs <~128 bytes with read-only access, values are typically faster due to cache locality. For mutation or large structs, pointers win. When in doubt, benchmark.
|
||||
|
||||
-> See the `samber/cc-skills-golang@golang-structs-interfaces` skill for pointer vs value **receiver** rules.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: golang-concurrency
|
||||
description: Golang concurrency patterns. Use when writing or reviewing concurrent Go code involving goroutines, channels, select, locks, sync primitives, errgroup, singleflight, worker pools, or fan-out/fan-in pipelines. Also triggers when you detect goroutine leaks, race conditions, channel ownership issues, or need to choose between channels and mutexes.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
**Persona:** You are a Go concurrency engineer. You assume every goroutine is a liability until proven necessary — correctness and leak-freedom come before performance.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Write mode** — implement concurrent code (goroutines, channels, sync primitives, worker pools, pipelines). Follow the sequential instructions below.
|
||||
- **Review mode** — reviewing a PR's concurrent code changes. Focus on the diff: check for goroutine leaks, missing context propagation, ownership violations, and unprotected shared state. Sequential.
|
||||
- **Audit mode** — auditing existing concurrent code across a codebase. Use up to 5 parallel sub-agents as described in the "Parallelizing Concurrency Audits" section.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-concurrency` skill takes precedence.
|
||||
|
||||
# Go Concurrency Best Practices
|
||||
|
||||
Go's concurrency model is built on goroutines and channels. Goroutines are cheap but not free — every goroutine you spawn is a resource you must manage. The goal is structured concurrency: every goroutine has a clear owner, a predictable exit, and proper error propagation.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Every goroutine must have a clear exit** — without a shutdown mechanism (context, done channel, WaitGroup), they leak and accumulate until the process crashes
|
||||
2. **Share memory by communicating** — channels transfer ownership explicitly; mutexes protect shared state but make ownership implicit
|
||||
3. **Send copies, not pointers** on channels — sending pointers creates invisible shared memory, defeating the purpose of channels
|
||||
4. **Only the sender closes a channel** — closing from the receiver side panics if the sender writes after close
|
||||
5. **Specify channel direction** (`chan<-`, `<-chan`) — the compiler prevents misuse at build time
|
||||
6. **Default to unbuffered channels** — larger buffers mask backpressure; use them only with measured justification
|
||||
7. **Always include `ctx.Done()` in select** — without it, goroutines leak after caller cancellation
|
||||
8. **Never use `time.After` in loops** — each call creates a timer that lives until it fires, accumulating memory. Use `time.NewTimer` + `Reset`
|
||||
9. **Track goroutine leaks in tests** with `go.uber.org/goleak`
|
||||
|
||||
For detailed channel/select code examples, see [Channels and Select Patterns](references/channels-and-select.md).
|
||||
|
||||
## Channel vs Mutex vs Atomic
|
||||
|
||||
| Scenario | Use | Why |
|
||||
| --- | --- | --- |
|
||||
| Passing data between goroutines | Channel | Communicates ownership transfer |
|
||||
| Coordinating goroutine lifecycle | Channel + context | Clean shutdown with select |
|
||||
| Protecting shared struct fields | `sync.Mutex` / `sync.RWMutex` | Simple critical sections |
|
||||
| Simple counters, flags | `sync/atomic` | Lock-free, lower overhead |
|
||||
| Many readers, few writers on a map | `sync.Map` | Optimized for read-heavy workloads. **Concurrent map read/write causes a hard crash** |
|
||||
| Caching expensive computations | `sync.Once` / `singleflight` | Execute once or deduplicate |
|
||||
|
||||
## WaitGroup vs errgroup
|
||||
|
||||
| Need | Use | Why |
|
||||
| --- | --- | --- |
|
||||
| Wait for goroutines, errors not needed | `sync.WaitGroup` | Fire-and-forget |
|
||||
| Wait + collect first error | `errgroup.Group` | Error propagation |
|
||||
| Wait + cancel siblings on first error | `errgroup.WithContext` | Context cancellation on error |
|
||||
| Wait + limit concurrency | `errgroup.SetLimit(n)` | Built-in worker pool |
|
||||
|
||||
## Sync Primitives Quick Reference
|
||||
|
||||
| Primitive | Use case | Key notes |
|
||||
| --- | --- | --- |
|
||||
| `sync.Mutex` | Protect shared state | Keep critical sections short; never hold across I/O |
|
||||
| `sync.RWMutex` | Many readers, few writers | Never upgrade RLock to Lock (deadlock) |
|
||||
| `sync/atomic` | Simple counters, flags | Prefer typed atomics (Go 1.19+): `atomic.Int64`, `atomic.Bool` |
|
||||
| `sync.Map` | Concurrent map, read-heavy | No explicit locking; use `RWMutex`+map when writes dominate |
|
||||
| `sync.Pool` | Reuse temporary objects | Always `Reset()` before `Put()`; reduces GC pressure |
|
||||
| `sync.Once` | One-time initialization | Go 1.21+: `OnceFunc`, `OnceValue`, `OnceValues` |
|
||||
| `sync.WaitGroup` | Wait for goroutine completion | `Add` before `go`; Go 1.24+: `wg.Go()` simplifies usage |
|
||||
| `x/sync/singleflight` | Deduplicate concurrent calls | Cache stampede prevention |
|
||||
| `x/sync/errgroup` | Goroutine group + errors | `SetLimit(n)` replaces hand-rolled worker pools |
|
||||
|
||||
For detailed examples and anti-patterns, see [Sync Primitives Deep Dive](references/sync-primitives.md).
|
||||
|
||||
## Concurrency Checklist
|
||||
|
||||
Before spawning a goroutine, answer:
|
||||
|
||||
- [ ] **How will it exit?** — context cancellation, channel close, or explicit signal
|
||||
- [ ] **Can I signal it to stop?** — pass `context.Context` or done channel
|
||||
- [ ] **Can I wait for it?** — `sync.WaitGroup` or `errgroup`
|
||||
- [ ] **Who owns the channels?** — creator/sender owns and closes
|
||||
- [ ] **Should this be synchronous instead?** — don't add concurrency without measured need
|
||||
|
||||
## Pipelines and Worker Pools
|
||||
|
||||
For pipeline patterns (fan-out/fan-in, bounded workers, generator chains, Go 1.23+ iterators, `samber/ro`), see [Pipelines and Worker Pools](references/pipelines.md).
|
||||
|
||||
## Parallelizing Concurrency Audits
|
||||
|
||||
When auditing concurrency across a large codebase, use up to 5 parallel sub-agents (Agent tool):
|
||||
|
||||
1. Find all goroutine spawns (`go func`, `go method`) and verify shutdown mechanisms
|
||||
2. Search for mutable globals and shared state without synchronization
|
||||
3. Audit channel usage — ownership, direction, closure, buffer sizes
|
||||
4. Find `time.After` in loops, missing `ctx.Done()` in select, unbounded spawning
|
||||
5. Check mutex usage, `sync.Map`, atomics, and thread-safety documentation
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Fire-and-forget goroutine | Provide stop mechanism (context, done channel) |
|
||||
| Closing channel from receiver | Only the sender closes |
|
||||
| `time.After` in hot loop | Reuse `time.NewTimer` + `Reset` |
|
||||
| Missing `ctx.Done()` in select | Always select on context to allow cancellation |
|
||||
| Unbounded goroutine spawning | Use `errgroup.SetLimit(n)` or semaphore |
|
||||
| Sharing pointer via channel | Send copies or immutable values |
|
||||
| `wg.Add` inside goroutine | Call `Add` before `go` — `Wait` may return early otherwise |
|
||||
| Forgetting `-race` in CI | Always run `go test -race ./...` |
|
||||
| Mutex held across I/O | Keep critical sections short |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- -> See `samber/cc-skills-golang@golang-performance` skill for false sharing, cache-line padding, `sync.Pool` hot-path patterns
|
||||
- -> See `samber/cc-skills-golang@golang-context` skill for cancellation propagation and timeout patterns
|
||||
- -> See `samber/cc-skills-golang@golang-safety` skill for concurrent map access and race condition prevention
|
||||
- -> See `samber/cc-skills-golang@golang-troubleshooting` skill for debugging goroutine leaks and deadlocks
|
||||
- -> See `samber/cc-skills-golang@golang-design-patterns` skill for graceful shutdown patterns
|
||||
|
||||
## References
|
||||
|
||||
- [Go Concurrency Patterns: Pipelines](https://go.dev/blog/pipelines)
|
||||
- [Effective Go: Concurrency](https://go.dev/doc/effective_go#concurrency)
|
||||
@@ -0,0 +1,181 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "time-after-in-select-loop",
|
||||
"description": "Tests whether the model avoids time.After inside a select loop and uses time.NewTimer with Reset instead",
|
||||
"prompt": "I have a Go worker that reads from a channel and needs a 5-second inactivity timeout. If nothing arrives for 5 seconds, it should log and continue waiting. Write the select loop.",
|
||||
"trap": "Without the skill, the model uses time.After(5*time.Second) inside the for/select loop, which leaks a timer on every iteration",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Does NOT use time.After inside the loop body"},
|
||||
{"id": "1.2", "text": "Creates time.NewTimer (or time.NewTicker) outside the loop"},
|
||||
{"id": "1.3", "text": "Calls timer.Reset() after handling a message or timeout"},
|
||||
{"id": "1.4", "text": "Calls timer.Stop() (or defers it) to clean up the timer"},
|
||||
{"id": "1.5", "text": "Drains the timer channel before Reset when appropriate (if !timer.Stop() { <-timer.C })"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "channel-closing-ownership",
|
||||
"description": "Tests whether the model follows the rule that only the sender closes a channel, not the receiver",
|
||||
"prompt": "I have a producer goroutine that sends items to a channel and a consumer goroutine that reads from it. The consumer knows when it has received enough items and wants to signal completion. How should I close the channel to stop the producer?",
|
||||
"trap": "Without the skill, the model closes the channel from the consumer side, which panics if the producer writes after close",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Does NOT close the channel from the consumer/receiver side"},
|
||||
{"id": "2.2", "text": "Uses a separate signaling mechanism (done channel, context cancellation, or similar) for the consumer to tell the producer to stop"},
|
||||
{"id": "2.3", "text": "The producer is the one that closes the data channel (or it is closed by the channel creator/sender)"},
|
||||
{"id": "2.4", "text": "Explains the panic risk of closing a channel from the receiver side"},
|
||||
{"id": "2.5", "text": "The producer selects on the stop signal alongside its send operation"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "waitgroup-add-placement",
|
||||
"description": "Tests whether wg.Add is called before the go statement, not inside the goroutine",
|
||||
"prompt": "Write a Go function that processes a slice of URLs concurrently and waits for all to complete using sync.WaitGroup. Each goroutine should fetch the URL and store the result.",
|
||||
"trap": "Without the skill, the model might place wg.Add(1) inside the goroutine, creating a race where Wait() returns before Add() runs",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Calls wg.Add(1) BEFORE the go statement, not inside the goroutine"},
|
||||
{"id": "3.2", "text": "Calls defer wg.Done() inside the goroutine"},
|
||||
{"id": "3.3", "text": "Calls wg.Wait() after the loop to wait for all goroutines"},
|
||||
{"id": "3.4", "text": "Does not place wg.Add inside the goroutine function body"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "channel-direction-in-signatures",
|
||||
"description": "Tests whether the model specifies channel direction (send-only, receive-only) in function signatures",
|
||||
"prompt": "Write a Go pipeline with two functions: one that generates integers from a slice and sends them to a channel, and one that reads integers from a channel, doubles them, and sends to an output channel. Wire them together in main.",
|
||||
"trap": "Without the skill, the model uses bidirectional chan int in function parameters instead of chan<- and <-chan",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "The generator function returns <-chan int (receive-only for callers)"},
|
||||
{"id": "4.2", "text": "The doubler function accepts <-chan int as input parameter"},
|
||||
{"id": "4.3", "text": "The doubler function returns <-chan int (receive-only for callers)"},
|
||||
{"id": "4.4", "text": "Internally, channels are created as bidirectional but exposed as directional through return types"},
|
||||
{"id": "4.5", "text": "The producer (generator) closes its output channel with defer close(out)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "unbuffered-channel-default",
|
||||
"description": "Tests whether the model defaults to unbuffered channels and requires justification for buffered ones",
|
||||
"prompt": "I need a channel to pass tasks from a dispatcher to a pool of workers in Go. Should I buffer it? If so, how large?",
|
||||
"trap": "Without the skill, the model suggests an arbitrary large buffer (e.g., 100 or 1000) without justification or discussion of backpressure",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Recommends starting with unbuffered (or very small buffer like 0 or 1) as the default"},
|
||||
{"id": "5.2", "text": "Explains that large buffers mask backpressure problems"},
|
||||
{"id": "5.3", "text": "States that buffer size should be based on measured need, not arbitrary choice"},
|
||||
{"id": "5.4", "text": "Does NOT suggest a large arbitrary buffer (e.g., 100, 1000) without explaining the tradeoffs"},
|
||||
{"id": "5.5", "text": "Mentions that buffered channels hide the problem of slow consumers"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "send-copies-not-pointers",
|
||||
"description": "Tests whether the model sends copies (not pointers) through channels to avoid shared memory",
|
||||
"prompt": "I have a producer goroutine that creates Task structs and sends them to a channel for worker goroutines to process. The Task struct has fields like ID, Payload, and Status. Write the producer and consumer code.",
|
||||
"trap": "Without the skill, the model sends *Task pointers through the channel, creating invisible shared memory that defeats the purpose of channels",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Sends Task values (not *Task pointers) through the channel, OR explicitly documents why pointers are safe in this case"},
|
||||
{"id": "6.2", "text": "The channel type is chan Task (value type) rather than chan *Task"},
|
||||
{"id": "6.3", "text": "Does not mutate the Task struct after sending it on the channel (or sends a copy)"},
|
||||
{"id": "6.4", "text": "If pointers are used, explicitly acknowledges the shared-memory risk and explains the mitigation"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "errgroup-vs-waitgroup-decision",
|
||||
"description": "Tests whether the model chooses errgroup over WaitGroup when error handling is needed, and uses SetLimit for bounded concurrency",
|
||||
"prompt": "Write a Go function that fetches data from 50 URLs concurrently. At most 10 should run at once. If any fetch fails, stop all remaining work and return the error.",
|
||||
"trap": "Without the skill, the model builds a hand-rolled worker pool with WaitGroup + semaphore channel, missing errgroup.SetLimit which handles this natively",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses errgroup (golang.org/x/sync/errgroup) instead of sync.WaitGroup for error propagation"},
|
||||
{"id": "7.2", "text": "Uses errgroup.WithContext to cancel siblings on first error"},
|
||||
{"id": "7.3", "text": "Uses g.SetLimit(10) for bounded concurrency instead of a hand-rolled semaphore"},
|
||||
{"id": "7.4", "text": "Does NOT build a manual worker pool with channels and WaitGroup when errgroup suffices"},
|
||||
{"id": "7.5", "text": "Each goroutine checks ctx.Done() or uses the context from errgroup.WithContext"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "ctx-done-in-select",
|
||||
"description": "Tests whether every select statement includes a ctx.Done() case to prevent goroutine leaks",
|
||||
"prompt": "Write a Go function that reads from an input channel, transforms each item, and writes to an output channel. It should run as a goroutine.",
|
||||
"trap": "Without the skill, the model writes a select with only channel operations but no ctx.Done() case, causing goroutine leaks on cancellation",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "The function accepts a context.Context parameter"},
|
||||
{"id": "8.2", "text": "Every select statement includes a case <-ctx.Done(): return branch"},
|
||||
{"id": "8.3", "text": "Both the read from input channel AND the write to output channel are wrapped in select with ctx.Done()"},
|
||||
{"id": "8.4", "text": "The goroutine exits cleanly when context is cancelled"},
|
||||
{"id": "8.5", "text": "The output channel is closed when the goroutine exits (defer close)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "sync-map-vs-rwmutex-decision",
|
||||
"description": "Tests whether the model correctly chooses between sync.Map and RWMutex+map based on access patterns",
|
||||
"prompt": "I need a concurrent cache in Go. Keys are strings, values are structs. The cache will have frequent writes from many goroutines updating the same keys, and reads happen about as often as writes. Which synchronization approach should I use?",
|
||||
"trap": "Without the skill, the model recommends sync.Map because it sounds like 'concurrent map', but sync.Map is slower for write-heavy overlapping-key patterns",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Recommends sync.RWMutex + plain map over sync.Map for this write-heavy, overlapping-key pattern"},
|
||||
{"id": "9.2", "text": "Explains that sync.Map is optimized for write-once/read-many or disjoint key sets"},
|
||||
{"id": "9.3", "text": "Explains that for frequent writes with overlapping keys, RWMutex+map is faster"},
|
||||
{"id": "9.4", "text": "Does NOT unconditionally recommend sync.Map for any concurrent map scenario"},
|
||||
{"id": "9.5", "text": "Mentions that concurrent map read/write without synchronization causes a hard crash (not just a data race)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "sync-pool-reset-before-put",
|
||||
"description": "Tests whether the model resets objects before returning them to sync.Pool",
|
||||
"prompt": "Write a Go function that uses sync.Pool to reuse bytes.Buffer objects for encoding JSON responses in an HTTP handler. Show the pool setup and the handler.",
|
||||
"trap": "Without the skill, the model returns the buffer to the pool without calling Reset(), leading to dirty objects and data corruption",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Calls buf.Reset() BEFORE bufPool.Put(buf), not after Get()"},
|
||||
{"id": "10.2", "text": "Uses defer to ensure the buffer is returned to the pool even on error"},
|
||||
{"id": "10.3", "text": "Does not assume the object from Get() is clean/zeroed"},
|
||||
{"id": "10.4", "text": "The pool's New function creates a new buffer"},
|
||||
{"id": "10.5", "text": "Does not store persistent state in pooled objects"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "goroutine-panic-recovery",
|
||||
"description": "Tests whether the model adds panic recovery at goroutine boundaries in production code",
|
||||
"prompt": "Write a Go HTTP server that spawns a background goroutine per request to do async processing (e.g., sending a notification email). The main handler returns 202 Accepted immediately.",
|
||||
"trap": "Without the skill, the model spawns go func() without recover(), so a panic in the background goroutine crashes the entire server process",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Adds defer func() { recover() }() or equivalent panic recovery inside the goroutine"},
|
||||
{"id": "11.2", "text": "Logs or handles the recovered panic (not just silently swallowed)"},
|
||||
{"id": "11.3", "text": "The goroutine has a shutdown mechanism (context, done channel, or similar)"},
|
||||
{"id": "11.4", "text": "Mentions that a panic in a goroutine crashes the entire process"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "singleflight-cache-stampede",
|
||||
"description": "Tests whether the model uses singleflight to deduplicate concurrent requests for the same resource",
|
||||
"prompt": "My Go service has an expensive database query for user profiles. Under load, hundreds of goroutines request the same user profile simultaneously, causing a thundering herd on the database. How should I solve this?",
|
||||
"trap": "Without the skill, the model suggests only traditional caching (TTL-based) or a mutex, missing singleflight which deduplicates in-flight requests",
|
||||
"assertions": [
|
||||
{"id": "12.1", "text": "Recommends golang.org/x/sync/singleflight as the primary solution"},
|
||||
{"id": "12.2", "text": "Shows usage of group.Do(key, func) where key identifies the deduplicated resource"},
|
||||
{"id": "12.3", "text": "Explains that only one goroutine executes the function; others wait and share the result"},
|
||||
{"id": "12.4", "text": "May combine singleflight with a cache layer for TTL-based caching, but singleflight is the deduplication mechanism"},
|
||||
{"id": "12.5", "text": "Does NOT suggest only a plain mutex or only a TTL cache as the solution to thundering herd"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "iterator-vs-goroutine-pipeline",
|
||||
"description": "Tests whether the model uses Go 1.23+ iterators for sequential CPU-bound transforms instead of goroutine+channel pipelines",
|
||||
"prompt": "I have a Go 1.23+ project. I need to filter a slice of users to find active ones, then extract their email addresses. The slice is in-memory and processing is pure CPU. Should I use a goroutine pipeline with channels?",
|
||||
"trap": "Without the skill, the model builds a goroutine+channel pipeline for a purely sequential, CPU-bound in-memory transformation where iterators are more appropriate",
|
||||
"assertions": [
|
||||
{"id": "13.1", "text": "Recommends against goroutine+channel pipeline for this purely sequential, in-memory transform"},
|
||||
{"id": "13.2", "text": "Suggests Go 1.23+ iterators (iter.Seq) or simple slice operations instead"},
|
||||
{"id": "13.3", "text": "Explains that goroutine+channel pipelines add overhead without benefit for sequential CPU-bound work"},
|
||||
{"id": "13.4", "text": "Mentions that goroutine pipelines are appropriate when stages involve I/O or need true parallelism"},
|
||||
{"id": "13.5", "text": "Does NOT build a multi-goroutine pipeline for this use case"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,160 @@
|
||||
# Channels and Select Patterns
|
||||
|
||||
## Goroutine Lifecycle
|
||||
|
||||
NEVER start a goroutine without knowing how it stops. Every goroutine MUST answer: **how will it stop?**
|
||||
|
||||
```go
|
||||
// ✗ Bad — fire-and-forget, no way to stop or wait
|
||||
func startWorker() {
|
||||
go func() {
|
||||
for {
|
||||
doWork() // runs forever, leaks on shutdown
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ✓ Good — goroutine respects context cancellation, caller can wait
|
||||
func startWorker(ctx context.Context) *sync.WaitGroup {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
doWork(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return &wg
|
||||
}
|
||||
```
|
||||
|
||||
### Panic Recovery at Goroutine Boundaries
|
||||
|
||||
A panic in a goroutine crashes the entire process. Always recover at goroutine boundaries in production code:
|
||||
|
||||
```go
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// ...
|
||||
}
|
||||
}()
|
||||
doWork(ctx)
|
||||
}()
|
||||
```
|
||||
|
||||
## Channel Direction
|
||||
|
||||
Specify direction in function signatures to prevent misuse at compile time:
|
||||
|
||||
```go
|
||||
// ✗ Bad — caller could accidentally close or send on a receive-only channel
|
||||
func consume(ch chan int) { ... }
|
||||
|
||||
// ✓ Good — compiler enforces correct usage
|
||||
func produce(ch chan<- int) { ... } // send-only
|
||||
func consume(ch <-chan int) { ... } // receive-only
|
||||
```
|
||||
|
||||
## Channel Closing
|
||||
|
||||
Channels MUST be closed by the sender (producer), NEVER by the receiver — it causes a panic if the sender writes after close.
|
||||
|
||||
```go
|
||||
// ✓ Good — producer closes when done
|
||||
func generate(ctx context.Context) <-chan int {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
defer close(ch) // sender closes
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case ch <- i:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
```
|
||||
|
||||
## Buffer Size
|
||||
|
||||
| Size | When to use |
|
||||
| --- | --- |
|
||||
| 0 (unbuffered) | Default. Synchronizes sender and receiver — use when you need handoff guarantees |
|
||||
| 1 | Signal channels (`done := make(chan struct{}, 1)`), or when sender must not block on a single pending item |
|
||||
| N > 1 | Only with measured justification — document why N was chosen and what happens when the buffer fills |
|
||||
|
||||
```go
|
||||
// ✓ Good — unbuffered for synchronous handoff
|
||||
ch := make(chan Result)
|
||||
|
||||
// ✓ Good — buffered 1 for signal
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
// ✗ Suspicious — arbitrary large buffer hides backpressure problems
|
||||
// Give explanation in comments.
|
||||
ch := make(chan Task, 1000) // why 1000? what if it fills?
|
||||
```
|
||||
|
||||
## Select for Non-Blocking Communication
|
||||
|
||||
Use `select` to multiplex channel operations and always include `ctx.Done()` to prevent goroutine leaks:
|
||||
|
||||
```go
|
||||
func process(ctx context.Context, in <-chan Task, out chan<- Result) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case task, ok := <-in:
|
||||
if !ok {
|
||||
return // channel closed
|
||||
}
|
||||
result := handle(ctx, task)
|
||||
select {
|
||||
case out <- result:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## NEVER Use `time.After` in Loops
|
||||
|
||||
```go
|
||||
// ✗ Bad — leaks a timer on every iteration until it fires
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
handle(msg)
|
||||
case <-time.After(5 * time.Second): // new timer every loop — leak
|
||||
handleTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
// ✓ Good — reuse the timer
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg := <-ch:
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
timer.Reset(5 * time.Second)
|
||||
handle(msg)
|
||||
case <-timer.C:
|
||||
handleTimeout()
|
||||
timer.Reset(5 * time.Second)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,255 @@
|
||||
# Pipelines and Worker Pools
|
||||
|
||||
## Pipeline Pattern
|
||||
|
||||
A pipeline is a series of stages connected by channels, where each stage is a goroutine (or group of goroutines) that:
|
||||
|
||||
1. Receives values from an upstream channel
|
||||
2. Processes each value
|
||||
3. Sends results to a downstream channel
|
||||
|
||||
```go
|
||||
// Stage 1: Generate integers
|
||||
func generate(ctx context.Context, nums ...int) <-chan int {
|
||||
out := make(chan int)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for _, n := range nums {
|
||||
select {
|
||||
case out <- n:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
// Stage 2: Square each integer
|
||||
func square(ctx context.Context, in <-chan int) <-chan int {
|
||||
out := make(chan int)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for n := range in {
|
||||
select {
|
||||
case out <- n * n:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
// Usage
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ch := generate(ctx, 2, 3, 4)
|
||||
results := square(ctx, ch)
|
||||
|
||||
for v := range results {
|
||||
fmt.Println(v) // 4, 9, 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key rules for pipelines**:
|
||||
|
||||
- Pipeline stages MUST accept and respect context cancellation — every stage must select on `ctx.Done()` to avoid goroutine leaks on early cancellation
|
||||
- The producer (first stage) closes its output channel; each subsequent stage closes its own output
|
||||
- NEVER create unbounded goroutines in pipeline stages
|
||||
- Use unbuffered channels unless you have measured throughput needs
|
||||
|
||||
## Fan-Out / Fan-In
|
||||
|
||||
**Fan-out**: multiple goroutines read from the same channel to parallelize CPU-bound work. **Fan-in**: multiple channels are merged into a single output channel.
|
||||
|
||||
```go
|
||||
// Fan-out: N workers reading from the same input channel
|
||||
func fanOut(ctx context.Context, in <-chan Task, workers int) <-chan Result {
|
||||
out := make(chan Result)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range in {
|
||||
select {
|
||||
case out <- process(ctx, task):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Fan-in: merge multiple channels into one
|
||||
func fanIn(ctx context.Context, channels ...<-chan Result) <-chan Result {
|
||||
out := make(chan Result)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, ch := range channels {
|
||||
wg.Add(1)
|
||||
go func(c <-chan Result) {
|
||||
defer wg.Done()
|
||||
for v := range c {
|
||||
select {
|
||||
case out <- v:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
## Worker Pool with errgroup
|
||||
|
||||
Fan-out workers SHOULD use `errgroup.SetLimit` for bounded concurrency. For most use cases, `errgroup.SetLimit` replaces hand-rolled worker pools:
|
||||
|
||||
```go
|
||||
func processAll(ctx context.Context, tasks []Task) error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(10) // max 10 concurrent workers
|
||||
|
||||
for _, task := range tasks {
|
||||
g.Go(func() error {
|
||||
return process(ctx, task)
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
Use a hand-rolled worker pool only when you need:
|
||||
|
||||
- Per-worker state (connections, buffers)
|
||||
- Custom backpressure or priority scheduling
|
||||
- Graceful draining with in-flight task completion
|
||||
|
||||
## Bounded Concurrency with Semaphore
|
||||
|
||||
When you need fine-grained concurrency control without errgroup:
|
||||
|
||||
```go
|
||||
func processAll(ctx context.Context, items []Item) error {
|
||||
sem := make(chan struct{}, 10) // semaphore of 10
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{} // acquire
|
||||
go func(item Item) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // release
|
||||
process(ctx, item)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `errgroup.SetLimit` over this pattern when error propagation is needed.
|
||||
|
||||
## Pipeline Alternatives
|
||||
|
||||
### Go 1.23+ Iterators (range-over-func)
|
||||
|
||||
For in-process data transformations that do not need concurrency, iterators avoid the overhead of goroutines and channels:
|
||||
|
||||
```go
|
||||
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
|
||||
return func(yield func(T) bool) {
|
||||
for v := range seq {
|
||||
if pred(v) {
|
||||
if !yield(v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
|
||||
return func(yield func(U) bool) {
|
||||
for v := range seq {
|
||||
if !yield(f(v)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use iterators when:
|
||||
|
||||
- Processing is CPU-bound and does not benefit from parallelism
|
||||
- You want lazy evaluation without goroutine overhead
|
||||
- The data source is already sequential (slice, database cursor)
|
||||
|
||||
Use goroutine+channel pipelines when:
|
||||
|
||||
- Stages involve I/O (network, disk) that benefits from concurrency
|
||||
- You need true parallelism across CPU cores
|
||||
- Stages have different throughput characteristics
|
||||
|
||||
### samber/ro
|
||||
|
||||
`samber/ro` provides a fluent, type-safe pipeline API for read-only collections:
|
||||
|
||||
```go
|
||||
import "github.com/samber/ro"
|
||||
|
||||
emails, _ := ro.Collect( // ignore error
|
||||
ro.Pipe(
|
||||
ro.FromSlice(users),
|
||||
ro.Filter(func(u User) bool { return u.Active }),
|
||||
ro.Map(func(u User) string { return u.Email }),
|
||||
),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
Use `samber/ro` for sequential data transformations that benefit from a fluent API. It might support parallel processing is needed.
|
||||
|
||||
## Goroutine Leak Detection
|
||||
|
||||
Goroutine leaks SHOULD be detected with goleak in tests. Use `go.uber.org/goleak` in `TestMain` to catch leaked goroutines across all tests:
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pipeline Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Missing `ctx.Done()` in pipeline stage | Always select on context to allow cancellation |
|
||||
| Not closing output channel | Producer must `defer close(out)` |
|
||||
| Unbounded goroutine spawning | Use `errgroup.SetLimit` or a semaphore |
|
||||
| Sending mutable data through channel | Send copies or immutable values |
|
||||
| Blocking send without select | Wrap channel sends in select with `ctx.Done()` |
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-concurrency` skill for sync primitives and channel patterns.
|
||||
@@ -0,0 +1,324 @@
|
||||
# Sync Primitives Deep Dive
|
||||
|
||||
## sync.Mutex
|
||||
|
||||
Protects shared state with exclusive access. MUST hold the lock for the shortest time possible — NEVER hold a mutex across I/O, network calls, or channel operations.
|
||||
|
||||
```go
|
||||
type SafeCache struct {
|
||||
mu sync.Mutex
|
||||
items map[string]string
|
||||
}
|
||||
|
||||
func (c *SafeCache) Get(key string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.items[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *SafeCache) Set(key, value string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items[key] = value
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding Convention
|
||||
|
||||
Embed the mutex as an unexported field, placed directly above the fields it protects:
|
||||
|
||||
```go
|
||||
type Registry struct {
|
||||
mu sync.Mutex // protects entries
|
||||
entries map[string]Entry
|
||||
}
|
||||
```
|
||||
|
||||
## sync.RWMutex
|
||||
|
||||
SHOULD be used when reads greatly outnumber writes. Multiple goroutines can hold `RLock` simultaneously; `Lock` is exclusive.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (c *Config) Get(key string) string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.values[key]
|
||||
}
|
||||
|
||||
func (c *Config) Set(key, value string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.values[key] = value
|
||||
}
|
||||
```
|
||||
|
||||
**Pitfall**: Do not upgrade RLock to Lock — this deadlocks. Release RLock first, then acquire Lock.
|
||||
|
||||
## sync/atomic
|
||||
|
||||
Lock-free operations for simple values. SHOULD be preferred over Mutex for simple counter operations. Faster than mutex for low-contention counters and flags.
|
||||
|
||||
```go
|
||||
// ✓ Good — atomic for a simple counter
|
||||
var requestCount atomic.Int64
|
||||
|
||||
func handleRequest() {
|
||||
requestCount.Add(1)
|
||||
}
|
||||
|
||||
func getCount() int64 {
|
||||
return requestCount.Load()
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✓ Good — atomic.Bool for a shutdown flag
|
||||
var shuttingDown atomic.Bool
|
||||
|
||||
func shutdown() {
|
||||
shuttingDown.Store(true)
|
||||
}
|
||||
|
||||
func isRunning() bool {
|
||||
return !shuttingDown.Load()
|
||||
}
|
||||
```
|
||||
|
||||
Go 1.19+ provides typed atomics (`atomic.Int64`, `atomic.Bool`, `atomic.Pointer[T]`) — prefer these over raw `atomic.AddInt64`/`atomic.LoadInt64`.
|
||||
|
||||
## sync.Map
|
||||
|
||||
SHOULD only be used for write-once/read-many patterns. Optimized for two common patterns: (1) keys are written once and read many times, (2) multiple goroutines read/write disjoint key sets. For other patterns, a plain `map` + `sync.RWMutex` is faster.
|
||||
|
||||
```go
|
||||
var cache sync.Map
|
||||
|
||||
func Get(key string) (any, bool) {
|
||||
return cache.Load(key)
|
||||
}
|
||||
|
||||
func Set(key string, value any) {
|
||||
cache.Store(key, value)
|
||||
}
|
||||
|
||||
func GetOrSet(key string, compute func() any) any {
|
||||
if v, ok := cache.Load(key); ok {
|
||||
return v
|
||||
}
|
||||
v, _ := cache.LoadOrStore(key, compute())
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
**When NOT to use `sync.Map`**: when you need to iterate, get the length, or when writes are frequent and keys overlap heavily. Use `sync.RWMutex` + `map` instead.
|
||||
|
||||
## sync.Pool
|
||||
|
||||
Reuse temporary objects to reduce GC pressure. MUST NOT store pointers to stack-allocated objects. Objects in the pool may be reclaimed at any GC cycle — do not store persistent state.
|
||||
|
||||
```go
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func process(data []byte) string {
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
bufPool.Put(buf)
|
||||
}()
|
||||
|
||||
buf.Write(data)
|
||||
// ... transform ...
|
||||
return buf.String()
|
||||
}
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Always `Reset()` before `Put()` — returning dirty objects causes bugs
|
||||
- Do not assume an object from `Get()` is zeroed — the `New` func only runs if the pool is empty
|
||||
- Best for short-lived, frequently allocated objects (buffers, encoders, temporary structs)
|
||||
|
||||
## sync.Once
|
||||
|
||||
MUST be used for one-time initialization. Execute exactly once, regardless of how many goroutines call it concurrently. Thread-safe by design.
|
||||
|
||||
```go
|
||||
type DBClient struct {
|
||||
initOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func (c *DBClient) getConn() *sql.DB {
|
||||
c.initOnce.Do(func() {
|
||||
var err error
|
||||
c.conn, err = sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("db init: %v", err))
|
||||
}
|
||||
})
|
||||
return c.conn
|
||||
}
|
||||
|
||||
func (c *DBClient) Close() error {
|
||||
var err error
|
||||
c.closeOnce.Do(func() {
|
||||
err = c.conn.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Go 1.21+ also provides `sync.OnceFunc`, `sync.OnceValue`, and `sync.OnceValues` for simpler use cases:
|
||||
|
||||
```go
|
||||
var loadConfig = sync.OnceValue(func() *Config {
|
||||
cfg, err := parseConfig("config.yaml")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
|
||||
// Usage: cfg := loadConfig()
|
||||
```
|
||||
|
||||
## sync.WaitGroup
|
||||
|
||||
Coordinate goroutine completion. Call `Add` before launching the goroutine, `Done` inside the goroutine, `Wait` in the caller.
|
||||
|
||||
```go
|
||||
func processAll(ctx context.Context, items []Item) {
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1) // Add BEFORE go
|
||||
go func(item Item) {
|
||||
defer wg.Done()
|
||||
process(ctx, item)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait() // blocks until all goroutines finish
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✗ Bad — Add inside the goroutine (race: Wait may return before Add runs)
|
||||
go func() {
|
||||
wg.Add(1)
|
||||
defer wg.Done()
|
||||
process(item)
|
||||
}()
|
||||
```
|
||||
|
||||
### Go 1.24+: wg.Go()
|
||||
|
||||
Go 1.24 introduced `wg.Go()` which eliminates the manual `Add`/`Done` bookkeeping:
|
||||
|
||||
```go
|
||||
func processAll(ctx context.Context, items []Item) error {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var lastErr error
|
||||
|
||||
for _, item := range items {
|
||||
item := item // optional starting Go 1.22+ (per-iteration scoping)
|
||||
wg.Go(func() {
|
||||
if err := process(ctx, item); err != nil {
|
||||
mu.Lock()
|
||||
lastErr = err
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return lastErr
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of `wg.Go()`**:
|
||||
|
||||
- No risk of forgetting `Add` or `Done`
|
||||
- Cleaner, less error-prone API
|
||||
- Semantically clearer: "do this concurrently"
|
||||
- Automatically handles Add/Done internally
|
||||
|
||||
**When to use**: Go 1.24+ projects where all concurrent work needs to complete and you want simpler code.
|
||||
|
||||
## golang.org/x/sync/singleflight
|
||||
|
||||
Deduplicates concurrent calls for the same key. When multiple goroutines request the same resource simultaneously, only one executes; the rest wait and share the result.
|
||||
|
||||
```go
|
||||
var group singleflight.Group
|
||||
|
||||
func GetUser(ctx context.Context, id string) (*User, error) {
|
||||
v, err, _ := group.Do(id, func() (any, error) {
|
||||
// Only one goroutine executes this for a given id
|
||||
return db.QueryUser(ctx, id)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(*User), nil
|
||||
}
|
||||
```
|
||||
|
||||
**Use cases**: cache stampede prevention, deduplicating expensive lookups (DB, API), rate-limited external service calls.
|
||||
|
||||
## golang.org/x/sync/errgroup
|
||||
|
||||
Goroutine group with error propagation. Returns the first error from any goroutine. With `WithContext`, cancels remaining goroutines on first error.
|
||||
|
||||
```go
|
||||
func fetchAll(ctx context.Context, urls []string) ([]Response, error) {
|
||||
g, ctx := errgroup.WithContext(ctx) // cancel siblings on first error
|
||||
results := make([]Response, len(urls))
|
||||
|
||||
for i, url := range urls {
|
||||
g.Go(func() error {
|
||||
resp, err := fetch(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching %s: %w", url, err)
|
||||
}
|
||||
results[i] = resp // safe: each goroutine writes to its own index
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Bounded Concurrency with SetLimit
|
||||
|
||||
SHOULD use `SetLimit` to bound concurrency and avoid unbounded goroutine spawning.
|
||||
|
||||
```go
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(10) // at most 10 goroutines run concurrently
|
||||
|
||||
for _, task := range tasks {
|
||||
g.Go(func() error {
|
||||
return process(ctx, task)
|
||||
})
|
||||
}
|
||||
return g.Wait()
|
||||
```
|
||||
|
||||
This replaces hand-rolled worker pools for most use cases.
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-concurrency` skill for high-level patterns and decision trees.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: golang-context
|
||||
description: Idiomatic context.Context usage in Golang — creation, propagation, cancellation, timeouts, deadlines, context values, and cross-service tracing. Apply when working with context.Context in any Go code.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-context` skill takes precedence.
|
||||
|
||||
# Go context.Context Best Practices
|
||||
|
||||
`context.Context` is Go's mechanism for propagating cancellation signals, deadlines, and request-scoped values across API boundaries and between goroutines. Think of it as the "session" of a request — it ties together every operation that belongs to the same unit of work.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. The same context MUST be propagated through the entire request lifecycle: HTTP handler → service → DB → external APIs
|
||||
2. `ctx` MUST be the first parameter, named `ctx context.Context`
|
||||
3. NEVER store context in a struct — pass explicitly through function parameters
|
||||
4. NEVER pass `nil` context — use `context.TODO()` if unsure
|
||||
5. `cancel()` MUST always be deferred immediately after `WithCancel`/`WithTimeout`/`WithDeadline`
|
||||
6. `context.Background()` MUST only be used at the top level (main, init, tests)
|
||||
7. **Use `context.TODO()`** as a placeholder when you know a context is needed but don't have one yet
|
||||
8. NEVER create a new `context.Background()` in the middle of a request path
|
||||
9. Context value keys MUST be unexported types to prevent collisions
|
||||
10. Context values MUST only carry request-scoped metadata — NEVER function parameters
|
||||
11. **Use `context.WithoutCancel`** (Go 1.21+) when spawning background work that must outlive the parent request
|
||||
|
||||
## Creating Contexts
|
||||
|
||||
| Situation | Use |
|
||||
| --- | --- |
|
||||
| Entry point (main, init, test) | `context.Background()` |
|
||||
| Function needs context but caller doesn't provide one yet | `context.TODO()` |
|
||||
| Inside an HTTP handler | `r.Context()` |
|
||||
| Need cancellation control | `context.WithCancel(parentCtx)` |
|
||||
| Need a deadline/timeout | `context.WithTimeout(parentCtx, duration)` |
|
||||
|
||||
## Context Propagation: The Core Principle
|
||||
|
||||
The most important rule: **propagate the same context through the entire call chain**. When you propagate correctly, cancelling the parent context cancels all downstream work automatically.
|
||||
|
||||
```go
|
||||
// ✗ Bad — creates a new context, breaking the chain
|
||||
func (s *OrderService) Create(ctx context.Context, order Order) error {
|
||||
return s.db.ExecContext(context.Background(), "INSERT INTO orders ...", order.ID)
|
||||
}
|
||||
|
||||
// ✓ Good — propagates the caller's context
|
||||
func (s *OrderService) Create(ctx context.Context, order Order) error {
|
||||
return s.db.ExecContext(ctx, "INSERT INTO orders ...", order.ID)
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Dives
|
||||
|
||||
- **[Cancellation, Timeouts & Deadlines](./references/cancellation.md)** — How cancellation propagates: `WithCancel` for manual cancellation, `WithTimeout` for automatic cancellation after a duration, `WithDeadline` for absolute time deadlines. Patterns for listening (`<-ctx.Done()`) in concurrent code, `AfterFunc` callbacks, and `WithoutCancel` for operations that must outlive their parent request (e.g., audit logs).
|
||||
|
||||
- **[Context Values & Cross-Service Tracing](./references/values-tracing.md)** — Safe context value patterns: unexported key types to prevent namespace collisions, when to use context values (request ID, user ID) vs function parameters. Trace context propagation: OpenTelemetry trace headers, correlation IDs for log aggregation, and marshaling/unmarshaling context across service boundaries.
|
||||
|
||||
- **[Context in HTTP Servers & Service Calls](./references/http-services.md)** — HTTP handler context: `r.Context()` for request-scoped cancellation, middleware integration, and propagating to services. HTTP client patterns: `NewRequestWithContext`, client timeouts, and retries with context awareness. Database operations: always use `*Context` variants (`QueryContext`, `ExecContext`) to respect deadlines.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See the `samber/cc-skills-golang@golang-concurrency` skill for goroutine cancellation patterns using context
|
||||
- → See the `samber/cc-skills-golang@golang-database` skill for context-aware database operations (QueryContext, ExecContext)
|
||||
- → See the `samber/cc-skills-golang@golang-observability` skill for trace context propagation with OpenTelemetry
|
||||
- → See the `samber/cc-skills-golang@golang-design-patterns` skill for timeout and resilience patterns
|
||||
|
||||
## Enforce with Linters
|
||||
|
||||
Many context pitfalls are caught automatically by linters: `govet`, `staticcheck`. → See the `samber/cc-skills-golang@golang-linter` skill for configuration and usage.
|
||||
@@ -0,0 +1,142 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "context-background-in-handler",
|
||||
"description": "Tests whether the model propagates r.Context() instead of creating context.Background() inside an HTTP handler",
|
||||
"prompt": "Write a Go HTTP handler for GET /orders/:id that fetches an order from a database and calls an external payment service to get payment status. Both operations should be cancellable if the client disconnects. Use database/sql and net/http.",
|
||||
"trap": "Model might create context.Background() inside the handler instead of using r.Context(), breaking cancellation chain",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Uses r.Context() to obtain the request context, NOT context.Background() inside the handler"},
|
||||
{"id": "1.2", "text": "Passes the same context (or a derived child) to the database query (QueryRowContext or similar *Context variant)"},
|
||||
{"id": "1.3", "text": "Passes the same context to the external HTTP call via http.NewRequestWithContext"},
|
||||
{"id": "1.4", "text": "Does NOT use http.NewRequest (without context) for the external service call"},
|
||||
{"id": "1.5", "text": "Checks ctx.Err() or handles context cancellation when the client disconnects"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "cancel-leak-timeout",
|
||||
"description": "Tests whether the model defers cancel() immediately after WithTimeout to prevent resource leaks",
|
||||
"prompt": "Write a Go function that retries an HTTP request up to 3 times with a 5-second timeout per attempt. Each attempt should have its own independent timeout. Return the response body as []byte or the last error.",
|
||||
"trap": "Model might forget to defer cancel() after WithTimeout, or create a single timeout for all retries instead of per-attempt",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Creates a new context.WithTimeout for each retry attempt (not one timeout for all retries)"},
|
||||
{"id": "2.2", "text": "Calls defer cancel() (or cancel() before next iteration) for every WithTimeout call"},
|
||||
{"id": "2.3", "text": "Does NOT discard the cancel function with _ (e.g., ctx, _ = context.WithTimeout(...))"},
|
||||
{"id": "2.4", "text": "Uses http.NewRequestWithContext to attach the per-attempt timeout context"},
|
||||
{"id": "2.5", "text": "Accepts a parent context parameter and derives timeouts from it"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "context-value-key-type",
|
||||
"description": "Tests whether the model uses unexported key types for context values instead of string keys",
|
||||
"prompt": "Write Go middleware that extracts a tenant ID from the X-Tenant-ID header and makes it available to downstream handlers. Also write a helper function to retrieve the tenant ID from the context. Other packages in the codebase will import and use this helper.",
|
||||
"trap": "Model might use a plain string key like context.WithValue(ctx, \"tenant_id\", ...) which causes namespace collisions across packages",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Uses an unexported type for the context key (e.g., type contextKey string or type tenantKey struct{})"},
|
||||
{"id": "3.2", "text": "Does NOT use a plain string as the context key (e.g., context.WithValue(ctx, \"tenant_id\", ...))"},
|
||||
{"id": "3.3", "text": "Provides a typed getter function (e.g., TenantIDFromContext) that returns the value with proper type assertion"},
|
||||
{"id": "3.4", "text": "Provides a setter function or the middleware injects the value using the unexported key"},
|
||||
{"id": "3.5", "text": "The getter handles the case where the value is missing from the context (returns zero value + bool or error)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "context-in-struct-trap",
|
||||
"description": "Tests whether the model avoids storing context.Context in a struct field",
|
||||
"prompt": "Design a Go Worker struct that processes jobs from a channel. The worker should support graceful shutdown — when told to stop, it finishes the current job and exits. Write NewWorker, Start, and Stop methods.",
|
||||
"trap": "Model might store ctx context.Context as a struct field for shutdown signaling instead of passing it through function parameters",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Does NOT store context.Context as a field in the Worker struct"},
|
||||
{"id": "4.2", "text": "Passes context as a parameter to Start() or Run() method (e.g., Start(ctx context.Context))"},
|
||||
{"id": "4.3", "text": "Uses context cancellation or a done channel for graceful shutdown signaling"},
|
||||
{"id": "4.4", "text": "Listens to ctx.Done() in a select statement to detect shutdown"},
|
||||
{"id": "4.5", "text": "ctx is the first parameter where it appears, named ctx context.Context"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "without-cancel-background-work",
|
||||
"description": "Tests whether the model uses context.WithoutCancel for background work that must outlive the request",
|
||||
"prompt": "Write a Go HTTP handler that processes a payment. After successfully charging the customer, it must send an audit log event to an external audit service asynchronously. The audit log MUST complete even if the client disconnects immediately after receiving the 200 response. The audit log needs the trace_id from the request context for correlation. Target Go 1.21+.",
|
||||
"trap": "Model might use context.Background() (losing trace_id) or pass r.Context() directly to the goroutine (cancelled when handler returns)",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Uses context.WithoutCancel to create a context for the audit goroutine"},
|
||||
{"id": "5.2", "text": "Does NOT pass r.Context() directly to the background audit goroutine (it gets cancelled when the handler returns)"},
|
||||
{"id": "5.3", "text": "Does NOT use context.Background() for the audit goroutine (that would lose trace_id and other values)"},
|
||||
{"id": "5.4", "text": "The audit goroutine preserves request-scoped values (trace_id) from the original context"},
|
||||
{"id": "5.5", "text": "Launches the audit as a separate goroutine (go keyword) so the handler can return immediately"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "nested-timeout-shorter-wins",
|
||||
"description": "Tests understanding that nested timeouts always use the shorter deadline",
|
||||
"prompt": "Write a Go service method that calls two downstream services sequentially. The overall operation has a 10-second timeout. The first call (to a fast cache) should have a 2-second timeout. The second call (to a slow database) should have an 8-second timeout. If the cache is slow, the database should still get its full 8 seconds. Implement this timeout hierarchy.",
|
||||
"trap": "Model might naively nest WithTimeout(parentCtx, 8s) under a parent that already has 10s-minus-elapsed, not realizing that if the first call takes 2 seconds the parent only has 8s left — or worse, create child with longer timeout than parent remaining",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Creates the overall 10-second timeout from the parent context"},
|
||||
{"id": "6.2", "text": "Creates the cache timeout as a child of the overall context (so the 2s cache timeout is bounded by the 10s overall)"},
|
||||
{"id": "6.3", "text": "Acknowledges or handles that the database timeout is bounded by whatever time remains on the parent (not a fresh 8 seconds independent of the parent)"},
|
||||
{"id": "6.4", "text": "Defers cancel() for every WithTimeout call"},
|
||||
{"id": "6.5", "text": "Does NOT create independent context.Background() timeouts that bypass the overall deadline"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "context-todo-vs-background",
|
||||
"description": "Tests correct usage of context.TODO() vs context.Background()",
|
||||
"prompt": "I'm refactoring a Go codebase to add context support. Many functions don't accept context yet. Write a migration plan showing how to incrementally add context.Context to this call chain: main() -> runServer() -> handleRequest() -> processOrder() -> saveToDatabase(). Some functions already accept context, others don't yet. Show intermediate steps with code.",
|
||||
"trap": "Model might use context.Background() everywhere as a placeholder. Skill teaches context.TODO() is the correct placeholder when a function needs a context but doesn't have one yet",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses context.TODO() (not context.Background()) as the temporary placeholder in functions not yet fully migrated"},
|
||||
{"id": "7.2", "text": "Uses context.Background() only at the true top level (main function or test setup)"},
|
||||
{"id": "7.3", "text": "Shows a migration path where context.TODO() is gradually replaced as callers are updated"},
|
||||
{"id": "7.4", "text": "Context is always the first parameter, named ctx context.Context"},
|
||||
{"id": "7.5", "text": "Does NOT pass nil as a context value at any point in the migration"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "db-context-variants",
|
||||
"description": "Tests that the model uses *Context database method variants instead of non-context ones",
|
||||
"prompt": "Write a Go repository layer for a User CRUD. Implement Create, GetByID, Update, and Delete methods using database/sql. Each method receives a context from the service layer. The repository should respect request cancellation — if the client disconnects, long-running queries should be cancelled.",
|
||||
"trap": "Model might use db.Query, db.Exec, db.QueryRow instead of their *Context variants (QueryContext, ExecContext, QueryRowContext), silently ignoring the context",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Uses db.QueryRowContext (not db.QueryRow) for GetByID"},
|
||||
{"id": "8.2", "text": "Uses db.ExecContext (not db.Exec) for Create, Update, and Delete"},
|
||||
{"id": "8.3", "text": "Passes the ctx parameter to every database call"},
|
||||
{"id": "8.4", "text": "Does NOT ignore the ctx parameter by calling non-context database methods"},
|
||||
{"id": "8.5", "text": "Each method accepts ctx context.Context as its first parameter"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "context-values-abuse",
|
||||
"description": "Tests that the model does not abuse context values for function parameters",
|
||||
"prompt": "Write a Go service that processes orders. The service needs: a database connection, a logger, a user ID (from auth), a trace ID (from middleware), and the order details. Design the ProcessOrder function signature and show how to pass all these dependencies.",
|
||||
"trap": "Model might stuff the database connection, logger, or order details into context values instead of passing them as explicit parameters or struct fields",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Database connection is passed as a struct field or explicit parameter, NOT via context value"},
|
||||
{"id": "9.2", "text": "Logger is passed as a struct field or explicit parameter, NOT via context value"},
|
||||
{"id": "9.3", "text": "Order details are passed as an explicit function parameter, NOT via context value"},
|
||||
{"id": "9.4", "text": "User ID and/or trace ID are stored in context values (these are request-scoped metadata)"},
|
||||
{"id": "9.5", "text": "Distinguishes between infrastructure dependencies (explicit) and request-scoped metadata (context values)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "afterfunc-cleanup",
|
||||
"description": "Tests awareness of context.AfterFunc for cleanup callbacks (Go 1.21+)",
|
||||
"prompt": "Write a Go function that opens a temporary file for processing and must clean it up when the request context is cancelled. The file should be deleted asynchronously when the context is done, without blocking the main processing flow. The main function should continue processing immediately after registering the cleanup. Target Go 1.21+.",
|
||||
"trap": "Model might use a goroutine with <-ctx.Done() manually or use defer (which doesn't handle context cancellation). Skill teaches context.AfterFunc for non-blocking cleanup callbacks",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Uses context.AfterFunc to register the cleanup callback"},
|
||||
{"id": "10.2", "text": "Does NOT block the main flow waiting for context cancellation (no <-ctx.Done() in the main goroutine for cleanup purposes)"},
|
||||
{"id": "10.3", "text": "The cleanup function removes the temporary file"},
|
||||
{"id": "10.4", "text": "Captures the stop function returned by AfterFunc for potential cancellation of the callback"},
|
||||
{"id": "10.5", "text": "Also includes a defer-based cleanup as a safety net (AfterFunc + defer for belt-and-suspenders)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
# Cancellation, Timeouts & Deadlines
|
||||
|
||||
## Cancellation
|
||||
|
||||
`context.WithCancel` returns a derived context and a `cancel` function. When `cancel()` is called, the context's `Done()` channel is closed, signaling all listeners to stop.
|
||||
|
||||
```go
|
||||
func processItems(ctx context.Context, items []Item) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel() // always defer cancel to free resources
|
||||
|
||||
errCh := make(chan error, len(items))
|
||||
for _, item := range items {
|
||||
go func(item Item) {
|
||||
errCh <- processOne(ctx, item)
|
||||
}(item)
|
||||
}
|
||||
|
||||
for range items {
|
||||
if err := <-errCh; err != nil {
|
||||
cancel() // cancel remaining goroutines on first error
|
||||
return fmt.Errorf("processing items: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Why `defer cancel()` matters
|
||||
|
||||
Every `WithCancel`, `WithTimeout`, and `WithDeadline` allocates internal resources (timers, goroutines). cancel() MUST be called (via defer) to prevent resource leaks. Even if the context will expire on its own, always defer cancel.
|
||||
|
||||
```go
|
||||
// ✗ Bad — cancel is never called, resources leak
|
||||
func fetch(ctx context.Context) error {
|
||||
ctx, _ = context.WithTimeout(ctx, 5*time.Second)
|
||||
return doWork(ctx)
|
||||
}
|
||||
|
||||
// ✓ Good — defer cancel immediately
|
||||
func fetch(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return doWork(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
## Timeouts and Deadlines
|
||||
|
||||
### `context.WithTimeout` — relative duration
|
||||
|
||||
```go
|
||||
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
### `context.WithDeadline` — absolute point in time
|
||||
|
||||
```go
|
||||
func (s *BatchService) ProcessBatch(ctx context.Context, batch Batch) error {
|
||||
// The batch must complete by its SLA deadline
|
||||
ctx, cancel := context.WithDeadline(ctx, batch.SLADeadline)
|
||||
defer cancel()
|
||||
|
||||
for _, item := range batch.Items {
|
||||
if err := s.process(ctx, item); err != nil {
|
||||
return fmt.Errorf("processing batch item %s: %w", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Nested timeouts take the shorter deadline
|
||||
|
||||
If a parent context has a 5s timeout and you create a child with 10s, the child still expires at 5s. The shorter deadline always wins.
|
||||
|
||||
```go
|
||||
// Parent has 2s timeout — child's 10s is effectively ignored
|
||||
parentCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer childCancel()
|
||||
// childCtx expires after 2s, not 10s
|
||||
```
|
||||
|
||||
## Listening for Cancellation
|
||||
|
||||
### The `select` pattern
|
||||
|
||||
Use `ctx.Done()` in a `select` statement to react to cancellation alongside other work:
|
||||
|
||||
```go
|
||||
func poll(ctx context.Context, interval time.Duration) error {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err() // context.Canceled or context.DeadlineExceeded
|
||||
case <-ticker.C:
|
||||
if err := doWork(ctx); err != nil {
|
||||
return fmt.Errorf("polling: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Checking cancellation in loops
|
||||
|
||||
For CPU-bound work, periodically check `ctx.Err()`:
|
||||
|
||||
```go
|
||||
func processLargeDataset(ctx context.Context, items []Item) error {
|
||||
for i, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("processing interrupted after %d/%d items: %w", i, len(items), ctx.Err())
|
||||
}
|
||||
process(item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## `context.AfterFunc` (Go 1.21+)
|
||||
|
||||
Registers a callback that runs in its own goroutine when the context is cancelled. Useful for cleanup without blocking the main flow.
|
||||
|
||||
```go
|
||||
func watchResource(ctx context.Context, res *Resource) {
|
||||
stop := context.AfterFunc(ctx, func() {
|
||||
// Runs in a new goroutine when ctx is cancelled
|
||||
res.Release()
|
||||
})
|
||||
|
||||
// If you no longer need the callback, cancel it:
|
||||
// stop() returns true if the callback was successfully cancelled
|
||||
_ = stop
|
||||
}
|
||||
```
|
||||
|
||||
## `context.WithoutCancel` (Go 1.21+)
|
||||
|
||||
Creates a child context that is not cancelled when the parent is. Use this for background work that must continue after the request completes — like async logging, audit trails, or enqueuing follow-up tasks.
|
||||
|
||||
```go
|
||||
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
order, err := h.orderService.Create(ctx, req)
|
||||
if err != nil {
|
||||
// handle error
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log must complete even if the client disconnects.
|
||||
// WithoutCancel preserves context values (trace_id) but detaches cancellation.
|
||||
auditCtx := context.WithoutCancel(ctx)
|
||||
go h.auditService.LogOrderCreated(auditCtx, order)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
```
|
||||
|
||||
Without `WithoutCancel`, you'd have to choose between `ctx` (which gets cancelled when the handler returns, killing your background work) and `context.Background()` (which loses trace_id and other values). `WithoutCancel` gives you the best of both: values are preserved, but cancellation is detached.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Context in HTTP Servers & Service Calls
|
||||
|
||||
## Context in HTTP Servers
|
||||
|
||||
`http.Request` carries a context that is cancelled when the client disconnects or the request handler returns. MUST use `r.Context()` — NEVER create a new `context.Background()` inside a handler.
|
||||
|
||||
```go
|
||||
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context() // this context is cancelled if the client disconnects
|
||||
|
||||
order, err := h.orderService.Get(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Client disconnected, no point writing a response
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware enriching context
|
||||
|
||||
Middleware injects request-scoped values before handlers run. Use unexported key types to prevent collisions:
|
||||
|
||||
```go
|
||||
// Helpers for trace propagation
|
||||
type contextKey string
|
||||
const (
|
||||
traceIDKey contextKey = "trace_id"
|
||||
spanIDKey contextKey = "span_id"
|
||||
)
|
||||
|
||||
func TracingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := r.Header.Get("X-Trace-ID")
|
||||
if traceID == "" {
|
||||
traceID = generateTraceID()
|
||||
}
|
||||
spanID := r.Header.Get("X-Span-ID")
|
||||
if spanID == "" {
|
||||
spanID = generateSpanID()
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), traceIDKey, traceID)
|
||||
ctx = context.WithValue(ctx, spanIDKey, spanID)
|
||||
|
||||
w.Header().Set("X-Trace-ID", traceID)
|
||||
w.Header().Set("X-Span-ID", spanID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Propagate trace context to downstream services
|
||||
func (c *HTTPClient) Do(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
if traceID, ok := ctx.Value(traceIDKey).(string); ok {
|
||||
req.Header.Set("X-Trace-ID", traceID)
|
||||
}
|
||||
if spanID, ok := ctx.Value(spanIDKey).(string); ok {
|
||||
req.Header.Set("X-Span-ID", spanID)
|
||||
}
|
||||
return c.client.Do(req)
|
||||
}
|
||||
```
|
||||
|
||||
## Context in Calls to Other Services
|
||||
|
||||
Context MUST be propagated to all HTTP clients and databases using context-aware APIs: `http.NewRequestWithContext`, `QueryContext`, `ExecContext`, and `QueryRowContext`. This ensures that client disconnections cancel all downstream operations.
|
||||
|
||||
```go
|
||||
// ✗ Bad — downstream calls ignore the request context
|
||||
func (c *PaymentClient) Charge(ctx context.Context, amount int) error {
|
||||
req, _ := http.NewRequest("POST", c.url+"/charge", body)
|
||||
return c.client.Do(req) // not context-aware
|
||||
}
|
||||
|
||||
// ✓ Good — all downstream operations respect the context
|
||||
func (c *PaymentClient) Charge(ctx context.Context, amount int) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.url+"/charge", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
return c.client.Do(req)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✗ Bad — downstream calls ignore the request context
|
||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
row := r.db.QueryRow("SELECT * FROM users WHERE id = $1", id)
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✓ Good — all downstream operations respect the context
|
||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Context Values & Cross-Service Tracing
|
||||
|
||||
## Using context values correctly
|
||||
|
||||
Context values carry request-scoped metadata that crosses API boundaries — not function parameters, configuration, or optional arguments. Good candidates: trace IDs, span IDs, request IDs, authenticated user info, correlation IDs.
|
||||
|
||||
Always use an unexported type as the key to prevent collisions between packages:
|
||||
|
||||
```go
|
||||
// ✓ Good — unexported key type prevents collisions
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
traceIDKey contextKey = "trace_id"
|
||||
requestIDKey contextKey = "request_id"
|
||||
)
|
||||
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey, traceID)
|
||||
}
|
||||
|
||||
func TraceIDFromContext(ctx context.Context) (string, bool) {
|
||||
traceID, ok := ctx.Value(traceIDKey).(string)
|
||||
return traceID, ok
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✗ Bad — string keys collide across packages
|
||||
ctx = context.WithValue(ctx, "trace_id", traceID) // another package could use the same key
|
||||
```
|
||||
|
||||
## What belongs in context values vs function parameters
|
||||
|
||||
| Data | Context value? | Why |
|
||||
| --- | --- | --- |
|
||||
| trace_id, span_id, request_id | Yes | Request-scoped metadata for observability |
|
||||
| Authenticated user/tenant | Yes | Request-scoped, crosses API boundaries |
|
||||
| Database connection | No | Infrastructure dependency, pass explicitly |
|
||||
| Feature flags | No | Configuration, pass explicitly or inject |
|
||||
| Function arguments (user ID, order data) | No | Business logic parameters, pass as arguments |
|
||||
| Logger | Depends | OK if enriched with request-scoped fields (trace_id); otherwise pass explicitly |
|
||||
|
||||
## Trace propagation between services
|
||||
|
||||
In a microservices architecture, `context.Context` is the vehicle for trace propagation. When Service A calls Service B, the trace_id and span_id travel through context values and are injected into outgoing HTTP headers (typically via OpenTelemetry). This creates a connected trace across the entire request path.
|
||||
|
||||
```go
|
||||
// Middleware injects trace_id from incoming request headers into context
|
||||
func TracingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := r.Header.Get("X-Trace-ID")
|
||||
if traceID == "" {
|
||||
traceID = generateTraceID()
|
||||
}
|
||||
|
||||
ctx := WithTraceID(r.Context(), traceID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// When making outbound HTTP calls, inject trace_id from context into headers
|
||||
func (c *HTTPClient) Do(ctx context.Context, method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
// Propagate trace_id to downstream service
|
||||
if traceID, ok := TraceIDFromContext(ctx); ok {
|
||||
req.Header.Set("X-Trace-ID", traceID)
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
```
|
||||
|
||||
With OpenTelemetry, this propagation is handled automatically through the `otel` SDK and `propagation.TraceContext`, but the mechanism is the same: context carries the trace state, and it must be propagated through every layer.
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: golang-continuous-integration
|
||||
description: Provides CI/CD pipeline configuration using GitHub Actions for Golang projects. Covers testing, linting, SAST, security scanning, code coverage, Dependabot, Renovate, GoReleaser, code review automation, and release pipelines. Use this whenever setting up CI for a Go project, configuring workflows, adding linters or security scanners, setting up Dependabot or Renovate, automating releases, or improving an existing CI pipeline. Also use when the user wants to add quality gates to their Go project.
|
||||
user-invocable: true
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch Bash(goreleaser:*) Bash(gh:*)
|
||||
---
|
||||
|
||||
**Persona:** You are a Go DevOps engineer. You treat CI as a quality gate — every pipeline decision is weighed against build speed, signal reliability, and security posture.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Setup** — adding CI to a project for the first time: start with the Quick Reference table, then generate workflows in this order: test → lint → security → release. Always check latest action versions before writing YAML.
|
||||
- **Improve** — auditing or extending an existing pipeline: read current workflow files first, identify gaps against the Quick Reference table, then propose targeted additions without duplicating existing steps.
|
||||
|
||||
# Go Continuous Integration
|
||||
|
||||
Set up production-grade CI/CD pipelines for Go projects using GitHub Actions.
|
||||
|
||||
## Action Versions
|
||||
|
||||
The versions shown in the examples below are reference versions that may be outdated. Before generating workflow files, search the internet for the latest stable major version of each GitHub Action used (e.g., `actions/checkout`, `actions/setup-go`, `golangci/golangci-lint-action`, `codecov/codecov-action`, `goreleaser/goreleaser-action`, etc.). Use the latest version you find, not the one hardcoded in the examples.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Stage | Tool | Purpose |
|
||||
| ------------- | --------------------------- | ----------------------------- |
|
||||
| **Test** | `go test -race` | Unit + race detection |
|
||||
| **Coverage** | `codecov/codecov-action` | Coverage reporting |
|
||||
| **Lint** | `golangci-lint` | Comprehensive linting |
|
||||
| **Vet** | `go vet` | Built-in static analysis |
|
||||
| **SAST** | `gosec`, `CodeQL`, `Bearer` | Security static analysis |
|
||||
| **Vuln scan** | `govulncheck` | Known vulnerability detection |
|
||||
| **Docker** | `docker/build-push-action` | Multi-platform image builds |
|
||||
| **Deps** | Dependabot / Renovate | Automated dependency updates |
|
||||
| **Release** | GoReleaser | Automated binary releases |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
`.github/workflows/test.yml` — see [test.yml](./assets/test.yml)
|
||||
|
||||
Adapt the Go version matrix to match `go.mod`:
|
||||
|
||||
```
|
||||
go 1.23 → matrix: ["1.23", "1.24", "1.25", "1.26", "stable"]
|
||||
go 1.24 → matrix: ["1.24", "1.25", "1.26", "stable"]
|
||||
go 1.25 → matrix: ["1.25", "1.26", "stable"]
|
||||
go 1.26 → matrix: ["1.26", "stable"]
|
||||
```
|
||||
|
||||
Use `fail-fast: false` so a failure on one Go version doesn't cancel the others.
|
||||
|
||||
Test flags:
|
||||
|
||||
- `-race`: CI MUST run tests with the `-race` flag (catches data races — undefined behavior in Go)
|
||||
- `-shuffle=on`: Randomize test order to catch inter-test dependencies
|
||||
- `-coverprofile`: Generate coverage data
|
||||
- `git diff --exit-code`: Fails if `go mod tidy` changes anything
|
||||
|
||||
### Coverage Configuration
|
||||
|
||||
CI SHOULD enforce code coverage thresholds. Configure thresholds in `codecov.yml` at the repo root — see [codecov.yml](./assets/codecov.yml)
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
`.github/workflows/integration.yml` — see [integration.yml](./assets/integration.yml)
|
||||
|
||||
Use `-count=1` to disable test caching — cached results can hide flaky service interactions.
|
||||
|
||||
---
|
||||
|
||||
## Linting
|
||||
|
||||
`golangci-lint` MUST be run in CI on every PR. `.github/workflows/lint.yml` — see [lint.yml](./assets/lint.yml)
|
||||
|
||||
### golangci-lint Configuration
|
||||
|
||||
Create `.golangci.yml` at the root of the project. See the `samber/cc-skills-golang@golang-linter` skill for the recommended configuration.
|
||||
|
||||
---
|
||||
|
||||
## Security & SAST
|
||||
|
||||
`.github/workflows/security.yml` — see [security.yml](./assets/security.yml)
|
||||
|
||||
CI MUST run `govulncheck`. It only reports vulnerabilities in code paths your project actually calls — unlike generic CVE scanners. CodeQL results appear in the repository's Security tab. Bearer is good at detecting sensitive data flow issues.
|
||||
|
||||
### CodeQL Configuration
|
||||
|
||||
Create `.github/codeql/codeql-config.yml` to use the extended security query suite — see [codeql-config.yml](./assets/codeql-config.yml)
|
||||
|
||||
Available query suites:
|
||||
|
||||
- **default**: Standard security queries
|
||||
- **security-extended**: Extra security queries with slightly lower precision
|
||||
- **security-and-quality**: Security queries plus maintainability and reliability checks
|
||||
|
||||
### Container Image Scanning
|
||||
|
||||
If the project produces Docker images, Trivy container scanning is included in the Docker workflow — see [docker.yml](./assets/docker.yml)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Management
|
||||
|
||||
### Dependabot
|
||||
|
||||
`.github/dependabot.yml` — see [dependabot.yml](./assets/dependabot.yml)
|
||||
|
||||
Minor/patch updates are grouped into a single PR. Major updates get individual PRs since they may have breaking changes.
|
||||
|
||||
#### Auto-Merge for Dependabot
|
||||
|
||||
`.github/workflows/dependabot-auto-merge.yml` — see [dependabot-auto-merge.yml](./assets/dependabot-auto-merge.yml)
|
||||
|
||||
> **Security warning:** This workflow requires `contents: write` and `pull-requests: write` — these are elevated permissions that allow merging PRs and modifying repository content. The `if: github.actor == 'dependabot[bot]'` guard restricts execution to Dependabot only. Do not remove this guard. Note that `github.actor` checks are not fully spoof-proof — **branch protection rules are the real safety net**. Ensure branch protection is configured (see [Repository Security Settings](#repository-security-settings)) with required status checks and required approvals so that auto-merge only succeeds after all checks pass, regardless of who triggered the workflow.
|
||||
|
||||
### Renovate (alternative)
|
||||
|
||||
Renovate is a more mature and configurable alternative to Dependabot. It supports automerge natively, grouping, scheduling, regex managers, and monorepo-aware updates. If Dependabot feels too limited, Renovate is the go-to choice.
|
||||
|
||||
Install the [Renovate GitHub App](https://github.com/apps/renovate), then create `renovate.json` at the repo root — see [renovate.json](./assets/renovate.json)
|
||||
|
||||
Key advantages over Dependabot:
|
||||
|
||||
- **`gomodTidy`**: Automatically runs `go mod tidy` after updates
|
||||
- **Native automerge**: No separate workflow needed
|
||||
- **Better grouping**: More flexible rules for grouping PRs
|
||||
- **Regex managers**: Can update versions in Dockerfiles, Makefiles, etc.
|
||||
- **Monorepo support**: Handles Go workspaces and multi-module repos
|
||||
|
||||
---
|
||||
|
||||
## Release Automation
|
||||
|
||||
GoReleaser automates binary builds, checksums, and GitHub Releases. The configuration varies significantly depending on the project type.
|
||||
|
||||
### Release Workflow
|
||||
|
||||
`.github/workflows/release.yml` — see [release.yml](./assets/release.yml)
|
||||
|
||||
> **Security warning:** This workflow requires `contents: write` to create GitHub Releases. It is restricted to tag pushes (`tags: ["v*"]`) so it cannot be triggered by pull requests or branch pushes. Only users with push access to the repository can create tags.
|
||||
|
||||
### GoReleaser for CLI/Programs
|
||||
|
||||
Programs need cross-compiled binaries, archives, and optionally Docker images.
|
||||
|
||||
`.goreleaser.yml` — see [goreleaser-cli.yml](./assets/goreleaser-cli.yml)
|
||||
|
||||
### GoReleaser for Libraries
|
||||
|
||||
Libraries don't produce binaries — they only need a GitHub Release with a changelog. Use a minimal config that skips the build.
|
||||
|
||||
`.goreleaser.yml` — see [goreleaser-lib.yml](./assets/goreleaser-lib.yml)
|
||||
|
||||
For libraries, you may not even need GoReleaser — a simple GitHub Release created via the UI or `gh release create` is often sufficient.
|
||||
|
||||
### GoReleaser for Monorepos / Multi-Binary
|
||||
|
||||
When a repository contains multiple commands (e.g., `cmd/api/`, `cmd/worker/`).
|
||||
|
||||
`.goreleaser.yml` — see [goreleaser-monorepo.yml](./assets/goreleaser-monorepo.yml)
|
||||
|
||||
### Docker Build & Push
|
||||
|
||||
For projects that produce Docker images. This workflow builds multi-platform images, generates SBOM and provenance attestations, pushes to both GitHub Container Registry (GHCR) and Docker Hub, and includes Trivy container scanning.
|
||||
|
||||
`.github/workflows/docker.yml` — see [docker.yml](./assets/docker.yml)
|
||||
|
||||
> **Security warning:** Permissions are scoped per job: the `container-scan` job only gets `contents: read` + `security-events: write`, while the `docker` job gets `packages: write` (to push to GHCR) and `attestations: write` + `id-token: write` (for provenance/SBOM signing). This ensures the scan job cannot push images even if compromised. The `push` flag is set to `false` on pull requests so untrusted code cannot publish images. The `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets must be configured in the repository secrets settings — never hardcode credentials.
|
||||
|
||||
Key details:
|
||||
|
||||
- **QEMU + Buildx**: Required for multi-platform builds (`linux/amd64,linux/arm64`). Remove platforms you don't need.
|
||||
- **`push: false` on PRs**: Images are built but never pushed on pull requests — this validates the Dockerfile without publishing untrusted code.
|
||||
- **Metadata action**: Automatically generates semver tags (`v1.2.3` → `1.2.3`, `1.2`, `1`), branch tags (`main`), and SHA tags.
|
||||
- **Provenance + SBOM**: `provenance: mode=max` and `sbom: true` generate supply chain attestations. These require `attestations: write` and `id-token: write` permissions.
|
||||
- **Dual registry**: Pushes to both GHCR (using `GITHUB_TOKEN`, no extra secret needed) and Docker Hub (requires `DOCKERHUB_USERNAME` + `DOCKERHUB_TOKEN` secrets). Remove the Docker Hub login and image line if not needed.
|
||||
- **Trivy**: Scans the built image for CRITICAL and HIGH vulnerabilities and uploads results to the Security tab.
|
||||
- Adapt the image names and registries to your project. For GHCR-only, remove the Docker Hub login step and the `docker.io/` line from `images:`.
|
||||
|
||||
---
|
||||
|
||||
## Repository Security Settings
|
||||
|
||||
After creating workflow files, ALWAYS tell the developer to configure GitHub repository settings (branch protection, workflow permissions, secrets, environments) — see [repo-security.md](./references/repo-security.md)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Missing `-race` in CI tests | Always use `go test -race` |
|
||||
| No `-shuffle=on` | Randomize test order to catch inter-test dependencies |
|
||||
| Caching integration test results | Use `-count=1` to disable caching |
|
||||
| `go mod tidy` not checked | Add `go mod tidy && git diff --exit-code` step |
|
||||
| Missing `fail-fast: false` | One Go version failing shouldn't cancel other jobs |
|
||||
| Not pinning action versions | GitHub Actions MUST use pinned major versions (e.g. `@vN`, not `@master`) |
|
||||
| No `permissions` block | Follow least-privilege per job |
|
||||
| Ignoring govulncheck findings | Fix or suppress with justification |
|
||||
|
||||
## Related Skills
|
||||
|
||||
See `samber/cc-skills-golang@golang-linter`, `samber/cc-skills-golang@golang-security`, `samber/cc-skills-golang@golang-testing`, `samber/cc-skills-golang@golang-dependency-management` skills.
|
||||
@@ -0,0 +1,9 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 80%
|
||||
threshold: 2%
|
||||
patch:
|
||||
default:
|
||||
target: 80%
|
||||
@@ -0,0 +1,8 @@
|
||||
name: "CodeQL config"
|
||||
|
||||
queries:
|
||||
- uses: security-and-quality
|
||||
|
||||
query-filters:
|
||||
- exclude:
|
||||
id: go/unused-result
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Dependabot Auto-Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto-Merge
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Auto-merge minor and patch updates
|
||||
if: steps.metadata.outputs.update-type != 'version-update:semver-major'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Go modules
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
labels: ["dependencies", "go"]
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
go-minor-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
labels: ["dependencies", "ci"]
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
|
||||
# Docker (if applicable)
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
labels: ["dependencies", "docker"]
|
||||
@@ -0,0 +1,94 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
container-scan:
|
||||
name: Container Scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t myapp:ci .
|
||||
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@v0.35.0
|
||||
with:
|
||||
image-ref: myapp:ci
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: CRITICAL,HIGH
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
|
||||
docker:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: container-scan
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
docker.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch
|
||||
type=sha
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,31 @@
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.Commit}}
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude: ["^docs", "^test", "^ci", "^chore", "^style"]
|
||||
@@ -0,0 +1,9 @@
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- skip: true
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude: ["^docs:", "^test:", "^ci:", "^chore:"]
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
|
||||
builds:
|
||||
- id: api
|
||||
main: ./cmd/api
|
||||
binary: api
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
- id: worker
|
||||
main: ./cmd/worker
|
||||
binary: worker
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: testdb
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Run integration tests
|
||||
run: go test -v -race -tags=integration -count=1 ./...
|
||||
env:
|
||||
DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
|
||||
REDIS_URL: redis://localhost:6379
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout 5m
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": ["gomod"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true,
|
||||
"groupName": "go minor/patch dependencies"
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions"],
|
||||
"automerge": true,
|
||||
"groupName": "github actions"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
name: Vulnerability Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Run govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
|
||||
gosec:
|
||||
name: gosec
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run gosec
|
||||
uses: securego/gosec@v2
|
||||
with:
|
||||
args: ./...
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: go
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
bearer:
|
||||
name: Bearer
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Bearer Security Scan
|
||||
uses: bearer/bearer-action@v2
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Go ${{ matrix.go }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
go:
|
||||
- "1.25"
|
||||
- "1.26"
|
||||
- "stable"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
go mod verify
|
||||
go mod download
|
||||
|
||||
- name: Check go mod tidy
|
||||
run: |
|
||||
go mod tidy
|
||||
git diff --exit-code go.mod go.sum
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -shuffle=on -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.go == 'stable'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage.out
|
||||
fail_ci_if_error: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,185 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "test-workflow-flags",
|
||||
"description": "Tests whether CI test workflows include all required flags (-race, -shuffle, -coverprofile) and use fail-fast: false",
|
||||
"prompt": "Create a GitHub Actions workflow file for running Go tests on a library that supports Go 1.25+. The project uses codecov for coverage. Just give me the YAML.",
|
||||
"trap": "Model may omit -shuffle=on, forget fail-fast: false, or skip the go mod tidy check",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Workflow includes -race flag in the go test command"},
|
||||
{"id": "1.2", "text": "Workflow includes -shuffle=on flag in the go test command"},
|
||||
{"id": "1.3", "text": "Workflow includes -coverprofile flag in the go test command"},
|
||||
{"id": "1.4", "text": "Strategy uses fail-fast: false"},
|
||||
{"id": "1.5", "text": "Go version matrix includes at least 'stable' and one explicit version like '1.25' or '1.26'"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "go-mod-tidy-check",
|
||||
"description": "Tests whether the workflow enforces go mod tidy consistency via git diff --exit-code",
|
||||
"prompt": "I want to make sure our Go CI catches cases where someone forgot to run go mod tidy before pushing. How should I add this check to our GitHub Actions workflow?",
|
||||
"trap": "Model may suggest running go mod tidy without the git diff --exit-code step to actually fail the build on changes",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Suggests running 'go mod tidy' as a CI step"},
|
||||
{"id": "2.2", "text": "Includes 'git diff --exit-code' after go mod tidy to detect uncommitted changes"},
|
||||
{"id": "2.3", "text": "The git diff checks go.mod and/or go.sum specifically, or uses a general git diff --exit-code"},
|
||||
{"id": "2.4", "text": "Also includes 'go mod verify' or 'go mod download' step"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "integration-test-caching",
|
||||
"description": "Tests knowledge that integration tests must use -count=1 to disable caching",
|
||||
"prompt": "I have integration tests that interact with PostgreSQL and Redis via GitHub Actions service containers. Sometimes tests pass even when the services are broken because Go seems to cache test results. How do I set up the workflow?",
|
||||
"trap": "Model may not know about -count=1 to disable test caching, or may suggest other workarounds",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Uses -count=1 flag to disable test result caching"},
|
||||
{"id": "3.2", "text": "Includes -race flag for integration tests"},
|
||||
{"id": "3.3", "text": "Uses build tags (e.g., -tags=integration) to separate integration tests"},
|
||||
{"id": "3.4", "text": "Uses GitHub Actions 'services' block for PostgreSQL and/or Redis"},
|
||||
{"id": "3.5", "text": "Includes health check options for service containers"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "security-scanning-pipeline",
|
||||
"description": "Tests whether the model recommends the full security stack: govulncheck, gosec, CodeQL, and Bearer",
|
||||
"prompt": "I want to add security scanning to my Go project's CI pipeline. What tools should I use and how do I set them up in GitHub Actions?",
|
||||
"trap": "Model may only suggest one or two tools (e.g., just gosec) and miss govulncheck (call-path-aware), CodeQL (Security tab integration), or Bearer (sensitive data flow)",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Recommends govulncheck and explains it only reports vulnerabilities in actually-called code paths"},
|
||||
{"id": "4.2", "text": "Recommends gosec for Go security scanning"},
|
||||
{"id": "4.3", "text": "Recommends CodeQL and mentions the security-extended or security-and-quality query suite"},
|
||||
{"id": "4.4", "text": "Recommends Bearer for sensitive data flow issues"},
|
||||
{"id": "4.5", "text": "Workflow includes security-events: write permission for SARIF upload"},
|
||||
{"id": "4.6", "text": "Suggests creating a CodeQL config file to use an extended query suite rather than just the default"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "dependabot-grouping-strategy",
|
||||
"description": "Tests whether Dependabot config groups minor/patch updates but keeps major updates separate",
|
||||
"prompt": "Set up Dependabot for my Go project on GitHub. I want automated dependency update PRs for Go modules, GitHub Actions, and Docker base images.",
|
||||
"trap": "Model may not group minor/patch into a single PR, or may group all updates including majors which could have breaking changes",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Configures Dependabot for gomod package ecosystem"},
|
||||
{"id": "5.2", "text": "Configures Dependabot for github-actions package ecosystem"},
|
||||
{"id": "5.3", "text": "Configures Dependabot for docker package ecosystem"},
|
||||
{"id": "5.4", "text": "Groups minor and patch Go module updates into a single PR"},
|
||||
{"id": "5.5", "text": "Major updates are NOT grouped (individual PRs for breaking changes)"},
|
||||
{"id": "5.6", "text": "Sets a weekly schedule"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "dependabot-auto-merge-security",
|
||||
"description": "Tests awareness of security implications in auto-merge workflow (elevated permissions, actor guard, branch protection as safety net)",
|
||||
"prompt": "I want Dependabot PRs to auto-merge when CI passes, but only for minor and patch updates. Create the workflow. What security concerns should I be aware of?",
|
||||
"trap": "Model may create the workflow without the github.actor guard, without mentioning elevated permissions risk, or without recommending branch protection as the real safety net",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Workflow has 'if: github.actor == dependabot[bot]' guard to restrict execution"},
|
||||
{"id": "6.2", "text": "Workflow checks metadata to exclude major updates from auto-merge"},
|
||||
{"id": "6.3", "text": "Warns about contents: write and pull-requests: write being elevated/high-risk permissions"},
|
||||
{"id": "6.4", "text": "Mentions branch protection rules as the real safety net (not just the actor guard)"},
|
||||
{"id": "6.5", "text": "Notes that github.actor checks are not fully spoof-proof"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "renovate-vs-dependabot",
|
||||
"description": "Tests knowledge of Renovate advantages over Dependabot",
|
||||
"prompt": "I'm using Dependabot for my Go monorepo with multiple modules but it's creating too many PRs and doesn't run go mod tidy. What are my options?",
|
||||
"trap": "Model may suggest workarounds for Dependabot rather than recommending Renovate with its gomodTidy, native automerge, and monorepo support",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Recommends Renovate as an alternative to Dependabot"},
|
||||
{"id": "7.2", "text": "Mentions Renovate's gomodTidy feature (automatic go mod tidy after updates)"},
|
||||
{"id": "7.3", "text": "Mentions Renovate's native automerge without needing a separate workflow"},
|
||||
{"id": "7.4", "text": "Mentions Renovate's monorepo/workspace support"},
|
||||
{"id": "7.5", "text": "Mentions Renovate's better grouping rules"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "goreleaser-library-vs-cli",
|
||||
"description": "Tests knowledge that GoReleaser config differs significantly between libraries and CLI programs",
|
||||
"prompt": "I need to set up GoReleaser for my Go project which is a library (no main package). How should I configure it?",
|
||||
"trap": "Model may generate a full GoReleaser config with builds, archives, and cross-compilation that doesn't apply to libraries",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Uses 'skip: true' in the builds section since libraries don't produce binaries"},
|
||||
{"id": "8.2", "text": "Keeps the config minimal (mainly changelog generation)"},
|
||||
{"id": "8.3", "text": "Mentions that for libraries, a simple GitHub Release via gh release create may be sufficient without GoReleaser"},
|
||||
{"id": "8.4", "text": "Does NOT include cross-compilation (goos/goarch) in the library config"},
|
||||
{"id": "8.5", "text": "Includes changelog configuration"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "docker-workflow-security",
|
||||
"description": "Tests awareness of Docker workflow security: push: false on PRs, per-job permissions, dual registry, provenance/SBOM",
|
||||
"prompt": "Create a GitHub Actions workflow that builds a multi-platform Docker image and pushes it to GHCR. Include security best practices.",
|
||||
"trap": "Model may push images on PRs (allowing untrusted code to publish), use overly broad permissions, or skip provenance/SBOM attestations",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Sets push to false on pull requests to prevent untrusted code from publishing images"},
|
||||
{"id": "9.2", "text": "Uses per-job permissions scoping (not just top-level)"},
|
||||
{"id": "9.3", "text": "Includes QEMU and Buildx setup for multi-platform builds"},
|
||||
{"id": "9.4", "text": "Includes provenance and/or SBOM attestation configuration"},
|
||||
{"id": "9.5", "text": "Includes packages: write permission for GHCR push"},
|
||||
{"id": "9.6", "text": "Login step is conditional on non-PR events"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "permissions-least-privilege",
|
||||
"description": "Tests whether the model follows least-privilege permissions principle and sets GITHUB_TOKEN to read-only by default",
|
||||
"prompt": "I'm setting up CI for a new open-source Go project. What GitHub repository settings should I configure for security? I already have the workflow files.",
|
||||
"trap": "Model may focus only on branch protection and miss workflow permissions, fork PR restrictions, and environment-based approval gates",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Recommends setting default GITHUB_TOKEN to read-only at the repository level"},
|
||||
{"id": "10.2", "text": "Recommends branch protection with required status checks"},
|
||||
{"id": "10.3", "text": "Recommends requiring PR approvals (at least 1)"},
|
||||
{"id": "10.4", "text": "Recommends dismissing stale approvals when new commits are pushed"},
|
||||
{"id": "10.5", "text": "Recommends restricting fork PR workflows for outside collaborators"},
|
||||
{"id": "10.6", "text": "Warns against pull_request_target with untrusted code"},
|
||||
{"id": "10.7", "text": "Recommends creating a release environment with required reviewers"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "release-workflow-fetch-depth",
|
||||
"description": "Tests whether the release workflow uses fetch-depth: 0 for changelog generation",
|
||||
"prompt": "Create a GitHub Actions release workflow that triggers on version tags and runs GoReleaser to produce binaries and a changelog.",
|
||||
"trap": "Model may use default checkout which does a shallow clone, causing GoReleaser to generate an incomplete or empty changelog",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Checkout step uses fetch-depth: 0 for full git history"},
|
||||
{"id": "11.2", "text": "Workflow triggers on tag push with a v* pattern"},
|
||||
{"id": "11.3", "text": "Uses contents: write permission for creating releases"},
|
||||
{"id": "11.4", "text": "Passes GITHUB_TOKEN to GoReleaser"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "action-version-pinning",
|
||||
"description": "Tests whether actions are pinned to major versions not branches",
|
||||
"prompt": "Review this GitHub Actions step and tell me if there are any issues:\n\n```yaml\nsteps:\n - uses: actions/checkout@master\n - uses: actions/setup-go@main\n with:\n go-version: stable\n```",
|
||||
"trap": "Model may not notice the branch references (@master, @main) instead of pinned major versions",
|
||||
"assertions": [
|
||||
{"id": "12.1", "text": "Identifies that using @master and @main is wrong and insecure"},
|
||||
{"id": "12.2", "text": "Recommends pinning to major versions like @v4, @v6"},
|
||||
{"id": "12.3", "text": "Explains the risk: branch references can change unexpectedly or be compromised"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "coverage-threshold-configuration",
|
||||
"description": "Tests knowledge of codecov.yml configuration with project and patch targets",
|
||||
"prompt": "I want to enforce that our Go project maintains at least 80% code coverage and that each PR doesn't drop coverage by more than 2%. How do I configure this?",
|
||||
"trap": "Model may only configure project-level thresholds and miss patch-level coverage targets",
|
||||
"assertions": [
|
||||
{"id": "13.1", "text": "Configures codecov.yml (not just CLI flags) for coverage thresholds"},
|
||||
{"id": "13.2", "text": "Sets project target to 80%"},
|
||||
{"id": "13.3", "text": "Sets a threshold value (e.g., 2%) to allow small drops"},
|
||||
{"id": "13.4", "text": "Configures patch coverage target for new code in PRs"},
|
||||
{"id": "13.5", "text": "Coverage upload is conditional on a single matrix entry (e.g., only on stable)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# Repository Security Settings
|
||||
|
||||
After creating workflow files, ALWAYS tell the developer to configure these GitHub repository settings. These are not optional — they are the security foundation that makes the CI pipeline trustworthy.
|
||||
|
||||
Determine the project's GitHub URL from its git remote (e.g., `git remote -v`) and build clickable links to the settings pages. For a project hosted at `https://github.com/{owner}/{repo}`, the relevant links are:
|
||||
|
||||
- Branch protection: `https://github.com/{owner}/{repo}/settings/branches`
|
||||
- Actions permissions: `https://github.com/{owner}/{repo}/settings/actions`
|
||||
- Secrets: `https://github.com/{owner}/{repo}/settings/secrets/actions`
|
||||
- Environments: `https://github.com/{owner}/{repo}/settings/environments`
|
||||
|
||||
Provide these links to the developer so they can click directly to the right settings page.
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
Configure a branch protection rule for `main` (or the default branch):
|
||||
|
||||
1. **Require a pull request before merging** — prevents direct pushes to main
|
||||
2. **Require approvals** (at least 1) — no self-merging without review
|
||||
3. **Dismiss stale pull request approvals when new commits are pushed** — prevents approving then sneaking in changes
|
||||
4. **Require status checks to pass before merging** — add all CI workflow job names as required checks (e.g., `Test (Go 1.24)`, `Test (Go stable)`, `Lint`)
|
||||
5. **Require branches to be up to date before merging** — prevents merging stale PRs that haven't been tested against latest main
|
||||
6. **Do not allow bypassing the above settings** — applies rules to admins too
|
||||
|
||||
## Workflow Permissions
|
||||
|
||||
Set the default `GITHUB_TOKEN` to **read-only** at the repository level:
|
||||
|
||||
1. Go to **Actions permissions** (link above)
|
||||
2. Workflow permissions MUST follow least privilege. Under **Workflow permissions**, select **"Read repository contents and packages permissions"**
|
||||
3. Uncheck **"Allow GitHub Actions to create and approve pull requests"** (unless auto-merge is needed — then check it only for that purpose)
|
||||
|
||||
This means workflows start with no write access by default. Each workflow that needs elevated permissions must explicitly declare them in its `permissions:` block. This is defense-in-depth: if a workflow is compromised, it cannot write to the repository unless explicitly granted.
|
||||
|
||||
## Fork Pull Request Restrictions
|
||||
|
||||
For public/open-source repositories:
|
||||
|
||||
1. In **Actions permissions** (link above), set **"Fork pull request workflows from outside collaborators"** to **"Require approval for all outside collaborators"**
|
||||
2. This prevents untrusted forks from running workflows that consume your Actions minutes or access secrets
|
||||
3. NEVER use `pull_request_target` with untrusted code — it runs with write access to the base repo
|
||||
|
||||
## Secrets and Environments
|
||||
|
||||
- Never put secrets in workflow files — use **Secrets** settings (link above)
|
||||
- For release workflows, create a **"release" environment** with required reviewers in **Environments** (link above) to add a manual approval gate before publishing
|
||||
- Rotate `CODECOV_TOKEN` and other third-party tokens periodically
|
||||
|
||||
## Permissions Cheat Sheet
|
||||
|
||||
Warn the developer about the security implications of every permission used:
|
||||
|
||||
| Permission | Workflows that need it | Risk |
|
||||
| --- | --- | --- |
|
||||
| `contents: read` | All workflows | **Low** — read-only, default safe |
|
||||
| `contents: write` | Release, auto-merge | **High** — can modify repo contents, create releases |
|
||||
| `packages: write` | Docker | **High** — can push container images to GHCR |
|
||||
| `pull-requests: write` | Auto-merge | **High** — can merge PRs, approve changes |
|
||||
| `attestations: write` | Docker | **Medium** — can create provenance/SBOM attestations |
|
||||
| `id-token: write` | Docker | **Medium** — OIDC token for signing attestations |
|
||||
| `security-events: write` | Security/SAST, Docker | **Medium** — can upload SARIF to Security tab |
|
||||
|
||||
Always prefer the narrowest permission scope. If a workflow only needs `contents: read`, do not grant `contents: write`.
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
name: golang-data-structures
|
||||
description: Golang data structures — slices (internals, capacity growth, preallocation, slices package), maps (internals, hash buckets, maps package), arrays, container/list/heap/ring, strings.Builder vs bytes.Buffer, generic collections, pointers (unsafe.Pointer, weak.Pointer), and copy semantics. Use when choosing or optimizing Go data structures, implementing generic containers, using container/ packages, unsafe or weak pointers, or questioning slice/map internals.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
**Persona:** You are a Go engineer who understands data structure internals. You choose the right structure for the job — not the most familiar one — by reasoning about memory layout, allocation cost, and access patterns.
|
||||
|
||||
# Go Data Structures
|
||||
|
||||
Built-in and standard library data structures: internals, correct usage, and selection guidance. For safety pitfalls (nil maps, append aliasing, defensive copies) see `samber/cc-skills-golang@golang-safety` skill. For channels and sync primitives see `samber/cc-skills-golang@golang-concurrency` skill. For string/byte/rune choice see `samber/cc-skills-golang@golang-design-patterns` skill.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Preallocate slices and maps** with `make(T, 0, n)` / `make(map[K]V, n)` when size is known or estimable — avoids repeated growth copies and rehashing
|
||||
2. **Arrays** SHOULD be preferred over slices only for fixed, compile-time-known sizes (hash digests, IPv4 addresses, matrix dimensions)
|
||||
3. **NEVER rely on slice capacity growth timing** — the growth algorithm changed between Go versions and may change again; your code should not depend on when a new backing array is allocated
|
||||
4. **Use `container/heap`** for priority queues, **`container/list`** only when frequent middle insertions are needed, **`container/ring`** for fixed-size circular buffers
|
||||
5. **`strings.Builder`** MUST be preferred for building strings; **`bytes.Buffer`** MUST be preferred for bidirectional I/O (implements both `io.Reader` and `io.Writer`)
|
||||
6. Generic data structures SHOULD use the **tightest constraint** possible — `comparable` for keys, custom interfaces for ordering
|
||||
7. **`unsafe.Pointer`** MUST only follow the 6 valid conversion patterns from the Go spec — NEVER store in a `uintptr` variable across statements
|
||||
8. **`weak.Pointer[T]`** (Go 1.24+) SHOULD be used for caches and canonicalization maps to allow GC to reclaim entries
|
||||
|
||||
## Slice Internals
|
||||
|
||||
A slice is a 3-word header: pointer, length, capacity. Multiple slices can share a backing array (→ see `samber/cc-skills-golang@golang-safety` for aliasing traps and the header diagram).
|
||||
|
||||
### Capacity Growth
|
||||
|
||||
- < 256 elements: capacity doubles
|
||||
- > = 256 elements: grows by ~25% (`newcap += (newcap + 3*256) / 4`)
|
||||
- Each growth copies the entire backing array — O(n)
|
||||
|
||||
### Preallocation
|
||||
|
||||
```go
|
||||
// Exact size known
|
||||
users := make([]User, 0, len(ids))
|
||||
|
||||
// Approximate size known
|
||||
results := make([]Result, 0, estimatedCount)
|
||||
|
||||
// Pre-grow before bulk append (Go 1.21+)
|
||||
s = slices.Grow(s, additionalNeeded)
|
||||
```
|
||||
|
||||
### `slices` Package (Go 1.21+)
|
||||
|
||||
Key functions: `Sort`/`SortFunc`, `BinarySearch`, `Contains`, `Compact`, `Grow`. For `Clone`, `Equal`, `DeleteFunc` → see `samber/cc-skills-golang@golang-safety` skill.
|
||||
|
||||
**[Slice Internals Deep Dive](./references/slice-internals.md)** — Full `slices` package reference, growth mechanics, `len` vs `cap`, header copying, backing array aliasing.
|
||||
|
||||
## Map Internals
|
||||
|
||||
Maps are hash tables with 8-entry buckets and overflow chains. They are reference types — assigning a map copies the pointer, not the data.
|
||||
|
||||
### Preallocation
|
||||
|
||||
```go
|
||||
m := make(map[string]*User, len(users)) // avoids rehashing during population
|
||||
```
|
||||
|
||||
### `maps` Package Quick Reference (Go 1.21+)
|
||||
|
||||
| Function | Purpose |
|
||||
| ----------------- | ---------------------------- |
|
||||
| `Collect` (1.23+) | Build map from iterator |
|
||||
| `Insert` (1.23+) | Insert entries from iterator |
|
||||
| `All` (1.23+) | Iterator over all entries |
|
||||
| `Keys`, `Values` | Iterators over keys/values |
|
||||
|
||||
For `Clone`, `Equal`, sorted iteration → see `samber/cc-skills-golang@golang-safety` skill.
|
||||
|
||||
**[Map Internals Deep Dive](./references/map-internals.md)** — How Go maps store and hash data, bucket overflow chains, why maps never shrink (and what to do about it), comparing map performance to alternatives.
|
||||
|
||||
## Arrays
|
||||
|
||||
Fixed-size, value types. Copied entirely on assignment. Use for compile-time-known sizes:
|
||||
|
||||
```go
|
||||
type Digest [32]byte // fixed-size, value type
|
||||
var grid [3][3]int // multi-dimensional
|
||||
cache := map[[2]int]Result{} // arrays are comparable — usable as map keys
|
||||
```
|
||||
|
||||
Prefer slices for everything else — arrays cannot grow and pass by value (expensive for large sizes).
|
||||
|
||||
## container/ Standard Library
|
||||
|
||||
| Package | Data Structure | Best For |
|
||||
| --- | --- | --- |
|
||||
| `container/list` | Doubly-linked list | LRU caches, frequent middle insertion/removal |
|
||||
| `container/heap` | Min-heap (priority queue) | Top-K, scheduling, Dijkstra |
|
||||
| `container/ring` | Circular buffer | Rolling windows, round-robin |
|
||||
| `bufio` | Buffered reader/writer/scanner | Efficient I/O with small reads/writes |
|
||||
|
||||
Container types use `any` (no type safety) — consider generic wrappers. **[Container Patterns, bufio, and Examples](./references/containers.md)** — When to use each container type, generic wrappers to add type safety, and `bufio` patterns for efficient I/O.
|
||||
|
||||
## strings.Builder vs bytes.Buffer
|
||||
|
||||
Use `strings.Builder` for pure string concatenation (avoids copy on `String()`), `bytes.Buffer` when you need `io.Reader` or byte manipulation. Both support `Grow(n)`. **[Details and comparison](./references/containers.md)**
|
||||
|
||||
## Generic Collections (Go 1.18+)
|
||||
|
||||
Use the tightest constraint possible. `comparable` for map keys, `cmp.Ordered` for sorting, custom interfaces for domain-specific ordering.
|
||||
|
||||
```go
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
|
||||
func (s Set[T]) Contains(v T) bool { _, ok := s[v]; return ok }
|
||||
```
|
||||
|
||||
**[Writing Generic Data Structures](./references/generics.md)** — Using Go 1.18+ generics for type-safe containers, understanding constraint satisfaction, and building domain-specific generic types.
|
||||
|
||||
## Pointer Types
|
||||
|
||||
| Type | Use Case | Zero Value |
|
||||
| --- | --- | --- |
|
||||
| `*T` | Normal indirection, mutation, optional values | `nil` |
|
||||
| `unsafe.Pointer` | FFI, low-level memory layout (6 spec patterns only) | `nil` |
|
||||
| `weak.Pointer[T]` (1.24+) | Caches, canonicalization, weak references | N/A |
|
||||
|
||||
**[Pointer Types Deep Dive](./references/pointers.md)** — Normal pointers, `unsafe.Pointer` (the 6 valid spec patterns), and `weak.Pointer[T]` for GC-safe caches that don't prevent cleanup.
|
||||
|
||||
## Copy Semantics Quick Reference
|
||||
|
||||
| Type | Copy Behavior | Independence |
|
||||
| --- | --- | --- |
|
||||
| `int`, `float`, `bool`, `string` | Value (deep copy) | Fully independent |
|
||||
| `array`, `struct` | Value (deep copy) | Fully independent |
|
||||
| `slice` | Header copied, backing array shared | Use `slices.Clone` |
|
||||
| `map` | Reference copied | Use `maps.Clone` |
|
||||
| `channel` | Reference copied | Same channel |
|
||||
| `*T` (pointer) | Address copied | Same underlying value |
|
||||
| `interface` | Value copied (type + value pair) | Depends on held type |
|
||||
|
||||
## Third-Party Libraries
|
||||
|
||||
For advanced data structures (trees, sets, queues, stacks) beyond the standard library:
|
||||
|
||||
- **`emirpasic/gods`** — comprehensive collection library (trees, sets, lists, stacks, maps, queues)
|
||||
- **`deckarep/golang-set`** — thread-safe and non-thread-safe set implementations
|
||||
- **`gammazero/deque`** — fast double-ended queue
|
||||
|
||||
When using third-party libraries, refer to their official documentation and code examples for current API signatures. Context7 can help as a discoverability platform.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-performance` skill for struct field alignment, memory layout optimization, and cache locality
|
||||
- → See `samber/cc-skills-golang@golang-safety` skill for nil map/slice pitfalls, append aliasing, defensive copying, `slices.Clone`/`Equal`
|
||||
- → See `samber/cc-skills-golang@golang-concurrency` skill for channels, `sync.Map`, `sync.Pool`, and all sync primitives
|
||||
- → See `samber/cc-skills-golang@golang-design-patterns` skill for `string` vs `[]byte` vs `[]rune`, iterators, streaming
|
||||
- → See `samber/cc-skills-golang@golang-structs-interfaces` skill for struct composition, embedding, and generics vs `any`
|
||||
- → See `samber/cc-skills-golang@golang-code-style` skill for slice/map initialization style
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Growing a slice in a loop without preallocation | Each growth copies the entire backing array — O(n) per growth. Use `make([]T, 0, n)` or `slices.Grow` |
|
||||
| Using `container/list` when a slice would suffice | Linked lists have poor cache locality (each node is a separate heap allocation). Benchmark first |
|
||||
| `bytes.Buffer` for pure string building | Buffer's `String()` copies the underlying bytes. `strings.Builder` avoids this copy |
|
||||
| `unsafe.Pointer` stored as `uintptr` across statements | GC can move the object between statements — the `uintptr` becomes a dangling reference |
|
||||
| Large struct values in maps (copying overhead) | Map access copies the entire value. Use `map[K]*V` for large value types to avoid the copy |
|
||||
|
||||
## References
|
||||
|
||||
- [Go Data Structures (Russ Cox)](https://research.swtch.com/godata)
|
||||
- [The Go Memory Model](https://go.dev/ref/mem)
|
||||
- [Effective Go](https://go.dev/doc/effective_go)
|
||||
@@ -0,0 +1,199 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "buffer-for-io",
|
||||
"description": "RenderAndStream assembles parts and streams to io.Writer",
|
||||
"task": "Write a function `RenderAndStream(w io.Writer, parts []string) error` in package `render`. It assembles all parts into a single output (joining with newlines), then streams the result to the provided io.Writer. Build the output efficiently.",
|
||||
"assertions": [
|
||||
{ "id": "1.1", "text": "Correct output to io.Writer", "trap": "none — baseline" },
|
||||
{ "id": "1.2", "text": "Efficient output assembly with Grow", "trap": "none — baseline" },
|
||||
{ "id": "1.3", "text": "No unnecessary intermediate allocation", "trap": "none — baseline" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "sorted-set",
|
||||
"description": "SortedSet[T] with Min/Max/Insert requiring ordered constraint",
|
||||
"task": "Write a `SortedSet[T]` in package `collections` that maintains elements in sorted order. Support Insert(T), Contains(T) bool, Min() (T, bool), Max() (T, bool), and Len() int. Elements must be orderable. Use binary search for efficiency.",
|
||||
"assertions": [
|
||||
{ "id": "2.1", "text": "Uses `cmp.Ordered` constraint", "trap": "Model might use `comparable` (too loose for sorting) or custom interface" },
|
||||
{ "id": "2.2", "text": "Binary search for Insert/Contains", "trap": "Model might linear scan" },
|
||||
{ "id": "2.3", "text": "Min/Max O(1) from sorted slice ends", "trap": "Model might scan entire slice" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "AddCleanup",
|
||||
"description": "String intern with weak.Pointer + runtime.AddCleanup for auto map shrink",
|
||||
"task": "Write a symbol table deduplicator in package `symbols` for Go 1.24+. `func Intern(s string) *string` returns a canonical pointer for equivalent strings. When all external references to the canonical string are dropped, it should be GC'd AND its map entry should be automatically removed (not just become a dead weak pointer — the map must shrink). Thread-safe.",
|
||||
"assertions": [
|
||||
{ "id": "3.1", "text": "Uses `weak.Pointer` or `weak.Make`", "trap": "Without skill, model uses runtime.SetFinalizer instead of weak.Pointer" },
|
||||
{ "id": "3.2", "text": "Uses `runtime.AddCleanup` (not SetFinalizer)", "trap": "Without skill, model defaults to deprecated SetFinalizer pattern" },
|
||||
{ "id": "3.3", "text": "Dead map entries automatically removed", "trap": "none — both approaches can achieve this" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "unsafe.Add-modern",
|
||||
"description": "Read packed binary header using modern unsafe.Add (Go 1.17+)",
|
||||
"task": "Write a function `ReadFields(data []byte) (magic uint32, version uint16, length uint32)` in package `proto` that reads a packed binary header. The header layout is: 4 bytes magic, 2 bytes version, 2 bytes padding, 4 bytes length. Use unsafe pointer arithmetic to access each field at its offset. Target Go 1.17+.",
|
||||
"assertions": [
|
||||
{ "id": "4.1", "text": "Uses `unsafe.Add` for pointer arithmetic", "trap": "Without skill, model uses old-style uintptr(base) + offset casting" },
|
||||
{ "id": "4.2", "text": "No intermediate `uintptr` variable", "trap": "Model might split pointer arithmetic across statements" },
|
||||
{ "id": "4.3", "text": "Bounds check before unsafe access", "trap": "none — baseline safety" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "full-slice-expr",
|
||||
"description": "SplitIntoChunks with full-slice expression to prevent append aliasing",
|
||||
"task": "Write a function `SplitIntoChunks(data []byte, chunkSize int) [][]byte` in package `chunker`. Split data into chunks of chunkSize bytes (last chunk may be smaller). IMPORTANT: callers will independently append to each returned chunk, so chunks must not share backing arrays — appending to one chunk must never corrupt another.",
|
||||
"assertions": [
|
||||
{ "id": "5.1", "text": "Chunks are append-safe (no aliasing)", "trap": "none — both approaches achieve this" },
|
||||
{ "id": "5.2", "text": "Uses full-slice expression `[:n:n]`", "trap": "Without skill, model uses make+copy per chunk instead of zero-alloc full-slice" },
|
||||
{ "id": "5.3", "text": "Minimal extra allocations (reuses backing array)", "trap": "Without skill, model allocates N fresh arrays instead of reusing original" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "list-valid-use",
|
||||
"description": "OrderedMap with O(1) middle deletion — valid container/list use case",
|
||||
"task": "Write an `OrderedMap[K comparable, V any]` in package `ordmap` that preserves insertion order AND supports O(1) deletion by key. Methods: Set(key K, value V), Get(key K) (V, bool), Delete(key K), Keys() []K (in insertion order). When a key is deleted from the middle, the insertion order of remaining keys must be preserved without shifting.",
|
||||
"assertions": [
|
||||
{ "id": "6.1", "text": "Uses `container/list` from stdlib", "trap": "Without skill, model builds custom generic linked list instead of using stdlib" },
|
||||
{ "id": "6.2", "text": "Map stores `*list.Element` for O(1) access", "trap": "none — both approaches use map for lookup" },
|
||||
{ "id": "6.3", "text": "Delete is O(1) via element reference", "trap": "none — both approaches achieve O(1) delete" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "composite-struct-key",
|
||||
"description": "Route cache keyed by (method, path) — struct key vs string encoding",
|
||||
"task": "Write an HTTP route cache in package `router`. The cache maps (method, path) pairs to handler names. Implement Set(method, path, handler string) and Get(method, path string) (string, bool). Millions of lookups per second — optimize for zero-allocation lookups.",
|
||||
"assertions": [
|
||||
{ "id": "7.1", "text": "Uses struct or array as map key", "trap": "Without skill, model concatenates strings (method+':'+path) or uses complex encoding" },
|
||||
{ "id": "7.2", "text": "Zero allocation on lookup path", "trap": "Without skill, model uses string concatenation (allocates) or complex unsafe tricks" },
|
||||
{ "id": "7.3", "text": "Simple, readable key type", "trap": "Without skill, model over-engineers with sync.Map + unsafe stack string hacks" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "map-memory-diag",
|
||||
"description": "Diagnose 2GB RSS after bulk delete from map — maps never shrink",
|
||||
"task": "A Go service processes events. During traffic spikes, `var eventIndex map[string]*Event` grows to 10M entries. After the spike, events expire and are deleted, leaving ~1000 entries. But RSS memory stays at 2GB. The team added `delete(eventIndex, key)` for every expired event — why doesn't memory decrease? Write a `Diagnose()` comment explaining the root cause, then write a `Compact()` method on an `EventStore` struct that fixes it. Package `events`.",
|
||||
"assertions": [
|
||||
{ "id": "8.1", "text": "Diagnoses 'maps never shrink buckets'", "trap": "none — model may or may not know this" },
|
||||
{ "id": "8.2", "text": "Compact creates fresh map with `make`", "trap": "none — both approaches implement rebuild" },
|
||||
{ "id": "8.3", "text": "Copies surviving entries to new map", "trap": "none — baseline correctness" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "ring-round-robin",
|
||||
"description": "LoadBalancer cycling backends using container/ring",
|
||||
"task": "Write a `LoadBalancer` in package `lb` that distributes requests across backends using round-robin. NewLoadBalancer(backends []string) *LoadBalancer creates the balancer. Next() string returns the next backend in rotation, cycling forever. The balancer must work correctly even after billions of calls (no integer overflow on counter).",
|
||||
"assertions": [
|
||||
{ "id": "9.1", "text": "Uses `container/ring` for round-robin", "trap": "Without skill, model uses atomic counter with modulo (also valid but not stdlib)" },
|
||||
{ "id": "9.2", "text": "No integer overflow risk", "trap": "none — both ring (no counter) and uint64 modulo (safe wrap) avoid overflow" },
|
||||
{ "id": "9.3", "text": "Correct rotation on each call", "trap": "none — baseline correctness" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "large-struct-ptr",
|
||||
"description": "DocumentStore with 20-field struct — pointer map needed",
|
||||
"task": "Write a `DocumentStore` in package `docs`. Document has 20 fields: ID, Title, Author, Body, Summary, Category, Tags []string, CreatedAt, UpdatedAt, PublishedAt time.Time, ViewCount, LikeCount, CommentCount int, Draft bool, Locale, Slug, MetaTitle, MetaDescription, CanonicalURL, RevisionID string. The store is read-heavy (100:1 read:write). Implement Add(doc Document), Get(id string) (*Document, bool), Update(id string, doc Document).",
|
||||
"assertions": [
|
||||
{ "id": "10.1", "text": "Uses pointer map `map[string]*Document`", "trap": "Without skill, model uses value map — copies 500+ byte struct on every read" },
|
||||
{ "id": "10.2", "text": "Get returns stored pointer (no copy)", "trap": "Without skill, model copies from value map and takes & of copy" },
|
||||
{ "id": "10.3", "text": "Uses `sync.RWMutex` for concurrent reads", "trap": "none — baseline concurrency" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "small-struct-val",
|
||||
"description": "CoordTracker with 16-byte Coord — value map preferred over pointer",
|
||||
"task": "Write a `CoordTracker` in package `geo`. It tracks millions of active GPS positions. Coord has Lat, Lon float64 (16 bytes total). Implement Update(id string, lat, lon float64), Position(id string) (Coord, bool), Remove(id string). Optimize the internal map for maximum read throughput given the tiny struct size.",
|
||||
"assertions": [
|
||||
{ "id": "11.1", "text": "Uses value map `map[string]Coord` (not pointer)", "trap": "Model might over-optimize with pointer map for 'millions' of entries" },
|
||||
{ "id": "11.2", "text": "Explains < 128-byte threshold for value vs pointer choice", "trap": "Without skill, model doesn't articulate the size-based tradeoff" },
|
||||
{ "id": "11.3", "text": "Proportional complexity for struct size", "trap": "Without skill, model may over-engineer with sharding for a simple case" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "clip-after-delete",
|
||||
"description": "PurgeInactive with slices.DeleteFunc + Clip to release dead references",
|
||||
"task": "Write a function `PurgeInactive(users []User) []User` in package `cleanup`. User has ID string, Active bool, and Profile []byte (can be several MB). Remove all inactive users. The returned slice must not hold references to any inactive User's Profile data in its excess capacity. Users are typically 90% inactive after a bulk import.",
|
||||
"assertions": [
|
||||
{ "id": "12.1", "text": "No dead references in result slice", "trap": "none — both approaches achieve this" },
|
||||
{ "id": "12.2", "text": "Uses `slices.DeleteFunc` + `slices.Clip`", "trap": "Without skill, model uses manual loop + make instead of modern slices package" },
|
||||
{ "id": "12.3", "text": "Excess capacity released", "trap": "none — both approaches release excess" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "heap-priority-queue",
|
||||
"description": "Task scheduler using container/heap — tests knowledge of heap.Interface and heap.Fix",
|
||||
"task": "Write a `TaskScheduler` in package `scheduler` that executes tasks in priority order. Each Task has ID string, Priority int (lower = higher priority), and ExecuteAt time.Time. Implement Schedule(task Task), Next() (Task, bool), and UpdatePriority(id string, newPriority int). UpdatePriority must efficiently re-order the task without removing and re-adding it.",
|
||||
"assertions": [
|
||||
{ "id": "13.1", "text": "Implements heap.Interface (Len, Less, Swap, Push, Pop) on a custom type", "trap": "Without skill, model might use a sorted slice with re-sort on every insert" },
|
||||
{ "id": "13.2", "text": "Uses heap.Fix for UpdatePriority (O(log n)) instead of removing and re-adding", "trap": "Without skill, model removes + re-pushes (O(n + log n)) or re-sorts (O(n log n))" },
|
||||
{ "id": "13.3", "text": "Maintains an index map (id -> position in heap) for O(1) lookup before Fix", "trap": "Without skill, model linear-scans the heap to find the task by ID" },
|
||||
{ "id": "13.4", "text": "Calls heap.Init before first use", "trap": "Model might forget initialization" },
|
||||
{ "id": "13.5", "text": "Next() uses heap.Pop (not direct slice access and removal)", "trap": "Model might read [0] and manually remove" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "growth-independence",
|
||||
"description": "Tests that code does not depend on specific slice growth factor",
|
||||
"task": "Write a Go function `EstimateAllocations(n int) int` in package `bench` that returns how many times the backing array will be reallocated when appending n elements one-by-one to an initially empty slice. Your implementation must work correctly on Go 1.20, Go 1.21, and any future Go version.",
|
||||
"assertions": [
|
||||
{ "id": "14.1", "text": "Does NOT hardcode a growth factor (e.g., assumes doubling or 1.25x) in the calculation", "trap": "Without skill, model assumes doubling below some threshold and 1.25x above" },
|
||||
{ "id": "14.2", "text": "Uses an actual append loop to empirically measure allocations, OR explicitly states the growth algorithm may change between versions", "trap": "Model might implement a formula based on current Go runtime behavior" },
|
||||
{ "id": "14.3", "text": "Comments or documents that the slice growth algorithm is an implementation detail not guaranteed by the spec", "trap": "Without skill, model presents the growth formula as a reliable contract" },
|
||||
{ "id": "14.4", "text": "Does NOT rely on the 256-element threshold as a stable boundary", "trap": "Model might treat the threshold as a spec guarantee" },
|
||||
{ "id": "14.5", "text": "Suggests preallocation as the solution to avoid depending on growth behavior", "trap": "Without skill, model tries to predict growth exactly" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "array-as-map-key",
|
||||
"description": "Tests when to use arrays (comparable, value type) as map keys vs slices",
|
||||
"task": "Write a `PixelCache` in package `imaging` that caches computed color values for pixel coordinates. Coordinates are (x, y) integer pairs. The cache is used in a rendering pipeline processing millions of pixels. Implement Set(x, y int, color uint32) and Get(x, y int) (uint32, bool). Optimize for lookup speed.",
|
||||
"assertions": [
|
||||
{ "id": "15.1", "text": "Uses [2]int array or a struct{X,Y int} as the map key (not string encoding or nested maps)", "trap": "Without skill, model might use fmt.Sprintf(\"%d,%d\") or map[int]map[int]uint32" },
|
||||
{ "id": "15.2", "text": "Key type is comparable (arrays and all-comparable-field structs satisfy this)", "trap": "Model might try to use a slice as a map key" },
|
||||
{ "id": "15.3", "text": "Zero allocation on lookup path (no string formatting)", "trap": "Without skill, model formats a string key per lookup" },
|
||||
{ "id": "15.4", "text": "Uses value map (uint32 is 4 bytes — pointer overhead not justified)", "trap": "Model might use *uint32 for no reason" },
|
||||
{ "id": "15.5", "text": "Preallocates map if estimated size is available", "trap": "Model might skip preallocation for a hot-path cache" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"name": "bufio-scanner-limit",
|
||||
"description": "Tests knowledge of bufio.Scanner's 64KB default token limit",
|
||||
"task": "Write a function `ParseLogFile(r io.Reader) ([]LogEntry, error)` in package `logs`. Each log line is a JSON object. Some log entries contain large base64-encoded payloads and can be up to 2MB per line. Parse all lines and return the entries.",
|
||||
"assertions": [
|
||||
{ "id": "16.1", "text": "Uses bufio.Scanner for line-by-line reading", "trap": "Without skill, model might use bufio.NewReader + ReadString or ReadBytes" },
|
||||
{ "id": "16.2", "text": "Calls scanner.Buffer() to increase the max token size beyond the 64KB default", "trap": "Without skill, model uses default Scanner which silently truncates or errors on lines > 64KB" },
|
||||
{ "id": "16.3", "text": "Sets buffer size to at least 2MB to accommodate the large lines", "trap": "Model might use Scanner without adjusting buffer, failing on large lines" },
|
||||
{ "id": "16.4", "text": "Checks scanner.Err() after the scan loop", "trap": "Model might ignore scanner errors" },
|
||||
{ "id": "16.5", "text": "Unmarshals each line as JSON into LogEntry struct", "trap": "None — baseline correctness" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"name": "generics-when-not-to-use",
|
||||
"description": "Tests judgment about when generics are NOT appropriate",
|
||||
"task": "Write a Go HTTP response helper package `respond`. It needs: RespondJSON(w, status, data) that marshals any data to JSON, RespondError(w, status, message) that sends an error JSON, and RespondNoContent(w) that sends 204. A teammate suggests making it generic: `Respond[T any](w, status, data T)`. Should you use generics here? Implement the best approach.",
|
||||
"assertions": [
|
||||
{ "id": "17.1", "text": "Does NOT make RespondJSON generic with a type parameter (any constraint with json.Marshal makes generics pointless — it's just interface{} with extra syntax)", "trap": "Without skill, model accepts the teammate's generic suggestion" },
|
||||
{ "id": "17.2", "text": "Uses `any` or `interface{}` parameter directly for the data argument", "trap": "Model might use generics just because the prompt suggests it" },
|
||||
{ "id": "17.3", "text": "Explains WHY generics are not appropriate here (any constraint means no type-specific behavior, json.Marshal already accepts any)", "trap": "Without skill, model adds generics without questioning the value" },
|
||||
{ "id": "17.4", "text": "Mentions that generics shine for containers/algorithms where type safety adds value, not for serialization", "trap": "Model might not articulate the distinction" },
|
||||
{ "id": "17.5", "text": "Implementation is straightforward without type parameters", "trap": "None — baseline" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
# Container Packages and String Builders
|
||||
|
||||
## container/list — Doubly-Linked List
|
||||
|
||||
A general-purpose doubly-linked list. Elements hold `any` values (no type safety).
|
||||
|
||||
### Time Complexity
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Insert at front/back** | O(1) | `PushFront()`, `PushBack()` |
|
||||
| **Remove front/back** | O(1) | `RemoveFront()`, `RemoveBack()` |
|
||||
| **Insert at arbitrary position** | O(1) | If you have the element reference (`*Element`) |
|
||||
| **Remove at arbitrary position** | O(1) | If you have the element reference |
|
||||
| **Access by index** | O(n) | Must walk the chain — no random access |
|
||||
| **Search for value** | O(n) | Linear scan required |
|
||||
|
||||
### When to Use
|
||||
|
||||
- LRU cache implementations (O(1) move-to-front)
|
||||
- Ordered collections with frequent insertion/removal at arbitrary positions
|
||||
- When you need stable iterators that survive insertions
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
Slices outperform linked lists for most use cases due to cache locality. If you only append/remove from the ends, use a slice or a deque. Also avoid if you need O(1) random access by index.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- LRU cache implementations (O(1) move-to-front with element reference)
|
||||
- Ordered task queues with frequent arbitrary insertions/removals (if mutations happen frequently)
|
||||
- Undo/redo stacks with stable element references
|
||||
- Sliding window algorithms where elements are frequently added/removed from both ends
|
||||
|
||||
## container/heap — Priority Queue
|
||||
|
||||
An interface-based min-heap. You provide a type implementing `heap.Interface` (which embeds `sort.Interface` plus `Push`/`Pop`).
|
||||
|
||||
### Time Complexity
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
| --- | --- | --- |
|
||||
| **heap.Push** | O(log n) | Appends and bubbles up |
|
||||
| **heap.Pop** | O(log n) | Removes root, moves last to root, bubbles down |
|
||||
| **heap.Init** | O(n) | Builds heap from unsorted slice in linear time |
|
||||
| **heap.Fix** | O(log n) | Re-heapifies after priority change |
|
||||
| **Peek (access root)** | O(1) | Direct access to `pq[0]` |
|
||||
| **Search for value** | O(n) | No indexed lookup — must scan all items |
|
||||
|
||||
### Space Complexity
|
||||
|
||||
O(n) — stores all items in a backing slice. The heap is an array-based structure, not a tree of pointers.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- Task scheduling (dequeue highest-priority tasks)
|
||||
- Dijkstra's algorithm (repeatedly pop minimum-distance node)
|
||||
- Huffman coding (repeatedly pop two smallest frequencies)
|
||||
- Event processing (process events in time order)
|
||||
- A\* pathfinding (explore nodes with lowest f-cost)
|
||||
- Load balancing (process requests from server with lowest load)
|
||||
|
||||
## container/ring — Circular Buffer
|
||||
|
||||
A fixed-size circular linked list. Useful for rolling windows and round-robin scheduling.
|
||||
|
||||
```go
|
||||
// Rolling average of last 5 values
|
||||
r := ring.New(5)
|
||||
for _, v := range values {
|
||||
r.Value = v
|
||||
r = r.Next()
|
||||
}
|
||||
|
||||
sum := 0.0
|
||||
r.Do(func(v any) {
|
||||
if v != nil {
|
||||
sum += v.(float64)
|
||||
}
|
||||
})
|
||||
avg := sum / float64(r.Len())
|
||||
```
|
||||
|
||||
## bufio — Buffered I/O
|
||||
|
||||
`bufio` wraps `io.Reader` and `io.Writer` with an internal buffer, reducing system call overhead for frequent small reads/writes. Use `NewReader()` / `NewWriter()` for default 4096-byte buffers, or `NewReaderSize()` / `NewWriterSize()` for custom sizes.
|
||||
|
||||
**bufio.Reader & Writer:** Call `Flush()` explicitly on writers—buffered data is not written until flush or buffer is full. Always `defer w.Flush()` to avoid data loss.
|
||||
|
||||
**bufio.Scanner:** Convenient line-by-line reading with `scanner.Scan()` and `scanner.Text()`. Default max token size is 64 KB; call `scanner.Buffer()` to increase for larger lines.
|
||||
|
||||
## strings.Builder vs bytes.Buffer
|
||||
|
||||
**strings.Builder:** Optimized for building strings. `String()` returns the accumulated string without copying. Use for concatenating string parts. `Reset()` discards the buffer.
|
||||
|
||||
**bytes.Buffer:** Implements both `io.Reader` and `io.Writer`. Use for I/O operations, encoding/decoding, or when you need both read and write. `Reset()` reuses the allocated memory.
|
||||
|
||||
**Choose Builder for string concatenation, Buffer for I/O operations or buffer reuse in pools.**
|
||||
@@ -0,0 +1,90 @@
|
||||
# Writing Generic Data Structures (Go 1.18+)
|
||||
|
||||
## Type Constraints
|
||||
|
||||
Use the tightest constraint that satisfies your needs:
|
||||
|
||||
| Constraint | What It Allows | Use For |
|
||||
| --- | --- | --- |
|
||||
| `any` | All types | Containers that only store/retrieve |
|
||||
| `comparable` | Types supporting `==` and `!=` | Map keys, set membership, dedup |
|
||||
| `cmp.Ordered` | Numeric types + `string` | Sorting, min/max, binary search |
|
||||
| Custom interface | Domain-specific operations | Specialized containers |
|
||||
|
||||
### Custom Constraints
|
||||
|
||||
```go
|
||||
// Union constraint — restrict to specific types
|
||||
type Number interface {
|
||||
~int | ~int64 | ~float64
|
||||
}
|
||||
|
||||
// Method constraint — require specific behavior
|
||||
type Stringer interface {
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
The `~` prefix includes all types whose underlying type matches (e.g., `~int` matches `type UserID int`).
|
||||
|
||||
## Generic Set Example
|
||||
|
||||
```go
|
||||
type Set[T comparable] map[T]struct{}
|
||||
|
||||
func NewSet[T comparable](vals ...T) Set[T] {
|
||||
s := make(Set[T], len(vals))
|
||||
for _, v := range vals {
|
||||
s[v] = struct{}{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Set[T]) Add(v T) { s[v] = struct{}{} }
|
||||
func (s Set[T]) Remove(v T) { delete(s, v) }
|
||||
func (s Set[T]) Contains(v T) bool { _, ok := s[v]; return ok }
|
||||
func (s Set[T]) Len() int { return len(s) }
|
||||
|
||||
func (s Set[T]) Union(other Set[T]) Set[T] {
|
||||
result := NewSet[T]()
|
||||
for v := range s {
|
||||
result.Add(v)
|
||||
}
|
||||
for v := range other {
|
||||
result.Add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Sorted Slice
|
||||
|
||||
```go
|
||||
func InsertSorted[T cmp.Ordered](s []T, v T) []T {
|
||||
i, _ := slices.BinarySearch(s, v)
|
||||
return slices.Insert(s, i, v)
|
||||
}
|
||||
```
|
||||
|
||||
## Constraint Composition
|
||||
|
||||
Combine multiple constraints with embedded interfaces:
|
||||
|
||||
```go
|
||||
type OrderedStringer interface {
|
||||
cmp.Ordered
|
||||
fmt.Stringer
|
||||
}
|
||||
```
|
||||
|
||||
## When NOT to Use Generics
|
||||
|
||||
- **Single concrete type** — generics add complexity for no benefit
|
||||
- **`any` constraint with type switches** — you're just reimplementing `interface{}` with extra syntax
|
||||
- **Two or fewer instantiations** — the abstraction overhead isn't justified
|
||||
- **Complex type relationships** — Go's type system doesn't support higher-kinded types; if the constraints become convoluted, use interfaces instead
|
||||
|
||||
Generics shine for data structures (containers, sets, trees), algorithms (sort, search, transform), and utility functions (min, max, clamp) where the logic is identical across types.
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-structs-interfaces` skill for generics vs `any` guidance and interface design.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Map Internals Deep Dive
|
||||
|
||||
## Hash Table Structure
|
||||
|
||||
Go maps use hash tables with bucket-based collision resolution. The map header holds:
|
||||
|
||||
- `count` — number of entries
|
||||
- `B` — log₂ of bucket count (2^B buckets total)
|
||||
- `buckets` — pointer to bucket array
|
||||
- `oldbuckets` — pointer to old buckets during growth
|
||||
|
||||
Each bucket holds 8 key-value pairs. Keys and values are stored in separate arrays within buckets to minimize padding waste.
|
||||
|
||||
## Memory Growth and Capacity
|
||||
|
||||
- **Load factor threshold**: 6.5 entries per bucket triggers growth (sweet spot between memory efficiency and collision performance)
|
||||
- **Overflow bucket chains** also trigger growth if too long (prevents O(1)→O(n) degradation)
|
||||
- **Bucket count doubles**: 2^B → 2^(B+1) (efficient rehashing with powers of 2)
|
||||
- **Incremental evacuation**: Old and new buckets coexist during growth; entries move lazily during operations to avoid GC pauses
|
||||
- **No `cap()` function**: Capacity depends on hash distribution and load factor, not a fixed limit. Preallocation (`make(map[string]int, expectedSize)`) is worthwhile for large maps to avoid repeated growth cycles
|
||||
|
||||
## Preallocation
|
||||
|
||||
```go
|
||||
// Without preallocation — multiple growths as entries are added
|
||||
m := map[string]int{}
|
||||
|
||||
// With preallocation — allocates enough buckets upfront
|
||||
m := make(map[string]int, expectedSize)
|
||||
```
|
||||
|
||||
Preallocation avoids repeated growths. The hint is approximate — Go allocates 2^B buckets where 2^B \* 6.5 >= hint.
|
||||
|
||||
## Pointers vs Values
|
||||
|
||||
For large value types, storing pointers reduces copy overhead:
|
||||
|
||||
```go
|
||||
// Large struct — copied on every read/write
|
||||
m := map[string]BigStruct{} // copies large struct
|
||||
|
||||
// Pointer — only pointer is copied
|
||||
m := map[string]*BigStruct{} // copies 8-byte pointer
|
||||
```
|
||||
|
||||
Trade-off: pointer maps add GC pressure. For small structs (< 128 bytes), value maps are typically faster.
|
||||
|
||||
## `maps` Package (Go 1.21+)
|
||||
|
||||
| Function | Description |
|
||||
| --- | --- |
|
||||
| `Clone`, `Equal`, `EqualFunc` | Shallow copy and equality comparison |
|
||||
| `Keys`, `Values`, `All` (1.23+) | Iterators over keys, values, or pairs |
|
||||
| `Collect`, `Insert` (1.23+) | Build maps from iterators or insert entries |
|
||||
|
||||
See `samber/cc-skills-golang@golang-safety` skill for `Clone`, `Equal`, and sorted iteration patterns.
|
||||
|
||||
## Map Key Requirements
|
||||
|
||||
Map keys must be comparable (`==` must work). This includes:
|
||||
|
||||
- All numeric types, `string`, `bool`
|
||||
- Pointers, channels, interfaces (compared by identity)
|
||||
- Arrays of comparable types
|
||||
- Structs where all fields are comparable
|
||||
|
||||
Slices, maps, and functions **cannot** be map keys.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Pointer Types Deep Dive
|
||||
|
||||
## Regular Pointers (`*T`)
|
||||
|
||||
### Stack vs Heap (Escape Analysis)
|
||||
|
||||
Go's compiler decides whether to allocate on the stack or heap. A variable "escapes" to the heap when its lifetime extends beyond the function:
|
||||
|
||||
```go
|
||||
func noEscape() int {
|
||||
x := 42
|
||||
return x // x stays on stack — copied on return
|
||||
}
|
||||
|
||||
func escapes() *int {
|
||||
x := 42
|
||||
return &x // x escapes to heap — pointer outlives function
|
||||
}
|
||||
```
|
||||
|
||||
Use `go build -gcflags="-m"` to see escape analysis decisions. Heap allocations add GC pressure — avoid unnecessary escapes in hot paths.
|
||||
|
||||
### `new(T)` vs `&T{}`
|
||||
|
||||
Both allocate and return a pointer. `&T{}` is preferred because it allows field initialization:
|
||||
|
||||
```go
|
||||
p := new(Point) // *Point with zero values
|
||||
p := &Point{X: 1} // *Point with initialized fields — preferred
|
||||
```
|
||||
|
||||
## `unsafe.Pointer`
|
||||
|
||||
`unsafe.Pointer` bypasses Go's type system for FFI and low-level memory manipulation. Only the 6 patterns from the Go spec are safe; any other pattern is undefined behavior.
|
||||
|
||||
### The 6 Valid Patterns (from the Go spec)
|
||||
|
||||
These are the ONLY safe ways to use `unsafe.Pointer`. Any other pattern is undefined behavior.
|
||||
|
||||
**Pattern 1: Convert `*T` to `*U` via `unsafe.Pointer`**
|
||||
|
||||
```go
|
||||
// Reinterpret a float64 as its raw bits
|
||||
f := 1.5
|
||||
bits := *(*uint64)(unsafe.Pointer(&f))
|
||||
```
|
||||
|
||||
**Pattern 2: Convert `unsafe.Pointer` to `uintptr` and back (same expression)**
|
||||
|
||||
```go
|
||||
// Pointer arithmetic — MUST be a single expression
|
||||
p := unsafe.Pointer(uintptr(unsafe.Pointer(&s.field)) + offset)
|
||||
```
|
||||
|
||||
**Pattern 3: `reflect.Value.Pointer()` or `UnsafeAddr()` to `unsafe.Pointer`**
|
||||
|
||||
```go
|
||||
p := unsafe.Pointer(reflect.ValueOf(&x).Pointer())
|
||||
```
|
||||
|
||||
**Pattern 4: `syscall.Syscall` arguments**
|
||||
|
||||
```go
|
||||
syscall.Syscall(SYS_READ, fd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
||||
```
|
||||
|
||||
### Critical Rule: NEVER Store `uintptr` Across Statements
|
||||
|
||||
```go
|
||||
// ✗ DANGEROUS — GC can move the object between these two lines
|
||||
u := uintptr(unsafe.Pointer(&x))
|
||||
// ... GC may run here, moving x ...
|
||||
p := unsafe.Pointer(u) // dangling pointer
|
||||
|
||||
// ✓ Safe — single expression
|
||||
p := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + offset)
|
||||
```
|
||||
|
||||
### Modern Alternatives (prefer these)
|
||||
|
||||
| Function | Since | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `unsafe.Add(ptr, len)` | Go 1.17 | Pointer arithmetic without `uintptr` conversion |
|
||||
| `unsafe.Slice(ptr, len)` | Go 1.17 | Create slice from pointer + length |
|
||||
| `unsafe.String(ptr, len)` | Go 1.20 | Create string from pointer + length |
|
||||
| `unsafe.SliceData(s)` | Go 1.17 | Get pointer to slice's backing array |
|
||||
| `unsafe.StringData(s)` | Go 1.20 | Get pointer to string's backing array |
|
||||
|
||||
These are safer than manual `uintptr` arithmetic because they keep values as pointers (visible to GC) throughout.
|
||||
|
||||
## `weak.Pointer[T]` (Go 1.24+)
|
||||
|
||||
A weak pointer holds a reference to an object without preventing garbage collection. When the GC reclaims the object, `Value()` returns `nil`.
|
||||
|
||||
```go
|
||||
strong := new(MyType)
|
||||
w := weak.Make(strong)
|
||||
|
||||
if p := w.Value(); p != nil {
|
||||
// object still alive
|
||||
} else {
|
||||
// object was garbage collected
|
||||
}
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Deduplication caches** — intern equivalent values without preventing GC
|
||||
- **Automatic cache eviction** — cached objects evict when no strong references remain
|
||||
|
||||
### `runtime.AddCleanup` vs `runtime.SetFinalizer`
|
||||
|
||||
Prefer `runtime.AddCleanup` (Go 1.24+) over `runtime.SetFinalizer`:
|
||||
|
||||
- Multiple cleanups can be registered per object
|
||||
- Cleanup function receives a value, not a pointer to the collected object
|
||||
- No risk of resurrecting the object
|
||||
- Works correctly with weak pointers
|
||||
@@ -0,0 +1,55 @@
|
||||
# Slice Internals
|
||||
|
||||
## Memory Layout
|
||||
|
||||
A slice is a 24-byte header (3 machine words):
|
||||
|
||||
- **Pointer** — points to backing array (heap-allocated)
|
||||
- **Length** — number of elements in use
|
||||
- **Capacity** — allocated size of backing array
|
||||
|
||||
Assigning or passing a slice copies the 24-byte header, not the backing array. Both the original and copy point to the same underlying data—mutations are visible to both.
|
||||
|
||||
## Capacity Growth
|
||||
|
||||
When `append` exceeds capacity:
|
||||
|
||||
- `oldCap < 256`: double capacity
|
||||
- `oldCap ≥ 256`: grow ~25% (`oldCap + (oldCap + 3*256) / 4`)
|
||||
|
||||
### Growth Cost
|
||||
|
||||
Each growth is O(n) — the entire array is copied to a new location. For a slice growing from 0 to N elements one at a time, the amortized cost per append is O(1), but the total copies are roughly 2N. **Preallocation eliminates all intermediate copies:**
|
||||
|
||||
```go
|
||||
// Known size — direct indexing
|
||||
out := make([]Result, len(input))
|
||||
for i, v := range input {
|
||||
out[i] = transform(v)
|
||||
}
|
||||
|
||||
// Approximate size
|
||||
out := make([]Result, 0, len(input)*2)
|
||||
for _, v := range input {
|
||||
out = append(out, transform(v))
|
||||
}
|
||||
```
|
||||
|
||||
## `slices` Package (Go 1.21+)
|
||||
|
||||
| Category | Key Functions |
|
||||
| --- | --- |
|
||||
| **Sort** | `Sort`, `SortFunc`, `SortStableFunc`, `IsSorted` |
|
||||
| **Search** | `BinarySearch`, `BinarySearchFunc`, `Contains`, `Index`, `IndexFunc` |
|
||||
| **Mutate** | `Insert`, `Delete`, `Replace`, `Compact`, `Reverse`, `Grow`, `Clip` |
|
||||
| **Create** | `Concat` (1.22+), `Repeat` (1.23+), `Chunk` (1.23+) |
|
||||
| **Compare** | `Clone`, `Equal`, `EqualFunc`, `Compare`, `DeleteFunc` |
|
||||
|
||||
## `copy()` vs `append()` vs `slices.Clone()`
|
||||
|
||||
| Operation | Use When |
|
||||
| --------------------- | -------------------------------- |
|
||||
| `copy(dst, src)` | Copying into pre-allocated slice |
|
||||
| `append(dst, src...)` | Appending to a slice |
|
||||
| `slices.Clone(s)` | Creating independent copy |
|
||||
| `s[:len(s):len(s)]` | Preventing append aliasing |
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
name: golang-database
|
||||
description: Comprehensive guide for Go database access. Covers parameterized queries, struct scanning, NULLable column handling, error patterns, transactions, isolation levels, SELECT FOR UPDATE, connection pool, batch processing, context propagation, and migration tooling. Use this skill whenever writing, reviewing, or debugging Golang code that interacts with PostgreSQL, MariaDB, MySQL, or SQLite. Also triggers for database testing or any question about database/sql, sqlx, pgx, or SQL queries in Golang. This skill explicitly does NOT generate database schemas or migration SQL.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
**Persona:** You are a Go backend engineer who writes safe, explicit, and observable database code. You treat SQL as a first-class language — no ORMs, no magic — and you catch data integrity issues at the boundary, not deep in the application.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Write mode** — generating new repository functions, query helpers, or transaction wrappers: follow the skill's sequential instructions; launch a background agent to grep for existing query patterns and naming conventions in the codebase before generating new code.
|
||||
- **Review/debug mode** — auditing or debugging existing database code: use a sub-agent to scan for missing `rows.Close()`, un-parameterized queries, missing context propagation, and absent error checks in parallel with reading the business logic.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-database` skill takes precedence.
|
||||
|
||||
# Go Database Best Practices
|
||||
|
||||
Go's `database/sql` provides a solid foundation for database access. Use `sqlx` or `pgx` on top of it for ergonomics — never an ORM.
|
||||
|
||||
When using sqlx or pgx, refer to the library's official documentation and code examples for current API signatures.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Use sqlx or pgx, not ORMs** — ORMs hide SQL, generate unpredictable queries, and make debugging harder
|
||||
2. Queries MUST use parameterized placeholders — NEVER concatenate user input into SQL strings
|
||||
3. Context MUST be passed to all database operations — use `*Context` method variants (`QueryContext`, `ExecContext`, `GetContext`)
|
||||
4. `sql.ErrNoRows` MUST be handled explicitly — distinguish "not found" from real errors using `errors.Is`
|
||||
5. Rows MUST be closed after iteration — `defer rows.Close()` immediately after `QueryContext` calls
|
||||
6. NEVER use `db.Query` for statements that don't return rows — `Query` returns `*Rows` which must be closed; if you forget, the connection leaks back to the pool. Use `db.Exec` instead
|
||||
7. **Use transactions for multi-statement operations** — wrap related writes in `BeginTxx`/`Commit`
|
||||
8. **Use `SELECT ... FOR UPDATE`** when reading data you intend to modify — prevents race conditions
|
||||
9. **Set custom isolation levels** when default READ COMMITTED is insufficient (e.g., serializable for financial operations)
|
||||
10. **Handle NULLable columns** with pointer fields (`*string`, `*int`) or `sql.NullXxx` types
|
||||
11. Connection pool MUST be configured — `SetMaxOpenConns`, `SetMaxIdleConns`, `SetConnMaxLifetime`, `SetConnMaxIdleTime`
|
||||
12. **Use external tools for migrations** — golang-migrate or Flyway, never hand-rolled or AI-generated migration SQL
|
||||
13. **Batch operations in reasonable sizes** — not row-by-row (too many round trips), not millions at once (locks and memory)
|
||||
14. **Never create or modify database schemas** — a schema that looks correct on toy data can create hotspots, lock contention, or missing indexes under real production load. Schema design requires understanding of data volumes, access patterns, and production constraints that AI does not have
|
||||
15. **Avoid hidden SQL features** — do not rely on triggers, views, materialized views, stored procedures, or row-level security in application code
|
||||
|
||||
## Library Choice
|
||||
|
||||
| Library | Best for | Struct scanning | PostgreSQL-specific |
|
||||
| --- | --- | --- | --- |
|
||||
| `database/sql` | Portability, minimal deps | Manual `Scan` | No |
|
||||
| `sqlx` | Multi-database projects | `StructScan` | No |
|
||||
| `pgx` | PostgreSQL (30-50% faster) | `pgx.RowToStructByName` | Yes (COPY, LISTEN, arrays) |
|
||||
| GORM/ent | **Avoid** | Magic | Abstracted away |
|
||||
|
||||
**Why NOT ORMs:**
|
||||
|
||||
- Unpredictable query generation — N+1 problems you cannot see in code
|
||||
- Magic hooks and callbacks (BeforeCreate, AfterUpdate) make debugging harder
|
||||
- Schema migrations coupled to application code
|
||||
- Learning the ORM API is harder than learning SQL, and the abstraction leaks
|
||||
|
||||
## Parameterized Queries
|
||||
|
||||
```go
|
||||
// ✗ VERY BAD — SQL injection vulnerability
|
||||
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
|
||||
|
||||
// ✓ Good — parameterized (PostgreSQL)
|
||||
var user User
|
||||
err := db.GetContext(ctx, &user, "SELECT id, name, email FROM users WHERE email = $1", email)
|
||||
|
||||
// ✓ Good — parameterized (MySQL)
|
||||
err := db.GetContext(ctx, &user, "SELECT id, name, email FROM users WHERE email = ?", email)
|
||||
```
|
||||
|
||||
### Dynamic IN clauses
|
||||
|
||||
```go
|
||||
query, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building IN clause: %w", err)
|
||||
}
|
||||
query = db.Rebind(query) // adjust placeholders for your driver
|
||||
err = db.SelectContext(ctx, &users, query, args...)
|
||||
```
|
||||
|
||||
### Dynamic column names
|
||||
|
||||
Never interpolate column names from user input. Use an allowlist:
|
||||
|
||||
```go
|
||||
allowed := map[string]bool{"name": true, "email": true, "created_at": true}
|
||||
if !allowed[sortCol] {
|
||||
return fmt.Errorf("invalid sort column: %s", sortCol)
|
||||
}
|
||||
query := fmt.Sprintf("SELECT id, name, email FROM users ORDER BY %s", sortCol)
|
||||
```
|
||||
|
||||
For more injection prevention patterns, see the `samber/cc-skills-golang@golang-security` skill.
|
||||
|
||||
## Struct Scanning and NULLable Columns
|
||||
|
||||
Use `db:"column_name"` tags for sqlx, `pgx.CollectRows` with `pgx.RowToStructByName` for pgx. Handle NULLable columns with pointer fields (`*string`, `*time.Time`) — they work cleanly with both scanning and JSON marshaling. See [Scanning Reference](./references/scanning.md) for examples of all approaches.
|
||||
|
||||
## Error Handling
|
||||
|
||||
```go
|
||||
func GetUser(id string) (*User, error) {
|
||||
var user User
|
||||
|
||||
err := db.GetContext(ctx, &user, "SELECT id, name FROM users WHERE id = $1", id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound // translate to domain error
|
||||
}
|
||||
return nil, fmt.Errorf("querying user %s: %w", id, err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```go
|
||||
func GetUser(id string) (u *User, exists bool, err error) {
|
||||
var user User
|
||||
|
||||
err := db.GetContext(ctx, &user, "SELECT id, name FROM users WHERE id = $1", id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil // "no user" is not a technical error, but a domain error
|
||||
}
|
||||
return nil, false, fmt.Errorf("querying user %s: %w", id, err)
|
||||
}
|
||||
|
||||
return &user, true, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Always close rows
|
||||
|
||||
```go
|
||||
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying users: %w", err)
|
||||
}
|
||||
defer rows.Close() // prevents connection leaks
|
||||
|
||||
for rows.Next() {
|
||||
// ...
|
||||
}
|
||||
if err := rows.Err(); err != nil { // always check after iteration
|
||||
return fmt.Errorf("iterating users: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Common database error patterns
|
||||
|
||||
| Error | How to detect | Action |
|
||||
| --- | --- | --- |
|
||||
| Row not found | `errors.Is(err, sql.ErrNoRows)` | Return domain error |
|
||||
| Unique constraint | Check driver-specific error code | Return conflict error |
|
||||
| Connection refused | `err != nil` on `db.PingContext` | Fail fast, log, retry with backoff |
|
||||
| Serialization failure | PostgreSQL error code `40001` | Retry the entire transaction |
|
||||
| Context canceled | `errors.Is(err, context.Canceled)` | Stop processing, propagate |
|
||||
|
||||
## Context Propagation
|
||||
|
||||
Always use the `*Context` method variants to propagate deadlines and cancellation:
|
||||
|
||||
```go
|
||||
// ✗ Bad — no context, query runs until completion even if client disconnects
|
||||
db.Query("SELECT ...")
|
||||
|
||||
// ✓ Good — respects context cancellation and timeouts
|
||||
db.QueryContext(ctx, "SELECT ...")
|
||||
```
|
||||
|
||||
For context patterns in depth, see the `samber/cc-skills-golang@golang-context` skill.
|
||||
|
||||
## Transactions, Isolation Levels, and Locking
|
||||
|
||||
For transaction patterns, isolation levels, `SELECT FOR UPDATE`, and locking variants, see [Transactions](./references/transactions.md).
|
||||
|
||||
## Connection Pool
|
||||
|
||||
```go
|
||||
db.SetMaxOpenConns(25) // limit total connections
|
||||
db.SetMaxIdleConns(10) // keep warm connections ready
|
||||
db.SetConnMaxLifetime(5 * time.Minute) // recycle stale connections
|
||||
db.SetConnMaxIdleTime(1 * time.Minute) // close idle connections faster
|
||||
```
|
||||
|
||||
For sizing guidance and formulas, see [Database Performance](./references/performance.md).
|
||||
|
||||
## Migrations
|
||||
|
||||
Use an external migration tool. Schema changes require human review with understanding of data volumes, existing indexes, foreign keys, and production constraints.
|
||||
|
||||
Recommended tools:
|
||||
|
||||
- [golang-migrate](https://github.com/golang-migrate/migrate) — CLI + Go library, supports all major databases
|
||||
- [Flyway](https://flywaydb.org/) — JVM-based, widely used in enterprise environments
|
||||
- [Atlas](https://atlasgo.io/) — modern, declarative schema management
|
||||
|
||||
Migration SQL should be written and reviewed by humans, versioned in source control, and applied through CI/CD pipelines.
|
||||
|
||||
## Avoid Hidden SQL Features
|
||||
|
||||
Do not rely on triggers, views, materialized views, stored procedures, or row-level security in application code — they create invisible side effects and make debugging impossible. Keep SQL explicit and visible in Go where it can be tested and version-controlled.
|
||||
|
||||
## Schema Creation
|
||||
|
||||
**This skill does NOT cover schema creation.** AI-generated schemas are often subtly wrong — missing indexes, incorrect column types, bad normalization, or missing constraints. Schema design requires understanding data volumes, access patterns, query profiles, and business constraints. Use dedicated database tooling and human review.
|
||||
|
||||
## Deep Dives
|
||||
|
||||
- **[Transactions](./references/transactions.md)** — Transaction boundaries, isolation levels, deadlock prevention, `SELECT FOR UPDATE`
|
||||
- **[Testing Database Code](./references/testing.md)** — Mock connections, integration tests with containers, fixtures, schema setup/teardown
|
||||
- **[Database Performance](./references/performance.md)** — Connection pool sizing, batch processing, indexing strategy, query optimization
|
||||
- **[Struct Scanning](./references/scanning.md)** — Struct tags, NULLable column handling, JSON marshaling patterns
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-security` skill for SQL injection prevention patterns
|
||||
- → See `samber/cc-skills-golang@golang-context` skill for context propagation to database operations
|
||||
- → See `samber/cc-skills-golang@golang-error-handling` skill for database error wrapping patterns
|
||||
- → See `samber/cc-skills-golang@golang-testing` skill for database integration test patterns
|
||||
|
||||
## References
|
||||
|
||||
- [database/sql tutorial](https://go.dev/doc/database/)
|
||||
- [sqlx](https://github.com/jmoiron/sqlx)
|
||||
- [pgx](https://github.com/jackc/pgx)
|
||||
- [golang-migrate](https://github.com/golang-migrate/migrate)
|
||||
@@ -0,0 +1,211 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "orm-vs-sqlx-pgx-recommendation",
|
||||
"description": "Tests whether the model recommends sqlx/pgx over ORMs and explains why ORMs are harmful",
|
||||
"prompt": "I'm starting a new Go web application with PostgreSQL. I need to set up database access. What library should I use? I've heard GORM is popular and has good docs. Can you set up the database layer for me?",
|
||||
"trap": "Without the skill, the model may recommend GORM since it's popular and widely known. The skill explicitly says to avoid ORMs and use sqlx or pgx.",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Recommends sqlx or pgx instead of GORM"},
|
||||
{"id": "1.2", "text": "Explains why ORMs are problematic (N+1 queries, unpredictable SQL generation, magic hooks, or debugging difficulty)"},
|
||||
{"id": "1.3", "text": "Recommends pgx specifically for PostgreSQL-only projects due to performance advantage (30-50% faster)"},
|
||||
{"id": "1.4", "text": "Does NOT set up GORM or ent as the primary database library"},
|
||||
{"id": "1.5", "text": "Mentions that learning the ORM API is harder than learning SQL or that ORMs hide SQL"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "exec-vs-query-for-non-select",
|
||||
"description": "Tests the subtle rule that db.Query must NOT be used for statements that don't return rows",
|
||||
"prompt": "Write a Go function that deletes expired sessions from a PostgreSQL database. Use sqlx. The function should delete all sessions where expires_at < now() and return the number of deleted rows.",
|
||||
"trap": "The model may use db.QueryContext or db.Query for the DELETE statement, which returns *Rows that must be closed. The skill says to use db.Exec for statements that don't return rows.",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Uses ExecContext (not QueryContext or Query) for the DELETE statement"},
|
||||
{"id": "2.2", "text": "Uses the *Context variant (ExecContext, not Exec)"},
|
||||
{"id": "2.3", "text": "Passes ctx to the database call"},
|
||||
{"id": "2.4", "text": "Retrieves RowsAffected() from the result to return the count"},
|
||||
{"id": "2.5", "text": "Uses parameterized query (not string concatenation)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "nullable-column-handling",
|
||||
"description": "Tests whether pointer fields are preferred for NULLable columns over sql.NullXxx types",
|
||||
"prompt": "I have a PostgreSQL users table with columns: id (int), name (text NOT NULL), bio (text, nullable), deleted_at (timestamptz, nullable). Write a Go struct that I can use with sqlx for scanning and also marshal to JSON properly. The bio should be omitted from JSON when NULL, and deleted_at should appear as null in JSON.",
|
||||
"trap": "Without the skill, the model may use sql.NullString and sql.NullTime which require custom JSON marshaling. The skill recommends pointer fields as the cleanest approach.",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Uses pointer types (*string for bio, *time.Time for deleted_at) rather than sql.NullString/sql.NullTime"},
|
||||
{"id": "3.2", "text": "Includes db struct tags for sqlx (db:\"column_name\")"},
|
||||
{"id": "3.3", "text": "Includes json struct tags"},
|
||||
{"id": "3.4", "text": "Uses json:\"bio,omitempty\" for bio (omitted when NULL)"},
|
||||
{"id": "3.5", "text": "Uses json:\"deleted_at\" without omitempty for deleted_at (appears as null)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "connection-pool-configuration",
|
||||
"description": "Tests that connection pool settings are configured with all four parameters and reasonable values",
|
||||
"prompt": "Write a Go function that creates a new sqlx database connection to PostgreSQL and returns *sqlx.DB. Just the basic setup, nothing fancy.",
|
||||
"trap": "Without the skill, the model often returns the *sqlx.DB without configuring the connection pool. The skill requires all four pool settings.",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Calls SetMaxOpenConns on the database connection"},
|
||||
{"id": "4.2", "text": "Calls SetMaxIdleConns on the database connection"},
|
||||
{"id": "4.3", "text": "Calls SetConnMaxLifetime on the database connection"},
|
||||
{"id": "4.4", "text": "Calls SetConnMaxIdleTime on the database connection"},
|
||||
{"id": "4.5", "text": "MaxIdleConns is less than or equal to MaxOpenConns"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "rows-close-and-err-check",
|
||||
"description": "Tests proper handling of rows iteration: defer Close and rows.Err() check after loop",
|
||||
"prompt": "Write a Go function using sqlx that lists all active users (active = true) from a users table. Return a slice of User structs. Use db.QueryContext, not db.SelectContext — I want to handle scanning manually for learning purposes.",
|
||||
"trap": "Without the skill, the model may forget to defer rows.Close() or skip the rows.Err() check after iteration, both of which are required.",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Calls defer rows.Close() immediately after the QueryContext call (before the loop)"},
|
||||
{"id": "5.2", "text": "Checks rows.Err() after the for rows.Next() loop completes"},
|
||||
{"id": "5.3", "text": "Returns the error from rows.Err() if non-nil"},
|
||||
{"id": "5.4", "text": "Uses QueryContext (not Query) with a context parameter"},
|
||||
{"id": "5.5", "text": "Checks the error returned by QueryContext before proceeding"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "errnorows-handling-pattern",
|
||||
"description": "Tests proper sql.ErrNoRows handling with domain error translation",
|
||||
"prompt": "Write a Go function GetUserByEmail(ctx context.Context, db *sqlx.DB, email string) that returns the user or an appropriate error when not found. Use sqlx.GetContext.",
|
||||
"trap": "Without the skill, the model may return the raw sql.ErrNoRows error directly or not distinguish it from other errors. The skill requires translating to a domain error.",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Uses errors.Is(err, sql.ErrNoRows) to check for not-found"},
|
||||
{"id": "6.2", "text": "Returns a domain-specific error (e.g. ErrUserNotFound) when no rows, NOT the raw sql.ErrNoRows"},
|
||||
{"id": "6.3", "text": "Wraps non-ErrNoRows errors with context using fmt.Errorf and %w"},
|
||||
{"id": "6.4", "text": "Uses GetContext (not Get) with the ctx parameter"},
|
||||
{"id": "6.5", "text": "Uses parameterized query placeholder ($1 or ?) not string concatenation"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "transaction-with-defer-rollback",
|
||||
"description": "Tests the correct transaction pattern: BeginTxx, defer Rollback, Commit",
|
||||
"prompt": "Write a Go function TransferFunds(ctx, db, fromAccountID, toAccountID string, amount int) that transfers money between two accounts atomically. Use sqlx.",
|
||||
"trap": "Without the skill, the model may forget defer tx.Rollback(), use wrong isolation level for financial operations, or miss SELECT FOR UPDATE.",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses BeginTxx (or BeginTx) to start a transaction"},
|
||||
{"id": "7.2", "text": "Calls defer tx.Rollback() immediately after BeginTxx"},
|
||||
{"id": "7.3", "text": "Uses SELECT ... FOR UPDATE when reading balances to prevent race conditions"},
|
||||
{"id": "7.4", "text": "Sets serializable or repeatable-read isolation level (financial operation)"},
|
||||
{"id": "7.5", "text": "Calls tx.Commit() at the end of the successful path"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "dynamic-in-clause-with-rebind",
|
||||
"description": "Tests proper handling of dynamic IN clauses with sqlx.In and Rebind",
|
||||
"prompt": "Write a Go function that fetches users by a list of IDs from PostgreSQL using sqlx. The function receives a []int64 of user IDs.",
|
||||
"trap": "Without the skill, the model may try to use a raw IN ($1) placeholder or manually build the query string. The skill shows sqlx.In + Rebind pattern.",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Uses sqlx.In() to expand the IN clause placeholders"},
|
||||
{"id": "8.2", "text": "Calls db.Rebind() on the query after sqlx.In to adjust placeholders for the driver"},
|
||||
{"id": "8.3", "text": "Passes the expanded args from sqlx.In to the query execution"},
|
||||
{"id": "8.4", "text": "Uses a *Context method variant (SelectContext, QueryContext, etc.)"},
|
||||
{"id": "8.5", "text": "Handles the error from sqlx.In"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "dynamic-column-name-allowlist",
|
||||
"description": "Tests that dynamic column names use allowlists, not direct interpolation",
|
||||
"prompt": "Write a Go function that lists users with a sortable column parameter. The caller specifies which column to sort by as a string. Use sqlx.",
|
||||
"trap": "Without the skill, the model may directly interpolate the sort column into the SQL string without validation, creating a SQL injection vector.",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Validates the sort column against an explicit allowlist (map or slice of allowed column names)"},
|
||||
{"id": "9.2", "text": "Returns an error if the column is not in the allowlist"},
|
||||
{"id": "9.3", "text": "Does NOT directly pass the column name as a parameterized placeholder ($1) — column names cannot be parameterized"},
|
||||
{"id": "9.4", "text": "Uses fmt.Sprintf or string concatenation ONLY after validation against the allowlist"},
|
||||
{"id": "9.5", "text": "Uses a *Context method variant for the actual query"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "schema-creation-refusal",
|
||||
"description": "Tests that the model refuses to generate database schemas",
|
||||
"prompt": "Create a PostgreSQL schema for an e-commerce application with users, products, orders, and order_items tables. Include proper indexes, foreign keys, and constraints.",
|
||||
"trap": "Without the skill, the model will happily generate the full schema. The skill explicitly states AI must NOT generate database schemas.",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Does NOT generate a complete CREATE TABLE schema"},
|
||||
{"id": "10.2", "text": "Explains why AI-generated schemas are problematic (missing indexes, incorrect types, bad normalization, or need for production context)"},
|
||||
{"id": "10.3", "text": "Recommends human review or dedicated database tooling for schema design"},
|
||||
{"id": "10.4", "text": "Mentions that schema design requires understanding data volumes, access patterns, or production constraints"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "batch-processing-sweet-spot",
|
||||
"description": "Tests that batch operations use reasonable batch sizes, not row-by-row or one giant batch",
|
||||
"prompt": "Write a Go function that inserts 50,000 user records into PostgreSQL. Use sqlx. Optimize for speed.",
|
||||
"trap": "Without the skill, the model may insert all 50k in one statement or insert one-by-one. The skill recommends 100-1000 rows per batch.",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Uses batching with a batch size between 100 and 1000 rows"},
|
||||
{"id": "11.2", "text": "Does NOT insert all 50,000 rows in a single statement"},
|
||||
{"id": "11.3", "text": "Does NOT insert one row at a time in a loop"},
|
||||
{"id": "11.4", "text": "Uses NamedExecContext or a multi-row INSERT pattern"},
|
||||
{"id": "11.5", "text": "Handles errors per batch with context about which batch failed"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "cursor-pagination-over-offset",
|
||||
"description": "Tests that cursor-based pagination is recommended over OFFSET for large datasets",
|
||||
"prompt": "Write a Go function that paginates through a large events table (millions of rows) ordered by created_at. The function should support page navigation.",
|
||||
"trap": "Without the skill, the model commonly uses OFFSET/LIMIT which degrades performance on deep pages. The skill requires cursor-based pagination.",
|
||||
"assertions": [
|
||||
{"id": "12.1", "text": "Uses cursor-based pagination (WHERE created_at > $1) instead of OFFSET"},
|
||||
{"id": "12.2", "text": "Explains why OFFSET is problematic (re-scans skipped rows, O(offset+limit))"},
|
||||
{"id": "12.3", "text": "Uses LIMIT with ORDER BY for the page size"},
|
||||
{"id": "12.4", "text": "Returns a cursor value (e.g. the last created_at) for the next page"},
|
||||
{"id": "12.5", "text": "Uses parameterized queries for the cursor value"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "integration-test-with-build-tags",
|
||||
"description": "Tests proper database integration test setup with build tags and transaction rollback",
|
||||
"prompt": "Write integration tests for a Go repository layer that interacts with PostgreSQL. I want to test that Create and GetByID work correctly together.",
|
||||
"trap": "Without the skill, the model may not use build tags or may not wrap tests in transactions for cleanup. The skill requires both.",
|
||||
"assertions": [
|
||||
{"id": "13.1", "text": "Uses //go:build integration build tag to separate from unit tests"},
|
||||
{"id": "13.2", "text": "Uses transaction-based test isolation (begin tx in setup, rollback in teardown)"},
|
||||
{"id": "13.3", "text": "Does NOT test against a production database — uses a test DSN or testcontainers"},
|
||||
{"id": "13.4", "text": "Uses testify/suite or a similar setup/teardown pattern"},
|
||||
{"id": "13.5", "text": "Tests actual SQL correctness (not mocked — this is integration)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "avoid-hidden-sql-features",
|
||||
"description": "Tests that the model avoids triggers, views, stored procedures in application code",
|
||||
"prompt": "I want to automatically update an 'updated_at' column every time a row is modified in my users table. Should I create a PostgreSQL trigger for this? Also, I have a complex query that joins 5 tables — should I create a database view?",
|
||||
"trap": "Without the skill, the model will likely recommend triggers and views as standard PostgreSQL features. The skill says to avoid hidden SQL features.",
|
||||
"assertions": [
|
||||
{"id": "14.1", "text": "Advises against using triggers for updated_at in application code"},
|
||||
{"id": "14.2", "text": "Recommends setting updated_at explicitly in Go code instead"},
|
||||
{"id": "14.3", "text": "Advises against using views for the complex query"},
|
||||
{"id": "14.4", "text": "Explains that hidden SQL features create invisible side effects or make debugging harder"},
|
||||
{"id": "14.5", "text": "Recommends keeping SQL explicit and visible in Go code"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "pgx-copy-for-bulk-postgres",
|
||||
"description": "Tests knowledge of pgx COPY protocol for maximum PostgreSQL bulk insert throughput",
|
||||
"prompt": "I need to insert 100,000 rows into PostgreSQL as fast as possible. I'm already using pgx. What's the fastest approach?",
|
||||
"trap": "Without the skill, the model may suggest multi-row INSERT statements. The skill teaches pgx.CopyFrom using the binary COPY protocol for maximum throughput.",
|
||||
"assertions": [
|
||||
{"id": "15.1", "text": "Recommends pgx.CopyFrom using the COPY protocol"},
|
||||
{"id": "15.2", "text": "Shows pgx.CopyFromRows or pgx.CopyFromSlice usage"},
|
||||
{"id": "15.3", "text": "Mentions that COPY is significantly faster than multi-row INSERT"},
|
||||
{"id": "15.4", "text": "Uses pgx.Identifier for the table name"},
|
||||
{"id": "15.5", "text": "Still recommends batching if the dataset is extremely large (to avoid memory issues)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
# Database Performance
|
||||
|
||||
## Connection Pool Sizing
|
||||
|
||||
### Configuration
|
||||
|
||||
```go
|
||||
db, err := sqlx.Connect("postgres", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(25) // total connections (match your DB capacity)
|
||||
db.SetMaxIdleConns(10) // keep connections warm, reduce handshake overhead
|
||||
db.SetConnMaxLifetime(5 * time.Minute) // recycle connections (DNS changes, server restarts)
|
||||
db.SetConnMaxIdleTime(1 * time.Minute) // release idle connections back to the pool
|
||||
```
|
||||
|
||||
| Setting | Too low | Too high |
|
||||
| --- | --- | --- |
|
||||
| `MaxOpenConns` | Requests queue waiting for conn | DB overwhelmed, context switches |
|
||||
| `MaxIdleConns` | Cold connections, slow queries | Wasted memory holding idle conns |
|
||||
| `ConnMaxLifetime` | Frequent reconnection overhead | Stale connections after failover |
|
||||
| `ConnMaxIdleTime` | Same as MaxIdleConns too low | Idle conns consume server memory |
|
||||
|
||||
### Monitoring
|
||||
|
||||
Check pool stats in production to detect exhaustion:
|
||||
|
||||
```go
|
||||
stats := db.Stats()
|
||||
slog.Info("db pool",
|
||||
"open", stats.OpenConnections,
|
||||
"in_use", stats.InUse,
|
||||
"idle", stats.Idle,
|
||||
"wait_count", stats.WaitCount, // total waits for a connection
|
||||
"wait_duration", stats.WaitDuration, // total wait time
|
||||
)
|
||||
```
|
||||
|
||||
If `WaitCount` keeps climbing, increase `MaxOpenConns` or optimize slow queries.
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
Use a custom Prometheus collector to export pool metrics on-demand (scales to multiple pools automatically):
|
||||
|
||||
```go
|
||||
type DBCollector struct {
|
||||
pools map[string]*sqlx.DB
|
||||
}
|
||||
|
||||
func NewDBCollector(pools map[string]*sqlx.DB) *DBCollector {
|
||||
return &DBCollector{pools: pools}
|
||||
}
|
||||
|
||||
func (c *DBCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- prometheus.NewDesc("db_open_connections", "Number of open connections", []string{"pool"}, nil)
|
||||
ch <- prometheus.NewDesc("db_in_use_connections", "Connections currently in use", []string{"pool"}, nil)
|
||||
ch <- prometheus.NewDesc("db_idle_connections", "Idle connections in pool", []string{"pool"}, nil)
|
||||
ch <- prometheus.NewDesc("db_total_latency_seconds", "Total latency for a connection", []string{"pool"}, nil)
|
||||
}
|
||||
|
||||
func (c *DBCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
for poolName, db := range c.pools {
|
||||
stats := db.Stats()
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
prometheus.NewDesc("db_open_connections", "Number of open connections", []string{"pool"}, nil),
|
||||
prometheus.GaugeValue, float64(stats.OpenConnections), poolName)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
prometheus.NewDesc("db_in_use_connections", "Connections currently in use", []string{"pool"}, nil),
|
||||
prometheus.GaugeValue, float64(stats.InUse), poolName)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
prometheus.NewDesc("db_idle_connections", "Idle connections in pool", []string{"pool"}, nil),
|
||||
prometheus.GaugeValue, float64(stats.Idle), poolName)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
prometheus.NewDesc("db_total_latency_seconds", "Total latency for a connection", []string{"pool"}, nil),
|
||||
prometheus.CounterValue, float64(stats.LatencyCount), poolName)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
pools := map[string]*sqlx.DB{
|
||||
"primary": mainDB,
|
||||
"replica": replicaDB,
|
||||
}
|
||||
prometheus.MustRegister(NewDBCollector(pools))
|
||||
}
|
||||
```
|
||||
|
||||
**Collector advantages:**
|
||||
|
||||
- Metrics are collected on-demand during scrapes (no background goroutine)
|
||||
- Always returns current state (no stale data between scrapes)
|
||||
- Scales to multiple pools automatically
|
||||
- Lower memory footprint (no metric state in memory)
|
||||
|
||||
**Alert thresholds:**
|
||||
|
||||
- Open connections approaching `MaxOpenConns` → risk of request queuing
|
||||
- Wait count climbing steadily → pool is exhausted, increase `MaxOpenConns`
|
||||
- Idle connections too high → reduce `MaxIdleConns` or lower `ConnMaxIdleTime`
|
||||
|
||||
## Batch Processing
|
||||
|
||||
Avoid two extremes:
|
||||
|
||||
- **Row-by-row** — N round trips for N rows, extremely slow
|
||||
- **One giant batch** — locks tables, consumes memory, can timeout and block other queries
|
||||
|
||||
### Sweet spot: 100–1,000 rows per batch
|
||||
|
||||
Adjust based on row size and database load. Larger rows → smaller batches.
|
||||
|
||||
### Batch INSERT with sqlx
|
||||
|
||||
```go
|
||||
func insertUsersBatch(ctx context.Context, db *sqlx.DB, users []User) error {
|
||||
const batchSize = 500
|
||||
for i := 0; i < len(users); i += batchSize {
|
||||
end := min(i+batchSize, len(users))
|
||||
batch := users[i:end]
|
||||
|
||||
_, err := db.NamedExecContext(ctx, `INSERT INTO users (name, email) VALUES (:name, :email)`, batch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting users batch %d-%d: %w", i, end, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk INSERT with pgx (PostgreSQL COPY protocol)
|
||||
|
||||
For maximum throughput on PostgreSQL, use `pgx.CopyFrom` which uses the binary COPY protocol — significantly faster than multi-row INSERT:
|
||||
|
||||
```go
|
||||
rows := make([][]any, len(users))
|
||||
for i, u := range users {
|
||||
rows[i] = []any{u.Name, u.Email}
|
||||
}
|
||||
_, err := pool.CopyFrom(ctx,
|
||||
pgx.Identifier{"users"},
|
||||
[]string{"name", "email"},
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
```
|
||||
|
||||
### Cursor-based pagination (avoid OFFSET)
|
||||
|
||||
For reading large datasets, use cursor-based pagination instead of `OFFSET`. OFFSET re-scans skipped rows, getting slower as you paginate deeper:
|
||||
|
||||
```go
|
||||
// ✗ Bad — OFFSET re-scans rows, O(offset + limit)
|
||||
SELECT * FROM events ORDER BY created_at LIMIT 100 OFFSET 10000
|
||||
|
||||
// ✓ Good — cursor-based, O(limit) regardless of depth
|
||||
SELECT * FROM events WHERE created_at > $1 ORDER BY created_at LIMIT 100
|
||||
```
|
||||
|
||||
## Indexing Strategy
|
||||
|
||||
**Never create or drop indexes yourself.** Index changes affect production query performance and write throughput. Always suggest to the developer and let them decide.
|
||||
|
||||
### Use SQL MCP to check existing indexes
|
||||
|
||||
When a SQL MCP tool is available, query the database to check existing indexes before suggesting new ones:
|
||||
|
||||
```sql
|
||||
-- PostgreSQL: list indexes on a table
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'users';
|
||||
|
||||
-- Check for unused indexes (low scan count relative to writes)
|
||||
SELECT schemaname, relname, indexrelname, idx_scan, idx_tup_read
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE idx_scan < 10
|
||||
ORDER BY idx_scan;
|
||||
```
|
||||
|
||||
### When to suggest adding indexes
|
||||
|
||||
- Foreign key columns (PostgreSQL does NOT auto-index foreign keys)
|
||||
- Columns frequently used in `WHERE`, `JOIN`, or `ORDER BY`
|
||||
- Composite indexes for multi-column queries (leftmost column is most selective)
|
||||
- Partial indexes for filtered queries (`WHERE active = true`)
|
||||
|
||||
### When to suggest removing indexes
|
||||
|
||||
- Indexes with near-zero `idx_scan` count (nobody reads them)
|
||||
- Duplicate indexes (same columns in same order)
|
||||
- Indexes on write-heavy tables that slow down INSERT/UPDATE/DELETE
|
||||
- Wide composite indexes where a narrower one would suffice
|
||||
|
||||
Always present findings as suggestions with data (scan counts, table size), never execute DDL yourself.
|
||||
|
||||
## Query Performance Tips
|
||||
|
||||
- **`EXPLAIN ANALYZE`** before optimizing — measure, don't guess
|
||||
- **List columns explicitly** — avoid `SELECT *`, it fetches unnecessary data and breaks struct scanning when schema changes
|
||||
- **Use `LIMIT`** for pagination, always with an `ORDER BY`
|
||||
- **Prefer `EXISTS` over `COUNT`** for existence checks — `EXISTS` stops at the first match
|
||||
- **Avoid N+1 queries** — use `JOIN` or batch `WHERE id IN (...)` instead of querying in a loop
|
||||
- **Suggest improvements, never execute them** — performance changes (indexes, query rewrites, configuration) need human review in context of production data and workload patterns
|
||||
|
||||
Batch operations SHOULD use 100–1,000 rows per batch — adjust based on row size and database load. Cursor-based pagination MUST replace `OFFSET` for large datasets — the cursor column MUST be chosen based on actual indexes (e.g., `created_at`, `user_id`). NEVER create indexes blindly — check existing indexes, measure with `EXPLAIN ANALYZE`, and present findings as suggestions. N+1 queries MUST be eliminated — use `JOIN` or batch `WHERE id IN (...)`.
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-observability` skill for database metrics and query monitoring.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Struct Scanning and NULLable Columns
|
||||
|
||||
## Struct Scanning with sqlx
|
||||
|
||||
Tag struct fields with `db:"column_name"` for sqlx:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int64 `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Email string `db:"email"`
|
||||
DeletedAt *time.Time `db:"deleted_at"` // NULLable
|
||||
}
|
||||
|
||||
// Single row
|
||||
var user User
|
||||
err := db.GetContext(ctx, &user, "SELECT id, name, email, deleted_at FROM users WHERE id = $1", id)
|
||||
|
||||
// Multiple rows
|
||||
var users []User
|
||||
err := db.SelectContext(ctx, &users, "SELECT id, name, email, deleted_at FROM users WHERE active = true")
|
||||
```
|
||||
|
||||
## Struct Scanning with pgx
|
||||
|
||||
With pgx (v5+), use `pgx.CollectRows` for automatic struct mapping:
|
||||
|
||||
```go
|
||||
rows, err := pool.Query(ctx, "SELECT id, name, email FROM users WHERE active = true")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying users: %w", err)
|
||||
}
|
||||
users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User])
|
||||
```
|
||||
|
||||
## JSON Marshaling
|
||||
|
||||
Struct tags for both database and JSON work together. Pointer fields marshal to `null` in JSON when NULL in the database:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Bio *string `db:"bio" json:"bio,omitempty"` // NULL → omitted in JSON
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` // NULL → null in JSON
|
||||
}
|
||||
```
|
||||
|
||||
## NULLable Columns
|
||||
|
||||
Three approaches, from most to least recommended:
|
||||
|
||||
**1. Pointer fields (recommended)** — clean, works with JSON marshaling:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"` // nil when NULL
|
||||
}
|
||||
// Check: if user.DeletedAt != nil { ... }
|
||||
```
|
||||
|
||||
**2. `sql.NullXxx` types** or `sql.Null[T]` generic — explicit but verbose, requires custom JSON marshaling:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID int64 `db:"id"`
|
||||
Bio sql.NullString `db:"bio"`
|
||||
}
|
||||
// Check: if user.Bio.Valid { use(user.Bio.String) }
|
||||
```
|
||||
|
||||
**3. `COALESCE` in SQL** — moves NULL handling to the query:
|
||||
|
||||
```sql
|
||||
SELECT id, COALESCE(bio, '') AS bio FROM users WHERE id = $1
|
||||
```
|
||||
@@ -0,0 +1,209 @@
|
||||
# Testing Database Code
|
||||
|
||||
## Unit Tests with Mocks
|
||||
|
||||
Define a repository interface so business logic can be tested without a database. Mock the interface with `testify/mock`:
|
||||
|
||||
```go
|
||||
// Repository interface — the contract
|
||||
type UserRepository interface {
|
||||
GetByID(ctx context.Context, id int64) (*User, bool, error)
|
||||
Create(ctx context.Context, user *User) error
|
||||
}
|
||||
|
||||
// Production implementation
|
||||
type pgUserRepository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func (r *pgUserRepository) GetByID(ctx context.Context, id int64) (*User, bool, error) {
|
||||
var user User
|
||||
err := r.db.GetContext(ctx, &user, "SELECT id, name, email FROM users WHERE id = $1", id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("querying user %d: %w", id, err)
|
||||
}
|
||||
return &user, true, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Mock for service-layer tests
|
||||
|
||||
```go
|
||||
type mockUserRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
args := m.Called(ctx, id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*User), args.Error(1)
|
||||
}
|
||||
|
||||
func TestUserService_GetUser(t *testing.T) {
|
||||
repo := new(mockUserRepo)
|
||||
svc := NewUserService(repo)
|
||||
|
||||
expected := &User{ID: 1, Name: "Alice", Email: "alice@example.com"}
|
||||
repo.On("GetByID", mock.Anything, int64(1)).Return(expected, nil)
|
||||
|
||||
user, err := svc.GetUser(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, user)
|
||||
repo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserService_GetUser_NotFound(t *testing.T) {
|
||||
repo := new(mockUserRepo)
|
||||
svc := NewUserService(repo)
|
||||
|
||||
repo.On("GetByID", mock.Anything, int64(999)).Return(nil, ErrUserNotFound)
|
||||
|
||||
user, err := svc.GetUser(context.Background(), 999)
|
||||
assert.Nil(t, user)
|
||||
assert.ErrorIs(t, err, ErrUserNotFound)
|
||||
}
|
||||
```
|
||||
|
||||
Unit tests verify business logic, not SQL correctness. They run fast and without external dependencies.
|
||||
|
||||
## sqlmock for Query-Level Testing
|
||||
|
||||
When you need to verify exact SQL without a real database, use [DATA-DOG/go-sqlmock](https://github.com/DATA-DOG/go-sqlmock):
|
||||
|
||||
```go
|
||||
func TestGetByID_sqlmock(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
sqlxDB := sqlx.NewDb(db, "postgres")
|
||||
repo := &pgUserRepository{db: sqlxDB}
|
||||
|
||||
rows := sqlmock.NewRows([]string{"id", "name", "email"}).
|
||||
AddRow(1, "Alice", "alice@example.com")
|
||||
mock.ExpectQuery("SELECT id, name, email FROM users WHERE id = \\$1").
|
||||
WithArgs(1).
|
||||
WillReturnRows(rows)
|
||||
|
||||
user, err := repo.GetByID(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Alice", user.Name)
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
```
|
||||
|
||||
sqlmock is useful for verifying query structure and error handling paths, but it does not validate that your SQL is correct against a real database schema.
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Integration tests run against a real database. Gate them with build tags so `go test ./...` skips them by default:
|
||||
|
||||
```go
|
||||
//go:build integration
|
||||
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type UserRepoSuite struct {
|
||||
suite.Suite
|
||||
db *sqlx.DB
|
||||
tx *sqlx.Tx
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) SetupSuite() {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL") // e.g., postgres://test:test@localhost:5432/testdb?sslmode=disable
|
||||
db, err := sqlx.Connect("postgres", dsn)
|
||||
s.Require().NoError(err)
|
||||
s.db = db
|
||||
// Run migrations here if needed
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TearDownSuite() {
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) SetupTest() {
|
||||
tx, err := s.db.Beginx()
|
||||
s.Require().NoError(err)
|
||||
s.tx = tx
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TearDownTest() {
|
||||
s.tx.Rollback() // rolls back all changes — each test starts clean
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestCreateAndGet() {
|
||||
repo := NewUserRepository(s.tx)
|
||||
user := &User{Name: "Alice", Email: "alice@example.com"}
|
||||
|
||||
err := repo.Create(context.Background(), user)
|
||||
s.Require().NoError(err)
|
||||
s.NotZero(user.ID)
|
||||
|
||||
got, err := repo.GetByID(context.Background(), user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Equal("Alice", got.Name)
|
||||
}
|
||||
|
||||
func TestUserRepoSuite(t *testing.T) {
|
||||
suite.Run(t, new(UserRepoSuite))
|
||||
}
|
||||
```
|
||||
|
||||
Run integration tests:
|
||||
|
||||
```bash
|
||||
go test -tags=integration -v ./internal/repository/...
|
||||
```
|
||||
|
||||
### Test database with testcontainers-go
|
||||
|
||||
For CI environments without a pre-existing database:
|
||||
|
||||
```go
|
||||
func (s *UserRepoSuite) SetupSuite() {
|
||||
ctx := context.Background()
|
||||
container, err := postgres.Run(ctx, "postgres:16-alpine",
|
||||
postgres.WithDatabase("testdb"),
|
||||
postgres.WithUsername("test"),
|
||||
postgres.WithPassword("test"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(30*time.Second),
|
||||
),
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
s.container = container
|
||||
|
||||
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
s.Require().NoError(err)
|
||||
s.db, err = sqlx.Connect("postgres", connStr)
|
||||
s.Require().NoError(err)
|
||||
}
|
||||
```
|
||||
|
||||
## What to Test
|
||||
|
||||
| What | Unit test (mock) | Integration test |
|
||||
| ------------------------- | :--------------: | :--------------: |
|
||||
| Business logic | ✓ | |
|
||||
| SQL correctness | | ✓ |
|
||||
| Error paths (not found) | ✓ | ✓ |
|
||||
| Transaction boundaries | | ✓ |
|
||||
| NULL handling round-trips | | ✓ |
|
||||
| Constraint violations | | ✓ |
|
||||
| Query performance | | ✓ (with EXPLAIN) |
|
||||
|
||||
Unit tests MUST use mocks (interface mocks or sqlmock) — no real database connections. Integration tests MUST use build tags (`//go:build integration`) to separate from unit tests. Integration tests SHOULD use testcontainers-go for reproducible database environments in CI. NEVER test against production databases.
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-testing` skill for general test patterns and CI configuration.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Transactions, Isolation Levels, and Locking
|
||||
|
||||
## Basic transaction pattern
|
||||
|
||||
```go
|
||||
tx, err := db.BeginTxx(ctx, nil) // default isolation (READ COMMITTED)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // no-op if already committed
|
||||
|
||||
// ... execute queries using tx ...
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("committing transaction: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Custom isolation level
|
||||
|
||||
```go
|
||||
tx, err := db.BeginTxx(ctx, &sql.TxOptions{
|
||||
Isolation: sql.LevelSerializable, // strongest guarantee
|
||||
})
|
||||
```
|
||||
|
||||
| Level | Use when |
|
||||
| --- | --- |
|
||||
| `LevelReadCommitted` | Default — good for most operations |
|
||||
| `LevelRepeatableRead` | Need consistent reads within a transaction |
|
||||
| `LevelSerializable` | Financial operations, inventory, anything with strict consistency |
|
||||
|
||||
## SELECT FOR UPDATE — prevent race conditions
|
||||
|
||||
```go
|
||||
var balance int
|
||||
err := tx.GetContext(ctx, &balance, "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", accountID)
|
||||
// Row is locked until tx.Commit() or tx.Rollback()
|
||||
```
|
||||
|
||||
Use `FOR UPDATE` when you read a value, compute something from it, and then write it back. Without the lock, concurrent transactions can read stale data.
|
||||
|
||||
## Locking variants
|
||||
|
||||
| Clause | Effect |
|
||||
| --- | --- |
|
||||
| `FOR UPDATE` | Locks rows for write — other transactions block on same rows |
|
||||
| `FOR UPDATE NOWAIT` | Same, but fails immediately instead of waiting |
|
||||
| `FOR SHARE` | Locks rows for read — prevents writes but allows other reads |
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
name: golang-dependency-injection
|
||||
description: Comprehensive guide for dependency injection (DI) in Golang. Covers why DI matters (testability, loose coupling, separation of concerns, lifecycle management), manual constructor injection, and DI library comparison (google/wire, uber-go/dig, uber-go/fx, samber/do). Use this skill when designing service architecture, setting up dependency injection, refactoring tightly coupled code, managing singletons or service factories, or when the user asks about inversion of control, service containers, or wiring dependencies in Go.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs
|
||||
---
|
||||
|
||||
**Persona:** You are a Go software architect. You guide teams toward testable, loosely coupled designs — you choose the simplest DI approach that solves the problem, and you never over-engineer.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Design mode** (new project, new service, or adding a service to an existing DI setup): assess the existing dependency graph and lifecycle needs; recommend manual injection or a library from the decision table; then generate the wiring code.
|
||||
- **Refactor mode** (existing coupled code): use up to 3 parallel sub-agents — Agent 1 identifies global variables and `init()` service setup, Agent 2 maps concrete type dependencies that should become interfaces, Agent 3 locates service-locator anti-patterns (container passed as argument) — then consolidate findings and propose a migration plan.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-dependency-injection` skill takes precedence.
|
||||
|
||||
# Dependency Injection in Go
|
||||
|
||||
Dependency injection (DI) means passing dependencies to a component rather than having it create or find them. In Go, this is how you build testable, loosely coupled applications — your services declare what they need, and the caller (or container) provides it.
|
||||
|
||||
This skill is not exhaustive. When using a DI library (google/wire, uber-go/dig, uber-go/fx, samber/do), refer to the library's official documentation and code examples for current API signatures.
|
||||
|
||||
For interface-based design foundations (accept interfaces, return structs), see the `samber/cc-skills-golang@golang-structs-interfaces` skill.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. Dependencies MUST be injected via constructors — NEVER use global variables or `init()` for service setup
|
||||
2. Small projects (< 10 services) SHOULD use manual constructor injection — no library needed
|
||||
3. Interfaces MUST be defined where consumed, not where implemented — accept interfaces, return structs
|
||||
4. NEVER use global registries or package-level service locators
|
||||
5. The DI container MUST only exist at the composition root (`main()` or app startup) — NEVER pass the container as a dependency
|
||||
6. **Prefer lazy initialization** — only create services when first requested
|
||||
7. **Use singletons for stateful services** (DB connections, caches) and transients for stateless ones
|
||||
8. **Mock at the interface boundary** — DI makes this trivial
|
||||
9. **Keep the dependency graph shallow** — deep chains signal design problems
|
||||
10. **Choose the right DI library** for your project size and team — see the decision table below
|
||||
|
||||
## Why Dependency Injection?
|
||||
|
||||
| Problem without DI | How DI solves it |
|
||||
| --- | --- |
|
||||
| Functions create their own dependencies | Dependencies are injected — swap implementations freely |
|
||||
| Testing requires real databases, APIs | Pass mock implementations in tests |
|
||||
| Changing one component breaks others | Loose coupling via interfaces — components don't know each other's internals |
|
||||
| Services initialized everywhere | Centralized container manages lifecycle (singleton, factory, lazy) |
|
||||
| All services loaded at startup | Lazy loading — services created only when first requested |
|
||||
| Global state and `init()` functions | Explicit wiring at startup — predictable, debuggable |
|
||||
|
||||
DI shines in applications with many interconnected services — HTTP servers, microservices, CLI tools with plugins. For a small script with 2-3 functions, manual wiring is fine. Don't over-engineer.
|
||||
|
||||
## Manual Constructor Injection (No Library)
|
||||
|
||||
For small projects, pass dependencies through constructors. See [Manual DI examples](./references/manual-di.md) for a complete application example.
|
||||
|
||||
```go
|
||||
// ✓ Good — explicit dependencies, testable
|
||||
type UserService struct {
|
||||
db UserStore
|
||||
mailer Mailer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewUserService(db UserStore, mailer Mailer, logger *slog.Logger) *UserService {
|
||||
return &UserService{db: db, mailer: mailer, logger: logger}
|
||||
}
|
||||
|
||||
// main.go — manual wiring
|
||||
func main() {
|
||||
logger := slog.Default()
|
||||
db := postgres.NewUserStore(connStr)
|
||||
mailer := smtp.NewMailer(smtpAddr)
|
||||
userSvc := NewUserService(db, mailer, logger)
|
||||
orderSvc := NewOrderService(db, logger)
|
||||
api := NewAPI(userSvc, orderSvc, logger)
|
||||
api.ListenAndServe(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✗ Bad — hardcoded dependencies, untestable
|
||||
type UserService struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserService() *UserService {
|
||||
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // hidden dependency
|
||||
return &UserService{db: db}
|
||||
}
|
||||
```
|
||||
|
||||
Manual DI breaks down when:
|
||||
|
||||
- You have 15+ services with cross-dependencies
|
||||
- You need lifecycle management (health checks, graceful shutdown)
|
||||
- You want lazy initialization or scoped containers
|
||||
- Wiring order becomes fragile and hard to maintain
|
||||
|
||||
## DI Library Comparison
|
||||
|
||||
Go has three main approaches to DI libraries:
|
||||
|
||||
- [google/wire examples](./references/google-wire.md) — Compile-time code generation
|
||||
- [uber-go/dig + fx examples](./references/uber-dig-fx.md) — Reflection-based framework
|
||||
- [samber/do examples](./references/samber-do.md) — Generics-based, no code generation
|
||||
|
||||
### Decision Table
|
||||
|
||||
| Criteria | Manual | google/wire | uber-go/dig + fx | samber/do |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Project size** | Small (< 10 services) | Medium-Large | Large | Any size |
|
||||
| **Type safety** | Compile-time | Compile-time (codegen) | Runtime (reflection) | Compile-time (generics) |
|
||||
| **Code generation** | None | Required (`wire_gen.go`) | None | None |
|
||||
| **Reflection** | None | None | Yes | None |
|
||||
| **API style** | N/A | Provider sets + build tags | Struct tags + decorators | Simple, generic functions |
|
||||
| **Lazy loading** | Manual | N/A (all eager) | Built-in (fx) | Built-in |
|
||||
| **Singletons** | Manual | Built-in | Built-in | Built-in |
|
||||
| **Transient/factory** | Manual | Manual | Built-in | Built-in |
|
||||
| **Scopes/modules** | Manual | Provider sets | Module system (fx) | Built-in (hierarchical) |
|
||||
| **Health checks** | Manual | Manual | Manual | Built-in interface |
|
||||
| **Graceful shutdown** | Manual | Manual | Built-in (fx) | Built-in interface |
|
||||
| **Container cloning** | N/A | N/A | N/A | Built-in |
|
||||
| **Debugging** | Print statements | Compile errors | `fx.Visualize()` | `ExplainInjector()`, web interface |
|
||||
| **Go version** | Any | Any | Any | 1.18+ (generics) |
|
||||
| **Learning curve** | None | Medium | High | Low |
|
||||
|
||||
### Quick Comparison: Same App, Four Ways
|
||||
|
||||
The dependency graph: `Config -> Database -> UserStore -> UserService -> API`
|
||||
|
||||
**Manual**:
|
||||
|
||||
```go
|
||||
cfg := NewConfig()
|
||||
db := NewDatabase(cfg)
|
||||
store := NewUserStore(db)
|
||||
svc := NewUserService(store)
|
||||
api := NewAPI(svc)
|
||||
api.Run()
|
||||
// No automatic shutdown, health checks, or lazy loading
|
||||
```
|
||||
|
||||
**google/wire**:
|
||||
|
||||
```go
|
||||
// wire.go — then run: wire ./...
|
||||
func InitializeAPI() (*API, error) {
|
||||
wire.Build(NewConfig, NewDatabase, NewUserStore, NewUserService, NewAPI)
|
||||
return nil, nil
|
||||
}
|
||||
// No shutdown or health check support
|
||||
```
|
||||
|
||||
**uber-go/fx**:
|
||||
|
||||
```go
|
||||
app := fx.New(
|
||||
fx.Provide(NewConfig, NewDatabase, NewUserStore, NewUserService),
|
||||
fx.Invoke(func(api *API) { api.Run() }),
|
||||
)
|
||||
app.Run() // manages lifecycle, but reflection-based
|
||||
```
|
||||
|
||||
**samber/do**:
|
||||
|
||||
```go
|
||||
i := do.New()
|
||||
do.Provide(i, NewConfig)
|
||||
do.Provide(i, NewDatabase) // auto shutdown + health check
|
||||
do.Provide(i, NewUserStore)
|
||||
do.Provide(i, NewUserService)
|
||||
api := do.MustInvoke[*API](i)
|
||||
api.Run()
|
||||
// defer i.Shutdown() — handles all cleanup automatically
|
||||
```
|
||||
|
||||
## Testing with DI
|
||||
|
||||
DI makes testing straightforward — inject mocks instead of real implementations:
|
||||
|
||||
```go
|
||||
// Define a mock
|
||||
type MockUserStore struct {
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
func (m *MockUserStore) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Test with manual injection
|
||||
func TestUserService_GetUser(t *testing.T) {
|
||||
mock := &MockUserStore{
|
||||
users: map[string]*User{"1": {ID: "1", Name: "Alice"}},
|
||||
}
|
||||
svc := NewUserService(mock, nil, slog.Default())
|
||||
|
||||
user, err := svc.GetUser(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if user.Name != "Alice" {
|
||||
t.Errorf("got %q, want %q", user.Name, "Alice")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with samber/do — Clone and Override
|
||||
|
||||
Container cloning creates an isolated copy where you override only the services you need to mock:
|
||||
|
||||
```go
|
||||
func TestUserService_WithDo(t *testing.T) {
|
||||
// Create a test injector with mock implementation
|
||||
testInjector := do.New()
|
||||
|
||||
// Provide the mock UserStore interface
|
||||
do.Override[UserStore](testInjector, &MockUserStore{
|
||||
users: map[string]*User{"1": {ID: "1", Name: "Alice"}},
|
||||
})
|
||||
|
||||
// Provide other real services as needed
|
||||
do.Provide[*slog.Logger](testInjector, func(i *do.Injector) (*slog.Logger, error) {
|
||||
return slog.Default(), nil
|
||||
})
|
||||
|
||||
svc := do.MustInvoke[*UserService](testInjector)
|
||||
user, err := svc.GetUser(context.Background(), "1")
|
||||
// ... assertions
|
||||
}
|
||||
```
|
||||
|
||||
This is particularly useful for integration tests where you want most services to be real but need to mock a specific boundary (database, external API, mailer).
|
||||
|
||||
## When to Adopt a DI Library
|
||||
|
||||
| Signal | Action |
|
||||
| --- | --- |
|
||||
| < 10 services, simple dependencies | Stay with manual constructor injection |
|
||||
| 10-20 services, some cross-cutting concerns | Consider a DI library |
|
||||
| 20+ services, lifecycle management needed | Strongly recommended |
|
||||
| Need health checks, graceful shutdown | Use a library with built-in lifecycle support |
|
||||
| Team unfamiliar with DI concepts | Start manual, migrate incrementally |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
| --- | --- |
|
||||
| Global variables as dependencies | Pass through constructors or DI container |
|
||||
| `init()` for service setup | Explicit initialization in `main()` or container |
|
||||
| Depending on concrete types | Accept interfaces at consumption boundaries |
|
||||
| Passing the container everywhere (service locator) | Inject specific dependencies, not the container |
|
||||
| Deep dependency chains (A->B->C->D->E) | Flatten — most services should depend on repositories and config directly |
|
||||
| Creating a new container per request | One container per application; use scopes for request-level isolation |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-samber-do` skill for detailed samber/do usage patterns
|
||||
- → See `samber/cc-skills-golang@golang-structs-interfaces` skill for interface design and composition
|
||||
- → See `samber/cc-skills-golang@golang-testing` skill for testing with dependency injection
|
||||
- → See `samber/cc-skills-golang@golang-project-layout` skill for DI initialization placement
|
||||
|
||||
## References
|
||||
|
||||
- [samber/do/v2 documentation](https://do.samber.dev) | [github.com/samber/do/v2](https://github.com/samber/do)
|
||||
- [google/wire user guide](https://github.com/google/wire/blob/main/docs/guide.md)
|
||||
- [uber-go/fx documentation](https://uber-go.github.io/fx/)
|
||||
- [uber-go/dig](https://github.com/uber-go/dig)
|
||||
@@ -0,0 +1,156 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "constructor-injection-not-globals",
|
||||
"description": "Tests that dependencies are injected via constructors, not global variables or init()",
|
||||
"prompt": "I have a UserService that needs a database connection and a logger. What's the best way to set this up in Go? I was thinking of using a package-level var for the database.",
|
||||
"trap": "Without the skill, the model may accept the global variable approach or use init() for setup. The skill explicitly forbids globals and init() for service setup.",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Uses constructor injection (NewUserService taking dependencies as parameters)"},
|
||||
{"id": "1.2", "text": "Explicitly advises against package-level variables for service dependencies"},
|
||||
{"id": "1.3", "text": "Explains why globals are problematic (untestable, hidden dependencies, or coupling)"},
|
||||
{"id": "1.4", "text": "Does NOT use init() for service initialization"},
|
||||
{"id": "1.5", "text": "Returns a concrete struct pointer from the constructor, not an interface"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "interface-defined-at-consumer",
|
||||
"description": "Tests that interfaces are defined where consumed, not where implemented",
|
||||
"prompt": "I'm building a Go service that uses a UserStore. Should I define the UserStore interface in the same package as the PostgreSQL implementation or somewhere else? Show me the correct pattern.",
|
||||
"trap": "Without the skill, the model often defines the interface in the implementation package (next to the struct). The skill requires interfaces to be defined at the consumption site.",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Defines the interface in the consuming package (e.g. service package), not the implementation package"},
|
||||
{"id": "2.2", "text": "Explains the principle: accept interfaces, return structs"},
|
||||
{"id": "2.3", "text": "The implementation package returns a concrete struct pointer"},
|
||||
{"id": "2.4", "text": "The consumer depends on its own locally-defined interface"},
|
||||
{"id": "2.5", "text": "Does NOT have the implementation package import the consumer's interface"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "container-not-passed-as-dependency",
|
||||
"description": "Tests that the DI container is never passed as a dependency (service locator anti-pattern)",
|
||||
"prompt": "I'm using samber/do for DI in my Go project. My UserService needs a Database and a Mailer. Should I pass the do.Injector to UserService so it can look up what it needs?",
|
||||
"trap": "Without the skill, the model may accept passing the injector/container as a dependency. The skill explicitly forbids this as the service locator anti-pattern.",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Advises against passing the injector/container as a dependency"},
|
||||
{"id": "3.2", "text": "Identifies this as the service locator anti-pattern"},
|
||||
{"id": "3.3", "text": "Shows that the Injector should only exist at the composition root (main or app startup)"},
|
||||
{"id": "3.4", "text": "Shows UserService receiving Database and Mailer directly as constructor parameters"},
|
||||
{"id": "3.5", "text": "Shows the provider function using do.MustInvoke inside the provider, not inside UserService methods"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "manual-di-for-small-projects",
|
||||
"description": "Tests that small projects use manual DI, not a library",
|
||||
"prompt": "I'm building a small Go REST API with about 5 services: config, database, user repository, user service, and HTTP handler. What DI approach should I use?",
|
||||
"trap": "Without the skill, the model may recommend a DI library like Wire or Fx for any project. The skill says small projects (< 10 services) should use manual constructor injection.",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Recommends manual constructor injection for a project with only 5 services"},
|
||||
{"id": "4.2", "text": "Does NOT recommend a DI library as the primary approach"},
|
||||
{"id": "4.3", "text": "Shows wiring in main() with explicit constructor calls in dependency order"},
|
||||
{"id": "4.4", "text": "Initializes infrastructure first, then repositories, then services, then transport"},
|
||||
{"id": "4.5", "text": "Mentions that a DI library becomes worthwhile at 10-20+ services"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "di-library-selection-judgment",
|
||||
"description": "Tests correct DI library recommendation based on project characteristics",
|
||||
"prompt": "I'm building a large Go microservice with 40+ services, complex lifecycle management (health checks, graceful shutdown), and I want compile-time type safety. My team is comfortable with Go generics. Which DI library should I use?",
|
||||
"trap": "Without the skill, the model may recommend uber-go/fx (most popular) or google/wire. The skill's decision table shows samber/do matches all these criteria: any size, compile-time generics, built-in lifecycle, health checks, shutdown.",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Recommends samber/do as a strong fit given the criteria (generics, lifecycle, compile-time safety)"},
|
||||
{"id": "5.2", "text": "Explains why uber-go/fx is a valid alternative but uses reflection (runtime errors, not compile-time)"},
|
||||
{"id": "5.3", "text": "Explains why google/wire lacks built-in lifecycle management (no health checks, no shutdown)"},
|
||||
{"id": "5.4", "text": "Mentions that samber/do requires Go 1.18+ for generics"},
|
||||
{"id": "5.5", "text": "Discusses at least 3 DI library options from the decision table"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "wire-build-constraint-and-codegen",
|
||||
"description": "Tests proper google/wire setup with wireinject build constraint",
|
||||
"prompt": "Set up google/wire for a Go application with Config, Database, UserStore, and UserService. Show the wire.go file and explain what happens when I run wire.",
|
||||
"trap": "Without the skill, the model may forget the //go:build wireinject build constraint or not explain that wire.Build generates plain Go constructors.",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Includes //go:build wireinject build constraint in the wire.go file"},
|
||||
{"id": "6.2", "text": "Uses wire.Build with all provider functions listed"},
|
||||
{"id": "6.3", "text": "Shows wire.Bind for binding interface to implementation"},
|
||||
{"id": "6.4", "text": "Explains that wire generates wire_gen.go with plain constructor calls"},
|
||||
{"id": "6.5", "text": "Mentions that wire_gen.go must not be edited manually"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "fx-lifecycle-hooks-pattern",
|
||||
"description": "Tests proper uber-go/fx lifecycle hook usage for startup/shutdown",
|
||||
"prompt": "Set up a database connection using uber-go/fx that connects on startup and cleanly closes on shutdown.",
|
||||
"trap": "Without the skill, the model may open and close the connection manually instead of using fx.Lifecycle hooks. The skill requires OnStart/OnStop hooks.",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses fx.Lifecycle parameter in the provider function"},
|
||||
{"id": "7.2", "text": "Registers OnStart hook for establishing the database connection"},
|
||||
{"id": "7.3", "text": "Registers OnStop hook for closing the database connection"},
|
||||
{"id": "7.4", "text": "Uses lc.Append(fx.Hook{...}) pattern"},
|
||||
{"id": "7.5", "text": "OnStart and OnStop take context.Context as parameter"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "testing-with-di-mock-injection",
|
||||
"description": "Tests that DI enables testing by injecting mocks at the interface boundary",
|
||||
"prompt": "Write a test for a UserService that depends on a UserStore interface. The test should verify that GetUser returns the correct user when found and an error when not found. Don't use any DI library.",
|
||||
"trap": "Without the skill, the model may test with a real database or skip the mock pattern. The skill requires mocking at the interface boundary.",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Creates a mock implementation of the UserStore interface"},
|
||||
{"id": "8.2", "text": "Injects the mock into UserService via the constructor (NewUserService)"},
|
||||
{"id": "8.3", "text": "Tests both the success path (user found) and the error path (not found)"},
|
||||
{"id": "8.4", "text": "Does NOT use a real database connection in the test"},
|
||||
{"id": "8.5", "text": "The mock is defined in the test file, not as a package-level or global variable"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "shallow-dependency-graph",
|
||||
"description": "Tests that deep dependency chains are flagged as a design problem",
|
||||
"prompt": "My Go application has this dependency chain: Config -> Database -> UserRepo -> UserService -> NotificationService -> OrderService -> PaymentService -> APIHandler. Is this a good architecture?",
|
||||
"trap": "Without the skill, the model may accept this deep chain as normal layered architecture. The skill says deep chains signal design problems and recommends flattening.",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Identifies the deep dependency chain as a design problem"},
|
||||
{"id": "9.2", "text": "Recommends flattening the dependency graph"},
|
||||
{"id": "9.3", "text": "Suggests that most services should depend on repositories and config directly, not transitively through other services"},
|
||||
{"id": "9.4", "text": "Explains the negative consequences of deep chains (fragility, hard to test, or hard to maintain)"},
|
||||
{"id": "9.5", "text": "Proposes a concrete restructuring where OrderService and PaymentService don't depend on each other transitively"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "one-container-per-app-not-per-request",
|
||||
"description": "Tests that a DI container is created once per application, not per request",
|
||||
"prompt": "I'm building a Go HTTP API with samber/do. In each HTTP handler, I'm creating a new do.New() injector, providing all services, then invoking the one I need. This way each request gets fresh services. Is this correct?",
|
||||
"trap": "Without the skill, the model may accept the per-request container pattern as reasonable isolation. The skill explicitly forbids creating a new container per request.",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Identifies creating a new container per request as a mistake"},
|
||||
{"id": "10.2", "text": "Recommends one container per application created at startup"},
|
||||
{"id": "10.3", "text": "Explains the performance or correctness problem with per-request containers (recreating singletons, no connection reuse)"},
|
||||
{"id": "10.4", "text": "Suggests using scopes for request-level isolation if needed"},
|
||||
{"id": "10.5", "text": "Shows the container being created once in main() and services injected into handlers"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "lazy-vs-eager-initialization",
|
||||
"description": "Tests knowledge of lazy initialization preference and singleton vs transient distinction",
|
||||
"prompt": "When using a DI container in Go, should all my services be created at application startup? I have a database pool, a cache client, and some request processing services.",
|
||||
"trap": "Without the skill, the model may recommend eager initialization for everything. The skill prefers lazy initialization and distinguishes singletons for stateful services from transients for stateless ones.",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Recommends lazy initialization (services created on first use, not all at startup)"},
|
||||
{"id": "11.2", "text": "Recommends singletons for stateful services like database connections and cache clients"},
|
||||
{"id": "11.3", "text": "Recommends transients (or factories) for stateless request processing services"},
|
||||
{"id": "11.4", "text": "Explains why lazy loading is beneficial (unused services are never created, faster startup)"},
|
||||
{"id": "11.5", "text": "Notes which DI libraries support lazy loading (samber/do, fx) vs which don't (wire is all eager)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
# google/wire — Compile-Time Code Generation
|
||||
|
||||
Wire uses code generation to resolve the dependency graph at compile time. Type-safe, but requires a build step.
|
||||
|
||||
- Docs: [github.com/google/wire](https://github.com/google/wire) | [User Guide](https://github.com/google/wire/blob/main/docs/guide.md)
|
||||
|
||||
Before writing Wire code, refer to the library's official documentation for up-to-date API signatures and examples.
|
||||
|
||||
## Provider Definitions
|
||||
|
||||
```go
|
||||
// providers.go
|
||||
package wire
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
// ProviderSet groups related providers
|
||||
var InfraSet = wire.NewSet(
|
||||
NewConfig,
|
||||
NewDatabase,
|
||||
NewCache,
|
||||
)
|
||||
|
||||
var ServiceSet = wire.NewSet(
|
||||
NewUserService,
|
||||
wire.Bind(new(UserStore), new(*PostgresUserStore)), // bind interface to impl
|
||||
)
|
||||
```
|
||||
|
||||
## Injector Definition
|
||||
|
||||
```go
|
||||
// wire.go — build constraint ensures this is only used by the wire tool
|
||||
//go:build wireinject
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
func InitializeApp() (*App, error) {
|
||||
wire.Build(
|
||||
InfraSet,
|
||||
ServiceSet,
|
||||
NewApp,
|
||||
)
|
||||
return nil, nil // wire replaces this body
|
||||
}
|
||||
```
|
||||
|
||||
## Generated Code
|
||||
|
||||
Run `wire ./...` to produce `wire_gen.go`:
|
||||
|
||||
```go
|
||||
// wire_gen.go — DO NOT EDIT (auto-generated by wire)
|
||||
func InitializeApp() (*App, error) {
|
||||
config := NewConfig()
|
||||
database, err := NewDatabase(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache := NewCache(config)
|
||||
store := NewPostgresUserStore(database)
|
||||
userService := NewUserService(store, cache)
|
||||
app := NewApp(userService)
|
||||
return app, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Wire generates plain constructors, so testing uses manual injection — no container to clone:
|
||||
|
||||
```go
|
||||
func TestUserService(t *testing.T) {
|
||||
mock := &MockUserStore{...}
|
||||
svc := NewUserService(mock, NewTestCache())
|
||||
// ... test
|
||||
}
|
||||
```
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
- Errors caught at compile time (codegen fails if graph is incomplete)
|
||||
- Requires running `wire ./...` after every dependency change
|
||||
- No lazy loading — all dependencies created eagerly
|
||||
- No built-in lifecycle management (health checks, shutdown)
|
||||
- No runtime container — wire generates plain Go constructor calls
|
||||
- Interface bindings require explicit `wire.Bind` declarations
|
||||
- Generated files (`wire_gen.go`) must be committed and kept in sync
|
||||
|
||||
Wire injectors MUST use `//go:build wireinject` build constraint. Generated `wire_gen.go` MUST NOT be edited manually — always regenerate with `wire ./...`.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Manual Constructor Injection
|
||||
|
||||
Manual DI is the simplest approach — pass dependencies through constructors. No library, no magic.
|
||||
|
||||
## Complete Application Example
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
// Layer 1: Configuration
|
||||
cfg := LoadConfig()
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
|
||||
// Layer 2: Infrastructure
|
||||
db, err := postgres.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache := redis.NewClient(cfg.RedisURL)
|
||||
defer cache.Close()
|
||||
|
||||
mailer := smtp.NewMailer(cfg.SMTPAddr)
|
||||
|
||||
// Layer 3: Repositories
|
||||
userRepo := postgres.NewUserRepository(db)
|
||||
orderRepo := postgres.NewOrderRepository(db)
|
||||
|
||||
// Layer 4: Services
|
||||
userSvc := service.NewUserService(userRepo, cache, mailer, logger)
|
||||
orderSvc := service.NewOrderService(orderRepo, userSvc, logger)
|
||||
paymentSvc := service.NewPaymentService(orderRepo, cfg.StripeKey, logger)
|
||||
|
||||
// Layer 5: Transport
|
||||
handler := http.NewHandler(userSvc, orderSvc, paymentSvc, logger)
|
||||
server := http.NewServer(cfg.Port, handler)
|
||||
|
||||
// Run
|
||||
go server.ListenAndServe()
|
||||
<-ctx.Done()
|
||||
server.Shutdown(context.Background())
|
||||
}
|
||||
```
|
||||
|
||||
## When Manual DI Works Well
|
||||
|
||||
- Small to medium projects (< 15 services)
|
||||
- Simple dependency graph with clear layering
|
||||
- No need for lazy loading or lifecycle management
|
||||
- Team prefers explicit, visible wiring
|
||||
|
||||
## When Manual DI Breaks Down
|
||||
|
||||
- Adding a new service means editing `main()` and getting the wiring order right
|
||||
- Lifecycle management (health checks, graceful shutdown) must be hand-coded with `defer`
|
||||
- No lazy initialization — all services are created at startup, even if unused
|
||||
- Cross-cutting concerns (logging, tracing) must be threaded through every constructor
|
||||
- With 30+ services, the wiring code becomes fragile and hard to maintain
|
||||
|
||||
Manual DI SHOULD be the default for small projects (< 15 services). Dependencies MUST be initialized in order — infrastructure first, then repositories, then services, then transport.
|
||||
@@ -0,0 +1,36 @@
|
||||
# samber/do — Generics-Based DI
|
||||
|
||||
> **For the full samber/do API, patterns, and advanced features, see the `samber/cc-skills-golang@golang-samber-do` skill.**
|
||||
|
||||
Type-safe dependency injection using Go generics. No reflection, no code generation, simple API.
|
||||
|
||||
- Docs: [do.samber.dev](https://do.samber.dev) | [github.com/samber/do/v2](https://github.com/samber/do)
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```go
|
||||
// Register services with providers
|
||||
injector := do.New()
|
||||
do.Provide(injector, func(i do.Injector) (*UserService, error) {
|
||||
db := do.MustInvoke[*Database](i)
|
||||
return NewUserService(db), nil
|
||||
})
|
||||
|
||||
// Invoke services (lazy — created on demand)
|
||||
svc := do.MustInvoke[*UserService](injector)
|
||||
|
||||
// Graceful shutdown — all services implementing Shutdowner are closed
|
||||
injector.ShutdownOnSignalsWithContext(ctx, os.Interrupt)
|
||||
```
|
||||
|
||||
## Why samber/do
|
||||
|
||||
- **No code generation** — no build step, no generated files to maintain
|
||||
- **No reflection** — errors are caught at compile time via generics, not at runtime
|
||||
- **Strongly typed** — Go generics provide full type safety without `interface{}` casts
|
||||
- **Built-in lifecycle** — health checks and graceful shutdown detected automatically
|
||||
- **Container cloning** — create isolated test containers from production configuration
|
||||
- **Simple API** — `Provide`, `Invoke`, `Shutdown` — that's most of what you need
|
||||
- **Package system** — organize services by domain without manual wiring order
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-samber-do` for full application setup, package organization, lifecycle management, debugging, testing with clone + override, and complete API reference.
|
||||
@@ -0,0 +1,142 @@
|
||||
# uber-go/dig + uber-go/fx — Reflection-Based DI
|
||||
|
||||
`dig` is the low-level DI container; `fx` is the full application framework built on top. Powerful but uses reflection — errors appear at startup, not compile time.
|
||||
|
||||
- Docs: [github.com/uber-go/dig](https://github.com/uber-go/dig) | [uber-go.github.io/fx](https://uber-go.github.io/fx/)
|
||||
|
||||
Before writing dig/fx code, refer to the library's official documentation for up-to-date API signatures and examples.
|
||||
|
||||
## dig — Basic Container
|
||||
|
||||
```go
|
||||
func main() {
|
||||
container := dig.New()
|
||||
|
||||
container.Provide(NewConfig)
|
||||
container.Provide(NewDatabase)
|
||||
container.Provide(NewUserStore)
|
||||
container.Provide(NewUserService)
|
||||
|
||||
// Invoke — dig resolves the full dependency chain
|
||||
err := container.Invoke(func(svc *UserService) {
|
||||
svc.Run()
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Named Dependencies
|
||||
|
||||
```go
|
||||
type DatabaseParams struct {
|
||||
dig.In
|
||||
|
||||
Primary *sql.DB `name:"primary"`
|
||||
Replica *sql.DB `name:"replica"`
|
||||
}
|
||||
|
||||
container.Provide(NewPrimaryDB, dig.Name("primary"))
|
||||
container.Provide(NewReplicaDB, dig.Name("replica"))
|
||||
|
||||
container.Provide(func(p DatabaseParams) *UserService {
|
||||
return &UserService{
|
||||
writer: p.Primary,
|
||||
reader: p.Replica,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### dig Tradeoffs
|
||||
|
||||
- Uses reflection — type mismatches are runtime errors, not compile errors
|
||||
- `dig.In` and `dig.Out` structs add boilerplate for complex graphs
|
||||
- No built-in lifecycle management
|
||||
- Powerful grouping with `dig.Group` for collecting multiple implementations
|
||||
|
||||
## fx — Full Application Framework
|
||||
|
||||
### Basic Application
|
||||
|
||||
```go
|
||||
func main() {
|
||||
app := fx.New(
|
||||
fx.Provide(
|
||||
NewConfig,
|
||||
NewDatabase,
|
||||
NewUserStore,
|
||||
NewUserService,
|
||||
),
|
||||
fx.Invoke(RegisterRoutes),
|
||||
fx.Invoke(StartServer),
|
||||
)
|
||||
|
||||
app.Run() // blocks until signal, then calls shutdown hooks
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```go
|
||||
func NewDatabase(lc fx.Lifecycle, cfg *Config) (*Database, error) {
|
||||
db := &Database{}
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(ctx context.Context) error {
|
||||
return db.Connect(cfg.URL)
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return db.Close()
|
||||
},
|
||||
})
|
||||
|
||||
return db, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Modules
|
||||
|
||||
```go
|
||||
var InfraModule = fx.Module("infra",
|
||||
fx.Provide(NewConfig),
|
||||
fx.Provide(NewDatabase),
|
||||
fx.Provide(NewCache),
|
||||
)
|
||||
|
||||
var ServiceModule = fx.Module("service",
|
||||
fx.Provide(NewUserService),
|
||||
fx.Provide(NewOrderService),
|
||||
)
|
||||
|
||||
app := fx.New(InfraModule, ServiceModule, fx.Invoke(StartServer))
|
||||
```
|
||||
|
||||
### Testing with fx
|
||||
|
||||
```go
|
||||
func TestUserService(t *testing.T) {
|
||||
var svc *UserService
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(NewMockUserStore),
|
||||
fx.Provide(NewUserService),
|
||||
fx.Populate(&svc),
|
||||
)
|
||||
app.RequireStart()
|
||||
defer app.RequireStop()
|
||||
|
||||
// ... test svc
|
||||
}
|
||||
```
|
||||
|
||||
### fx Tradeoffs
|
||||
|
||||
- Full application framework — manages startup, shutdown, and signal handling
|
||||
- Reflection-based — errors at startup, not compile time
|
||||
- Steep learning curve — `fx.In`, `fx.Out`, `fx.Annotate`, `fx.Decorate`
|
||||
- Built-in lifecycle (OnStart/OnStop hooks)
|
||||
- Heavyweight — pulls in the full fx framework
|
||||
- `fxtest` package for testing, but requires starting/stopping the app
|
||||
|
||||
fx lifecycle hooks MUST be used for start/stop — register `OnStart`/`OnStop` via `fx.Lifecycle`. fx modules SHOULD group related providers — use `fx.Module` to organize by domain.
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: golang-dependency-management
|
||||
description: Provides dependency management strategies for Golang projects including go.mod management, installing/upgrading packages, semantic versioning, Minimal Version Selection, vulnerability scanning, outdated dependency tracking, dependency size analysis, automated updates with Dependabot/Renovate, conflict resolution, and dependency graph visualization. Use this skill whenever adding, removing, updating, or auditing Go dependencies, resolving version conflicts, setting up automated dependency updates, analyzing binary size, or working with go.work workspaces.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent Bash(govulncheck:*)
|
||||
---
|
||||
|
||||
**Persona:** You are a Go dependency steward. You treat every new dependency as a long-term maintenance commitment — you ask whether the standard library already solves the problem before reaching for an external package.
|
||||
|
||||
# Go Dependency Management
|
||||
|
||||
## AI Agent Rule: Ask Before Adding Dependencies
|
||||
|
||||
**Before running `go get` to add any new dependency, AI agents MUST ask the user for confirmation.** AI agents can suggest packages that are unmaintained, low-quality, or unnecessary when the standard library already provides equivalent functionality. Using `go get -u` to upgrade an existing dependency is safe.
|
||||
|
||||
Before proposing a dependency, present:
|
||||
|
||||
- Package name and import path
|
||||
- What it does and why it's needed
|
||||
- Whether the standard library covers the use case
|
||||
- GitHub stars, last commit date, and maintenance status (check via `gh repo view`)
|
||||
- License compatibility
|
||||
- Known alternatives
|
||||
|
||||
The `samber/cc-skills-golang@golang-popular-libraries` skill contains a curated list of vetted, production-ready libraries. Prefer recommending packages from that list. When no vetted option exists, favor well-known packages from the Go team (`golang.org/x/...`) or established organizations over obscure alternatives.
|
||||
|
||||
## Key Rules
|
||||
|
||||
- `go.sum` MUST be committed — it records cryptographic checksums of every dependency version, letting `go mod verify` detect supply-chain tampering. Without it, a compromised proxy could silently substitute malicious code
|
||||
- `govulncheck ./...` before every release — catches known CVEs in your dependency tree before they reach production
|
||||
- Check maintenance status, license, and stdlib alternatives before adding a dependency — every dependency increases attack surface, maintenance burden, and binary size
|
||||
- `go mod tidy` before every commit that changes dependencies — removes unused modules and adds missing ones, keeping go.mod honest
|
||||
|
||||
## go.mod & go.sum
|
||||
|
||||
### Essential Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| ----------------- | -------------------------------------------- |
|
||||
| `go mod tidy` | Add missing deps, remove unused ones |
|
||||
| `go mod download` | Download modules to local cache |
|
||||
| `go mod verify` | Verify cached modules match go.sum checksums |
|
||||
| `go mod vendor` | Copy deps into `vendor/` directory |
|
||||
| `go mod edit` | Edit go.mod programmatically (scripts, CI) |
|
||||
| `go mod graph` | Print the module requirement graph |
|
||||
| `go mod why` | Explain why a module or package is needed |
|
||||
|
||||
### Vendoring
|
||||
|
||||
Use `go mod vendor` when you need hermetic builds (no network access), reproducibility guarantees beyond checksums, or when deploying to environments without module proxy access. CI pipelines and Docker builds sometimes benefit from vendoring. Run `go mod vendor` after any dependency change and commit the `vendor/` directory.
|
||||
|
||||
## Installing & Upgrading Dependencies
|
||||
|
||||
### Adding a Dependency
|
||||
|
||||
```bash
|
||||
go get github.com/pkg/errors # Latest version
|
||||
go get github.com/pkg/errors@v0.9.1 # Specific version
|
||||
go get github.com/pkg/errors@latest # Explicitly latest
|
||||
go get github.com/pkg/errors@master # Specific branch (pseudo-version)
|
||||
```
|
||||
|
||||
### Upgrading
|
||||
|
||||
```bash
|
||||
go get -u ./... # Upgrade ALL direct+indirect deps to latest minor/patch
|
||||
go get -u=patch ./... # Upgrade to latest patch only (safer)
|
||||
go get github.com/pkg@v1.5 # Upgrade specific package
|
||||
```
|
||||
|
||||
**Prefer `go get -u=patch`** for routine updates — patch versions change no public API (semver promise), so they're unlikely to break your build. Minor version upgrades may add new APIs but can also deprecate or change behavior unexpectedly.
|
||||
|
||||
### Removing a Dependency
|
||||
|
||||
```bash
|
||||
go get github.com/pkg/errors@none # Mark for removal
|
||||
go mod tidy # Clean up go.mod and go.sum
|
||||
```
|
||||
|
||||
### Installing CLI Tools
|
||||
|
||||
```bash
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
```
|
||||
|
||||
`go install` builds and installs a binary to `$GOPATH/bin`. Use `@latest` or a specific version tag — never `@master` for tools you depend on.
|
||||
|
||||
### The tools.go Pattern
|
||||
|
||||
Pin tool versions in your module without importing them in production code:
|
||||
|
||||
```go
|
||||
//go:build tools
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
|
||||
_ "golang.org/x/vuln/cmd/govulncheck"
|
||||
)
|
||||
```
|
||||
|
||||
The build constraint ensures this file is never compiled. The blank imports keep the tools in `go.mod` so `go install` uses the pinned version. Run `go mod tidy` after creating this file.
|
||||
|
||||
## Deep Dives
|
||||
|
||||
- **[Versioning & MVS](./references/versioning.md)** — Semantic versioning rules (major.minor.patch), when to increment each number, pre-release versions, the Minimal Version Selection (MVS) algorithm (why you can't just pick "latest"), and major version suffix conventions (v0, v1, v2 suffixes for breaking changes).
|
||||
|
||||
- **[Auditing Dependencies](./references/auditing.md)** — Vulnerability scanning with `govulncheck`, tracking outdated dependencies, analyzing which dependencies make the binary large (`goweight`), and distinguishing test-only vs binary dependencies to keep `go.mod` clean.
|
||||
|
||||
- **[Dependency Conflicts & Resolution](./references/conflicts.md)** — Diagnosing version conflicts (what `go get` does when you request incompatible versions), resolution strategies (`replace` directives for local development, `exclude` for broken versions, `retract` for published versions that should be skipped), and workflows for conflicts across your dependency tree.
|
||||
|
||||
- **[Go Workspaces](./references/workspaces.md)** — `go.work` files for multi-module development (e.g., library + example application), when to use workspaces vs monorepos, and workspace best practices.
|
||||
|
||||
- **[Automated Dependency Updates](./references/automated-updates.md)** — Setting up Dependabot or Renovate for automatic dependency update PRs, auto-merge strategies (when to merge automatically vs require review), and handling security updates.
|
||||
|
||||
- **[Visualizing the Dependency Graph](./references/visualization.md)** — `go mod graph` to inspect the full dependency tree, `modgraphviz` to visualize it, and interactive tools to find which dependency chains cause bloat.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-continuous-integration` skill for Dependabot/Renovate CI setup
|
||||
- → See `samber/cc-skills-golang@golang-security` skill for vulnerability scanning with govulncheck
|
||||
- → See `samber/cc-skills-golang@golang-popular-libraries` skill for vetted library recommendations
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Start a new module
|
||||
go mod init github.com/user/project
|
||||
|
||||
# Add a dependency
|
||||
go get github.com/pkg/errors@v0.9.1
|
||||
|
||||
# Upgrade all deps (patch only, safer)
|
||||
go get -u=patch ./...
|
||||
|
||||
# Remove unused deps
|
||||
go mod tidy
|
||||
|
||||
# Check for vulnerabilities
|
||||
govulncheck ./...
|
||||
|
||||
# Check for outdated deps
|
||||
go list -u -m -json all | go-mod-outdated -update -direct
|
||||
|
||||
# Analyze binary size by dependency
|
||||
goweight
|
||||
|
||||
# Understand why a dep exists
|
||||
go mod why -m github.com/some/module
|
||||
|
||||
# Visualize dependency graph
|
||||
go mod graph | modgraphviz | dot -Tpng -o deps.png
|
||||
|
||||
# Verify checksums
|
||||
go mod verify
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "ask-before-adding-dependency",
|
||||
"description": "Tests whether the AI agent asks for user confirmation before adding a new dependency via go get",
|
||||
"prompt": "I need to parse YAML configuration files in my Go project. Add the dependency and write the config parsing code.",
|
||||
"trap": "Without the skill, the model immediately runs go get for a YAML library without asking the user for confirmation, presenting alternatives, or checking stdlib",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Asks the user for confirmation before running go get to add a new dependency"},
|
||||
{"id": "1.2", "text": "Presents the package name and import path"},
|
||||
{"id": "1.3", "text": "Mentions whether the standard library covers the use case (encoding/json does, but YAML does not have stdlib support)"},
|
||||
{"id": "1.4", "text": "Lists known alternatives (e.g., gopkg.in/yaml.v3, github.com/goccy/go-yaml)"},
|
||||
{"id": "1.5", "text": "Does NOT silently run go get without asking first"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "go-sum-must-be-committed",
|
||||
"description": "Tests whether the model insists go.sum must be committed to version control",
|
||||
"prompt": "I'm setting up a new Go project. My .gitignore currently includes go.sum because it's auto-generated and I don't want to clutter the repo with generated files. Is this okay?",
|
||||
"trap": "Without the skill, the model might agree that auto-generated files can be gitignored, missing that go.sum is critical for supply-chain security",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Strongly advises against gitignoring go.sum"},
|
||||
{"id": "2.2", "text": "Explains that go.sum contains cryptographic checksums for dependency verification"},
|
||||
{"id": "2.3", "text": "Explains the supply-chain security risk: without go.sum, a compromised proxy could substitute malicious code"},
|
||||
{"id": "2.4", "text": "Mentions go mod verify as the mechanism that uses go.sum for integrity checking"},
|
||||
{"id": "2.5", "text": "Recommends removing go.sum from .gitignore"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "patch-only-upgrade-preference",
|
||||
"description": "Tests whether the model prefers go get -u=patch over go get -u for routine updates",
|
||||
"prompt": "I want to update all my Go dependencies to the latest versions. What command should I run?",
|
||||
"trap": "Without the skill, the model suggests go get -u ./... which upgrades to latest minor/patch, potentially introducing breaking behavioral changes",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Recommends go get -u=patch ./... as the safer default for routine updates"},
|
||||
{"id": "3.2", "text": "Explains that -u=patch only upgrades patch versions which have no API changes per semver"},
|
||||
{"id": "3.3", "text": "Explains that -u (without =patch) upgrades minor versions too, which can change behavior"},
|
||||
{"id": "3.4", "text": "Mentions running go mod tidy after upgrading"},
|
||||
{"id": "3.5", "text": "Does NOT recommend go get -u ./... without warning about the risk of minor version upgrades"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "mvs-algorithm-understanding",
|
||||
"description": "Tests understanding of Minimal Version Selection — Go selects the minimum satisfying version, not the latest",
|
||||
"prompt": "In my Go project, module A requires pkg@v1.2.0 and module B requires pkg@v1.3.0. My go.mod does not mention pkg directly. Which version of pkg will Go select and why?",
|
||||
"trap": "Without the skill, the model might say Go selects the latest available version of pkg (like npm/pip would), rather than the minimum required version (v1.3.0)",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Correctly states that Go selects v1.3.0 (not the latest available version)"},
|
||||
{"id": "4.2", "text": "Explains Minimal Version Selection (MVS): Go picks the highest minimum required, not the latest available"},
|
||||
{"id": "4.3", "text": "Distinguishes MVS from other package managers (npm, pip, cargo) that select the latest compatible"},
|
||||
{"id": "4.4", "text": "Mentions that MVS provides deterministic builds without a lock file"},
|
||||
{"id": "4.5", "text": "Explains that go.sum is integrity verification, not version locking"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "major-version-suffix-rule",
|
||||
"description": "Tests knowledge of Go's major version suffix convention for v2+",
|
||||
"prompt": "I'm publishing a Go library and need to release v2.0.0 with breaking changes. What do I need to change in my module path and imports?",
|
||||
"trap": "Without the skill, the model might just change the git tag to v2.0.0 without updating the module path to include /v2, breaking the import compatibility rule",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "States that the module path in go.mod must include /v2 suffix (e.g., github.com/example/pkg/v2)"},
|
||||
{"id": "5.2", "text": "States that all import paths must be updated to include /v2"},
|
||||
{"id": "5.3", "text": "Explains this is Go's import compatibility rule — different major versions are separate modules"},
|
||||
{"id": "5.4", "text": "Mentions that v0 and v1 do NOT have a suffix"},
|
||||
{"id": "5.5", "text": "Notes that this allows v1 and v2 to coexist in the same build"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "replace-directive-library-warning",
|
||||
"description": "Tests that the model warns about replace directives being ignored when the module is used as a dependency",
|
||||
"prompt": "I'm developing a Go library and I need to use a fork of one of my dependencies for a bug fix. I added a replace directive in my go.mod. Will consumers of my library use the fork too?",
|
||||
"trap": "Without the skill, the model might say yes, missing that replace directives only apply in the main module and are ignored when used as a dependency",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Clearly states that replace directives only take effect in the main module's go.mod"},
|
||||
{"id": "6.2", "text": "States that consumers of the library will NOT use the fork — replace is ignored when the module is consumed as a dependency"},
|
||||
{"id": "6.3", "text": "Recommends removing replace directives before publishing a library"},
|
||||
{"id": "6.4", "text": "Suggests alternative solutions (e.g., upstream the fix, publish the fork as a separate module)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "tools-go-pattern",
|
||||
"description": "Tests whether the model knows the tools.go pattern for pinning CLI tool versions in go.mod",
|
||||
"prompt": "My Go project uses golangci-lint and govulncheck. I want to ensure all developers and CI use the exact same versions of these tools. How do I pin them?",
|
||||
"trap": "Without the skill, the model suggests go install @latest in CI or a Makefile, missing the tools.go pattern that pins versions via go.mod",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Recommends the tools.go pattern (a file with //go:build tools constraint)"},
|
||||
{"id": "7.2", "text": "Uses blank imports (_ imports) to keep tools in go.mod"},
|
||||
{"id": "7.3", "text": "The build constraint ensures the file is never compiled into production code"},
|
||||
{"id": "7.4", "text": "Mentions running go mod tidy after creating the tools.go file"},
|
||||
{"id": "7.5", "text": "Explains that go install then uses the pinned version from go.mod"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "govulncheck-call-path-analysis",
|
||||
"description": "Tests understanding that govulncheck does static analysis to find actually-called vulnerable functions, not just dependency presence",
|
||||
"prompt": "My Go project has a dependency flagged by a CVE scanner. But I only use a small subset of the library's API. Is there a way to check if the vulnerability actually affects my code?",
|
||||
"trap": "Without the skill, the model suggests just upgrading the dependency or manually reviewing the CVE, missing govulncheck's call-path analysis that filters by actual usage",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Recommends govulncheck as the tool to check if the vulnerability is actually reachable from your code"},
|
||||
{"id": "8.2", "text": "Explains that govulncheck uses static analysis to trace call paths to vulnerable functions"},
|
||||
{"id": "8.3", "text": "Explains that if your code never calls the affected function, govulncheck will NOT flag it"},
|
||||
{"id": "8.4", "text": "Shows the govulncheck ./... command"},
|
||||
{"id": "8.5", "text": "Distinguishes govulncheck from generic CVE scanners that flag any dependency presence regardless of usage"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "go-work-sum-gitignore",
|
||||
"description": "Tests that go.work.sum should not be committed while go.sum should be committed",
|
||||
"prompt": "I'm setting up a Go workspace with go.work for local multi-module development. Which workspace files should I commit to git?",
|
||||
"trap": "Without the skill, the model might treat go.work.sum the same as go.sum (commit both), but go.work.sum should NOT be committed",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "States that go.work.sum should NOT be committed to version control"},
|
||||
{"id": "9.2", "text": "Recommends adding go.work.sum to .gitignore"},
|
||||
{"id": "9.3", "text": "Explains that go.work is for development only and does not affect published module consumers"},
|
||||
{"id": "9.4", "text": "Distinguishes this from go.sum which MUST be committed"},
|
||||
{"id": "9.5", "text": "May mention that go.work itself can optionally be committed depending on team preference"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "exclude-vs-retract-distinction",
|
||||
"description": "Tests understanding of the difference between exclude (consumer-side) and retract (author-side) directives",
|
||||
"prompt": "I published a Go library version v1.3.0 that has a critical bug. How do I prevent users from downloading it? Also, one of my dependencies has a buggy version — how do I skip it in my project?",
|
||||
"trap": "Without the skill, the model conflates exclude and retract, or uses them interchangeably",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Uses retract for the published library (author-side: marks own version as broken)"},
|
||||
{"id": "10.2", "text": "Uses exclude for the buggy dependency (consumer-side: skips a specific version of someone else's module)"},
|
||||
{"id": "10.3", "text": "Explains that retract goes in the library's own go.mod and warns users via go list"},
|
||||
{"id": "10.4", "text": "Explains that exclude redirects to the next higher available version"},
|
||||
{"id": "10.5", "text": "Notes that retracted versions are still downloadable but not selected by default"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "test-dependency-upgrade-flag",
|
||||
"description": "Tests knowledge of the -t flag for including test dependencies in upgrades",
|
||||
"prompt": "I ran go get -u ./... to upgrade my Go dependencies, but my test dependencies (like testify) weren't upgraded. Why?",
|
||||
"trap": "Without the skill, the model doesn't know about the -t flag and suggests upgrading test deps individually",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Explains that go get -u ./... excludes test-only dependencies by default"},
|
||||
{"id": "11.2", "text": "Recommends go get -u -t ./... to include test dependencies in the upgrade"},
|
||||
{"id": "11.3", "text": "Explains the difference between -u (production deps) and -u -t (production + test deps)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
# Auditing Dependencies
|
||||
|
||||
## Test-Only vs Binary Dependencies
|
||||
|
||||
Go's `go.mod` does **not** distinguish between test-only and production dependencies. All modules appear together, with `// indirect` marking transitive dependencies.
|
||||
|
||||
### What Gets Included in Your Binary
|
||||
|
||||
- `*_test.go` files are **never** compiled by `go build` — only by `go test`
|
||||
- Packages imported only by test files are not linked into the final binary
|
||||
- However, their modules still appear in `go.mod`
|
||||
|
||||
### Module Graph Pruning (Go 1.17+)
|
||||
|
||||
With `go 1.17` or higher in `go.mod`, Go prunes the module graph: transitive dependencies needed only for tests of other modules are excluded from the build graph. This reduces `go.mod` size and avoids downloading unnecessary modules.
|
||||
|
||||
### Upgrading With or Without Test Dependencies
|
||||
|
||||
```bash
|
||||
go get -u ./... # Upgrade deps, EXCLUDING test-only deps
|
||||
go get -u -t ./... # Upgrade deps, INCLUDING test-only deps
|
||||
```
|
||||
|
||||
### Impact on Binary Size
|
||||
|
||||
To check whether a large dependency is actually linked into your binary (vs. only used in tests), use `goweight` or `go-size-analyzer` — if the package doesn't appear in the binary breakdown, it's test-only and not contributing to binary size.
|
||||
|
||||
## Vulnerability Scanning with govulncheck
|
||||
|
||||
`govulncheck` reports known vulnerabilities that affect your code. It uses static analysis to narrow reports to vulnerabilities in code paths your project actually calls — unlike generic CVE scanners that flag every dependency regardless of usage.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
# Scan source code (most common)
|
||||
govulncheck ./...
|
||||
|
||||
# Scan a compiled binary
|
||||
govulncheck -mode=binary ./bin/myapp
|
||||
|
||||
# JSON output (for CI integration)
|
||||
govulncheck -format json ./...
|
||||
|
||||
# Include test code in analysis
|
||||
govulncheck -test ./...
|
||||
```
|
||||
|
||||
Output shows the vulnerability ID, affected module, fixed version, and the call trace from your code to the vulnerable function. If a vulnerability exists in a dependency but your code never calls the affected function, `govulncheck` does not flag it.
|
||||
|
||||
For CI pipeline integration, see the `samber/cc-skills-golang@golang-continuous-integration` skill.
|
||||
|
||||
## Tracking Outdated Dependencies with go-mod-outdated
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/psampaz/go-mod-outdated@latest
|
||||
|
||||
# Show outdated direct dependencies with available updates
|
||||
go list -u -m -json all | go-mod-outdated -update -direct
|
||||
|
||||
# Fail in CI if dependencies are outdated
|
||||
go list -u -m -json all | go-mod-outdated -update -direct -ci
|
||||
|
||||
# Markdown output
|
||||
go list -u -m -json all | go-mod-outdated -update -direct -style markdown
|
||||
```
|
||||
|
||||
Output columns: MODULE, CURRENT version, WANTED (latest minor/patch), LATEST (latest overall), and VALID TIMESTAMPS (warns if an "update" is chronologically older than current).
|
||||
|
||||
## Analyzing Dependency Size with goweight
|
||||
|
||||
```bash
|
||||
# Install
|
||||
go install github.com/jondot/goweight@latest
|
||||
|
||||
# Run in your project directory
|
||||
goweight
|
||||
|
||||
# JSON output for CI tracking
|
||||
goweight --json
|
||||
```
|
||||
|
||||
Output lists every package linked into the binary sorted by size contribution. Use this to identify bloated dependencies and evaluate whether a lighter alternative exists.
|
||||
|
||||
**Modern alternative**: [go-size-analyzer](https://github.com/Zxilly/go-size-analyzer) (`gsa`) supports ELF, Mach-O, PE, and WebAssembly formats with interactive HTML/SVG visualization:
|
||||
|
||||
```bash
|
||||
go install github.com/nicholasgasior/gsa@latest
|
||||
go build -o ./myapp ./cmd/myapp
|
||||
gsa -f html -o size-report.html ./myapp
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Automated Dependency Updates
|
||||
|
||||
Automate minor/patch dependency updates to reduce maintenance burden and stay current with security fixes. This requires a solid CI pipeline — tests and linting must pass before any auto-merge.
|
||||
|
||||
## Dependabot vs Renovate
|
||||
|
||||
| Feature | Dependabot | Renovate |
|
||||
| --- | --- | --- |
|
||||
| Platform | GitHub only | GitHub, GitLab, Bitbucket, self-hosted |
|
||||
| `go mod tidy` | Automatic | Opt-in (`gomodTidy`) |
|
||||
| Automerge | Separate workflow | Native support |
|
||||
| Grouping | Pattern-based | More flexible rules |
|
||||
| Monorepo support | Basic | Go workspaces aware |
|
||||
| Regex managers | No | Yes (Dockerfiles, Makefiles, etc) |
|
||||
|
||||
**Renovate is generally more mature and configurable.** Dependabot is simpler to set up for GitHub-only projects.
|
||||
|
||||
## Auto-Merge Strategy
|
||||
|
||||
- **Minor and patch updates**: Auto-merge after CI passes (tests + lint + govulncheck)
|
||||
- **Major updates**: Create PR for manual review (may contain breaking changes)
|
||||
- **Security updates**: Auto-merge regardless of version bump type
|
||||
|
||||
For workflow configuration files (dependabot.yml, renovate.json, auto-merge workflows), see the `samber/cc-skills-golang@golang-continuous-integration` skill.
|
||||
|
||||
## AI-driven Updates
|
||||
|
||||
When an AI agent performs updates, verify before committing:
|
||||
|
||||
1. Check changelog for breaking changes and new features
|
||||
2. Suggest improvements to your project code based on changelog features and best practices
|
||||
3. Run `go test ./...` and `go build ./...`
|
||||
4. Scan with `govulncheck ./...`
|
||||
5. For major versions, read the migration guide and test thoroughly
|
||||
@@ -0,0 +1,75 @@
|
||||
# Dependency Conflicts & Resolution
|
||||
|
||||
## Diagnosing Conflicts
|
||||
|
||||
```bash
|
||||
# See why a module is in your build
|
||||
go mod why -m github.com/some/module
|
||||
|
||||
# See which version is selected
|
||||
go list -m github.com/some/module
|
||||
|
||||
# See the full requirement graph
|
||||
go mod graph
|
||||
|
||||
# List all modules in the build
|
||||
go list -m all
|
||||
```
|
||||
|
||||
## Resolution Strategies
|
||||
|
||||
**Force a specific version** (when two deps require incompatible versions):
|
||||
|
||||
```bash
|
||||
go mod edit -replace=example.com/pkg@v1.2.0=example.com/pkg@v1.3.1
|
||||
```
|
||||
|
||||
```go
|
||||
// go.mod
|
||||
replace example.com/pkg v1.2.0 => example.com/pkg v1.3.1
|
||||
```
|
||||
|
||||
**Use a local fork** (for debugging or patching):
|
||||
|
||||
```go
|
||||
replace example.com/pkg => ../my-local-fork
|
||||
```
|
||||
|
||||
**Block a problematic version**:
|
||||
|
||||
```bash
|
||||
go mod edit -exclude=example.com/pkg@v1.3.0
|
||||
```
|
||||
|
||||
When a version is excluded, any requirement on that version is redirected to the next higher available version.
|
||||
|
||||
**Force upgrade a transitive dependency**:
|
||||
|
||||
```bash
|
||||
go get github.com/transitive/dep@v1.5.0
|
||||
```
|
||||
|
||||
This adds an explicit requirement in your `go.mod`, overriding whatever the transitive dependency chain would select via MVS.
|
||||
|
||||
## Resolution Workflow
|
||||
|
||||
1. Run `go mod graph` and `go mod why -m <module>` to understand the dependency chain
|
||||
2. Identify which of your direct dependencies pulls in the conflicting version
|
||||
3. Try upgrading the direct dependency first: `go get github.com/direct/dep@latest`
|
||||
4. If that doesn't resolve it, use `replace` or `exclude` as a temporary fix
|
||||
5. Run `go mod tidy` to clean up
|
||||
6. Verify with `go build ./...` and `go test ./...`
|
||||
|
||||
**Important**: `replace` and `exclude` directives only take effect in the **main module's** `go.mod`. They are ignored when your module is used as a dependency. Remove `replace` directives before publishing a library.
|
||||
|
||||
## Retract (For Module Authors)
|
||||
|
||||
Mark versions as broken or accidentally published:
|
||||
|
||||
```go
|
||||
// go.mod
|
||||
retract v1.0.0 // Contains critical bug in auth
|
||||
retract [v1.1.0, v1.2.0] // Range of broken versions
|
||||
```
|
||||
|
||||
Retracted versions are still downloadable but `go get` will not select them by default, and `go list -m -u` warns about them.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Versioning & Minimal Version Selection
|
||||
|
||||
## Semantic Versioning (SemVer)
|
||||
|
||||
Go modules use **`vMAJOR.MINOR.PATCH`** (the `v` prefix is required):
|
||||
|
||||
- **MAJOR**: Breaking changes to the public API
|
||||
- **MINOR**: Backward-compatible new functionality
|
||||
- **PATCH**: Backward-compatible bug fixes
|
||||
|
||||
### Stability Rules
|
||||
|
||||
| Version | Stability |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| `v0.x.x` | Unstable — no compatibility guarantees |
|
||||
| `v1.x.x`+ | Stable — backward-compatible within major |
|
||||
| Pre-release | Unstable (e.g., `v1.5.0-beta.1`) |
|
||||
|
||||
### Major Version Suffix Rule
|
||||
|
||||
For `v2` and above, the module path must include a `/vN` suffix. This is Go's import compatibility rule — different major versions are treated as entirely separate modules, allowing them to coexist in the same build:
|
||||
|
||||
```go
|
||||
// go.mod
|
||||
module github.com/example/pkg/v2
|
||||
|
||||
// Import in code
|
||||
import "github.com/example/pkg/v2/subpkg"
|
||||
```
|
||||
|
||||
Tags: `v2.0.0`, `v2.1.0`, etc. The `v0` and `v1` versions have no suffix.
|
||||
|
||||
### Special Cases
|
||||
|
||||
- **Pseudo-versions**: For untagged commits — `v0.0.0-20210101120000-abcdef123456` (base version + timestamp + commit hash)
|
||||
- **`+incompatible`**: Marks `v2+` modules that have not adopted the `/vN` path convention
|
||||
- **`gopkg.in`**: Always uses a version suffix with a dot — `gopkg.in/yaml.v3`
|
||||
|
||||
## Minimal Version Selection (MVS)
|
||||
|
||||
Go's dependency resolution algorithm is fundamentally different from npm, pip, or cargo.
|
||||
|
||||
### How It Works
|
||||
|
||||
Most package managers select the **latest** compatible version of each dependency. Go does the opposite: it selects the **minimum version that satisfies all requirements**. If module A requires `pkg@v1.2.0` and module B requires `pkg@v1.3.0`, MVS selects `v1.3.0` — the highest minimum required, not the latest available.
|
||||
|
||||
### Why This Design
|
||||
|
||||
- **Deterministic without a lock file**: Given the same `go.mod` inputs, MVS always produces the same build list. `go.sum` is just integrity verification.
|
||||
- **High fidelity**: Builds closely match what module authors tested against, since the nearest compatible version is selected rather than the latest.
|
||||
- **No solver needed**: The algorithm is simple graph traversal (under 50 lines of code), not an NP-hard constraint satisfaction problem.
|
||||
- **Reproducible across machines**: No "works on my machine" from different lock file states.
|
||||
|
||||
### Upgrades and Downgrades
|
||||
|
||||
- **Upgrade**: `go get pkg@v1.5.0` adds an edge to `v1.5.0` in the module graph and reruns MVS. Only the minimum necessary changes propagate.
|
||||
- **Downgrade**: `go get pkg@v1.2.0` removes all versions above `v1.2.0` from the graph, then walks backward to find the latest remaining versions of affected dependencies.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Visualizing the Dependency Graph
|
||||
|
||||
## go mod graph (Built-in)
|
||||
|
||||
```bash
|
||||
go mod graph
|
||||
```
|
||||
|
||||
Output: each line contains two space-separated fields (module and its requirement) in `path@version` format:
|
||||
|
||||
```
|
||||
example.com/main github.com/pkg/errors@v0.9.1
|
||||
example.com/main golang.org/x/text@v0.3.7
|
||||
github.com/pkg/errors@v0.9.1 golang.org/x/sys@v0.0.0-20210615035016
|
||||
```
|
||||
|
||||
## go mod why
|
||||
|
||||
```bash
|
||||
go mod why -m github.com/some/module
|
||||
```
|
||||
|
||||
Shows the shortest import path from your code to the module — useful for understanding why an unexpected dependency exists.
|
||||
|
||||
## Generate a Graph Image with modgraphviz
|
||||
|
||||
```bash
|
||||
go install golang.org/x/exp/cmd/modgraphviz@latest
|
||||
go mod graph | modgraphviz | dot -Tpng -o deps.png
|
||||
```
|
||||
|
||||
Green nodes represent versions selected by MVS (in the final build list). Grey nodes are versions that exist in the requirement graph but are not used.
|
||||
|
||||
## Interactive Visualization with go-mod-graph
|
||||
|
||||
[go-mod-graph](https://github.com/samber/go-mod-graph) provides a web-based interactive dependency explorer at [go-mod-graph.samber.dev](https://go-mod-graph.samber.dev):
|
||||
|
||||
- Zoomable, navigable dependency graph
|
||||
- Module weight display with color-coded size indicators
|
||||
- Searchable module list
|
||||
- Direct links to pkg.go.dev documentation
|
||||
- MVS algorithm visualization
|
||||
|
||||
## Complementary Analysis
|
||||
|
||||
```bash
|
||||
# General graph queries on go mod graph output
|
||||
go install golang.org/x/tools/cmd/digraph@latest
|
||||
go mod graph | digraph reverse github.com/some/module
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
# Go Workspaces (go.work)
|
||||
|
||||
## go.work vs go.mod
|
||||
|
||||
| Scenario | Use |
|
||||
| ---------------------------------------------- | --------- |
|
||||
| Single module project | `go.mod` |
|
||||
| Developing multiple related local modules | `go.work` |
|
||||
| Monorepo with separate Go modules | `go.work` |
|
||||
| Testing local changes across module boundaries | `go.work` |
|
||||
| Published library consumed by others | `go.mod` |
|
||||
|
||||
## Workspace Commands
|
||||
|
||||
```bash
|
||||
go work init # Initialize workspace
|
||||
go work use ./services/auth # Add module to workspace
|
||||
go work use -rm ./old-module # Remove module from workspace
|
||||
go work sync # Sync workspace with module changes
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
- Workspaces eliminate the need for `replace` directives during local development — the workspace automatically resolves local modules
|
||||
- **Do not commit `go.work.sum`** to version control (add to `.gitignore`)
|
||||
- `go.work` is for development only — it does not affect how consumers of your published modules resolve dependencies
|
||||
- For workspace directory structure examples, see the `samber/cc-skills-golang@golang-project-layout` skill
|
||||
@@ -0,0 +1,269 @@
|
||||
---
|
||||
name: golang-design-patterns
|
||||
description: Idiomatic Golang design patterns — functional options, constructors, error flow and cascading, resource management and lifecycle, graceful shutdown, resilience, architecture, dependency injection, data handling, and streaming. Apply when designing Go APIs, structuring applications, choosing between patterns, making design decisions, architectural choices, or production hardening.
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
|
||||
metadata:
|
||||
author: samber
|
||||
version: "1.0.0"
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent
|
||||
---
|
||||
|
||||
**Persona:** You are a Go architect who values simplicity and explicitness. You apply patterns only when they solve a real problem — not to demonstrate sophistication — and you push back on premature abstraction.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Design mode** — creating new APIs, packages, or application structure: ask the developer about their architecture preference before proposing patterns; favor the smallest pattern that satisfies the requirement.
|
||||
- **Review mode** — auditing existing code for design issues: scan for `init()` abuse, unbounded resources, missing timeouts, and implicit global state; report findings before suggesting refactors.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-design-patterns` skill takes precedence.
|
||||
|
||||
# Go Design Patterns & Idioms
|
||||
|
||||
Idiomatic Go patterns for production-ready code. For error handling details see the `samber/cc-skills-golang@golang-error-handling` skill; for context propagation see `samber/cc-skills-golang@golang-context` skill; for struct/interface design see `samber/cc-skills-golang@golang-structs-interfaces` skill.
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. Constructors SHOULD use **functional options** — they scale better as APIs evolve (one function per option, no breaking changes)
|
||||
2. Functional options MUST **return an error** if validation can fail — catch bad config at construction, not at runtime
|
||||
3. **Avoid `init()`** — runs implicitly, cannot return errors, makes testing unpredictable. Use explicit constructors
|
||||
4. Enums SHOULD **start at 1** (or Unknown sentinel at 0) — Go's zero value silently passes as the first enum member
|
||||
5. Error cases MUST be **handled first** with early return — keep happy path flat
|
||||
6. **Panic is for bugs, not expected errors** — callers can handle returned errors; panics crash the process
|
||||
7. **`defer Close()` immediately after opening** — later code changes can accidentally skip cleanup
|
||||
8. **`runtime.AddCleanup`** over `runtime.SetFinalizer` — finalizers are unpredictable and can resurrect objects
|
||||
9. Every external call SHOULD **have a timeout** — a slow upstream hangs your goroutine indefinitely
|
||||
10. **Limit everything** (pool sizes, queue depths, buffers) — unbounded resources grow until they crash
|
||||
11. Retry logic MUST **check context cancellation** between attempts
|
||||
12. **Use `strings.Builder`** for concatenation in loops → see `samber/cc-skills-golang@golang-code-style`
|
||||
13. string vs []byte: **use `[]byte` for mutation and I/O**, `string` for display and keys — conversions allocate
|
||||
14. Iterators (Go 1.23+): **use for lazy evaluation** — avoid loading everything into memory
|
||||
15. **Stream large transfers** — loading millions of rows causes OOM; stream keeps memory constant
|
||||
16. `//go:embed` for **static assets** — embeds at compile time, eliminates runtime file I/O errors
|
||||
17. **Use `crypto/rand`** for keys/tokens — `math/rand` is predictable → see `samber/cc-skills-golang@golang-security`
|
||||
18. Regexp MUST be **compiled once at package level** — compilation is O(n) and allocates
|
||||
19. Compile-time interface checks: **`var _ Interface = (*Type)(nil)`**
|
||||
20. **A little recode > a big dependency** — each dep adds attack surface and maintenance burden
|
||||
21. **Design for testability** — accept interfaces, inject dependencies
|
||||
|
||||
## Constructor Patterns: Functional Options vs Builder
|
||||
|
||||
### Functional Options (Preferred)
|
||||
|
||||
```go
|
||||
type Server struct {
|
||||
addr string
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
maxConns int
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func WithReadTimeout(d time.Duration) Option {
|
||||
return func(s *Server) { s.readTimeout = d }
|
||||
}
|
||||
|
||||
func WithWriteTimeout(d time.Duration) Option {
|
||||
return func(s *Server) { s.writeTimeout = d }
|
||||
}
|
||||
|
||||
func WithMaxConns(n int) Option {
|
||||
return func(s *Server) { s.maxConns = n }
|
||||
}
|
||||
|
||||
func NewServer(addr string, opts ...Option) *Server {
|
||||
// Default options
|
||||
s := &Server{
|
||||
addr: addr,
|
||||
readTimeout: 5 * time.Second,
|
||||
writeTimeout: 10 * time.Second,
|
||||
maxConns: 100,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Usage
|
||||
srv := NewServer(":8080",
|
||||
WithReadTimeout(30*time.Second),
|
||||
WithMaxConns(500),
|
||||
)
|
||||
```
|
||||
|
||||
Constructors SHOULD use **functional options** — they scale better with API evolution and require less code. Use builder pattern only if you need complex validation between configuration steps.
|
||||
|
||||
## Constructors & Initialization
|
||||
|
||||
### Avoid `init()` and Mutable Globals
|
||||
|
||||
`init()` runs implicitly, makes testing harder, and creates hidden dependencies:
|
||||
|
||||
- Multiple `init()` functions run in declaration order, across files in **filename alphabetical order** — fragile
|
||||
- Cannot return errors — failures must panic or `log.Fatal`
|
||||
- Runs before `main()` and tests — side effects make tests unpredictable
|
||||
|
||||
```go
|
||||
// Bad — hidden global state
|
||||
var db *sql.DB
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Good — explicit initialization, injectable
|
||||
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
```
|
||||
|
||||
### Enums: Start at 1
|
||||
|
||||
Zero values should represent invalid/unset state:
|
||||
|
||||
```go
|
||||
type Status int
|
||||
|
||||
const (
|
||||
StatusUnknown Status = iota // 0 = invalid/unset
|
||||
StatusActive // 1
|
||||
StatusInactive // 2
|
||||
StatusSuspended // 3
|
||||
)
|
||||
```
|
||||
|
||||
### Compile Regexp Once
|
||||
|
||||
```go
|
||||
// Good — compiled once at package level
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
func ValidateEmail(email string) bool {
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
```
|
||||
|
||||
### Use `//go:embed` for Static Assets
|
||||
|
||||
```go
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
```
|
||||
|
||||
### Compile-Time Interface Checks
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-structs-interfaces` for the `var _ Interface = (*Type)(nil)` pattern.
|
||||
|
||||
## Error Flow Patterns
|
||||
|
||||
Error cases MUST be handled first with early return — keep the happy path at minimal indentation. → See `samber/cc-skills-golang@golang-code-style` for the full pattern and examples.
|
||||
|
||||
### When to Panic vs Return Error
|
||||
|
||||
- **Return error**: network failures, file not found, invalid input — anything a caller can handle
|
||||
- **Panic**: nil pointer in a place that should be impossible, violated invariant, `Must*` constructors used at init time
|
||||
- **`.Close()` errors**: acceptable to not check — `defer f.Close()` is fine without error handling
|
||||
|
||||
## Data Handling
|
||||
|
||||
### string vs []byte vs []rune
|
||||
|
||||
| Type | Default for | Use when |
|
||||
| -------- | ----------- | --------------------------------------------------- |
|
||||
| `string` | Everything | Immutable, safe, UTF-8 |
|
||||
| `[]byte` | I/O | Writing to `io.Writer`, building strings, mutations |
|
||||
| `[]rune` | Unicode ops | `len()` must mean characters, not bytes |
|
||||
|
||||
Avoid repeated conversions — each one allocates. Stay in one type until you need the other.
|
||||
|
||||
### Iterators & Streaming for Large Data
|
||||
|
||||
Use iterators (Go 1.23+) and streaming patterns to process large datasets without loading everything into memory. For large transfers between services (e.g., 1M rows DB to HTTP), stream to prevent OOM.
|
||||
|
||||
For code examples, see [Data Handling Patterns](references/data-handling.md).
|
||||
|
||||
## Resource Management
|
||||
|
||||
`defer Close()` immediately after opening — don't wait, don't forget:
|
||||
|
||||
```go
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() // right here, not 50 lines later
|
||||
|
||||
rows, err := db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
```
|
||||
|
||||
For graceful shutdown, resource pools, and `runtime.AddCleanup`, see [Resource Management](references/resource-management.md).
|
||||
|
||||
## Resilience & Limits
|
||||
|
||||
### Timeout Every External Call
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := httpClient.Do(req.WithContext(ctx))
|
||||
```
|
||||
|
||||
### Retry & Context Checks
|
||||
|
||||
Retry logic MUST check `ctx.Err()` between attempts and use exponential/linear backoff via `select` on `ctx.Done()`. Long loops MUST check `ctx.Err()` periodically. → See `samber/cc-skills-golang@golang-context` skill.
|
||||
|
||||
## Database Patterns
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-database` skill for sqlx/pgx, transactions, nullable columns, connection pools, repository interfaces, testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
Ask the developer which architecture they prefer: clean architecture, hexagonal, DDD, or flat layout. Don't impose complex architecture on a small project.
|
||||
|
||||
Core principles regardless of architecture:
|
||||
|
||||
- **Keep domain pure** — no framework dependencies in the domain layer
|
||||
- **Fail fast** — validate at boundaries, trust internal code
|
||||
- **Make illegal states unrepresentable** — use types to enforce invariants
|
||||
- **Respect 12-factor app** principles — → see `samber/cc-skills-golang@golang-project-layout`
|
||||
|
||||
## Detailed Guides
|
||||
|
||||
| Guide | Scope |
|
||||
| --- | --- |
|
||||
| [Architecture Patterns](references/architecture.md) | High-level principles, when each architecture fits |
|
||||
| [Clean Architecture](references/clean-architecture.md) | Use cases, dependency rule, layered adapters |
|
||||
| [Hexagonal Architecture](references/hexagonal-architecture.md) | Ports and adapters, domain core isolation |
|
||||
| [Domain-Driven Design](references/ddd.md) | Aggregates, value objects, bounded contexts |
|
||||
|
||||
## Code Philosophy
|
||||
|
||||
- **Avoid repetitive code** — but don't abstract prematurely
|
||||
- **Minimize dependencies** — a little recode > a big dependency
|
||||
- **Design for testability** — accept interfaces, inject dependencies, keep functions pure
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-data-structures` skill for data structure selection, internals, and container/ packages
|
||||
- → See `samber/cc-skills-golang@golang-error-handling` skill for error wrapping, sentinel errors, and the single handling rule
|
||||
- → See `samber/cc-skills-golang@golang-structs-interfaces` skill for interface design and composition
|
||||
- → See `samber/cc-skills-golang@golang-concurrency` skill for goroutine lifecycle and graceful shutdown
|
||||
- → See `samber/cc-skills-golang@golang-context` skill for timeout and cancellation patterns
|
||||
- → See `samber/cc-skills-golang@golang-project-layout` skill for architecture and directory structure
|
||||
@@ -0,0 +1,251 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "functional-options-over-builder",
|
||||
"description": "Tests whether the model recommends functional options (not builder pattern) as the preferred constructor pattern in Go, and whether options return errors for validation",
|
||||
"prompt": "I need to create a Go HTTP server struct with optional configuration: read timeout, write timeout, max connections, TLS config, and a logger. Design the constructor API. The config will grow over time as we add features.",
|
||||
"trap": "Without the skill, the model may default to a builder pattern, config struct, or positional arguments instead of functional options. It may also forget that options should return errors when validation can fail.",
|
||||
"assertions": [
|
||||
{"id": "1.1", "text": "Uses functional options pattern (Option type as func that modifies the struct)"},
|
||||
{"id": "1.2", "text": "Constructor accepts variadic ...Option parameter"},
|
||||
{"id": "1.3", "text": "Each option is a With* function returning an Option"},
|
||||
{"id": "1.4", "text": "Sets sensible defaults inside the constructor before applying options"},
|
||||
{"id": "1.5", "text": "Mentions that functional options should return an error if validation can fail, or demonstrates error-returning option variant"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "avoid-init-function",
|
||||
"description": "Tests whether the model avoids init() for database initialization and uses explicit constructors instead",
|
||||
"prompt": "I'm writing a Go web service. I want to set up the database connection pool when the application starts. Here's my approach:\n\n```go\nvar db *sql.DB\n\nfunc init() {\n var err error\n db, err = sql.Open(\"postgres\", os.Getenv(\"DATABASE_URL\"))\n if err != nil {\n log.Fatal(err)\n }\n}\n```\n\nIs this a good pattern? How should I improve it?",
|
||||
"trap": "Without the skill, the model may accept the init() pattern as fine or only suggest minor improvements. The skill explicitly warns against init() for hidden dependencies and testability issues.",
|
||||
"assertions": [
|
||||
{"id": "2.1", "text": "Explicitly recommends against using init() for database initialization"},
|
||||
{"id": "2.2", "text": "Mentions that init() makes testing harder or unpredictable"},
|
||||
{"id": "2.3", "text": "Mentions that init() cannot return errors (must panic or log.Fatal)"},
|
||||
{"id": "2.4", "text": "Suggests explicit constructor or initialization function (e.g. NewUserRepository(db))"},
|
||||
{"id": "2.5", "text": "Mentions that init() runs before main/tests creating hidden dependencies"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "enum-start-at-one",
|
||||
"description": "Tests whether the model starts Go enums at 1 or uses an Unknown/Invalid sentinel at 0",
|
||||
"prompt": "I need to define a Go enum for order status with values: pending, processing, shipped, delivered, cancelled. Write the type and constants using iota.",
|
||||
"trap": "Without the skill, the model often starts the first meaningful enum value at 0 (iota), making the zero value silently pass as a valid status. The skill says enums SHOULD start at 1 or use an Unknown sentinel at 0.",
|
||||
"assertions": [
|
||||
{"id": "3.1", "text": "Zero value (iota = 0) is either skipped, named Unknown, Invalid, or Unspecified -- not a meaningful business value"},
|
||||
{"id": "3.2", "text": "First meaningful enum value starts at 1 or higher"},
|
||||
{"id": "3.3", "text": "Explains WHY: Go's zero value would silently pass as the first enum member if it were meaningful"},
|
||||
{"id": "3.4", "text": "Uses a custom type (not raw int or string)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "panic-vs-error-judgment",
|
||||
"description": "Tests whether the model correctly distinguishes when to panic vs return an error",
|
||||
"prompt": "I'm implementing a configuration parser in Go. If the config file has an invalid format, should I panic or return an error? What about if a required field is missing? What about if a developer passes nil to a function that documents it must not be nil?",
|
||||
"trap": "Without the skill, the model may be inconsistent about when to panic. The skill says panic is for bugs (violated invariants, impossible nil), not expected errors (invalid input, missing fields).",
|
||||
"assertions": [
|
||||
{"id": "4.1", "text": "Invalid config format: return error (caller can handle it)"},
|
||||
{"id": "4.2", "text": "Missing required field: return error (expected validation failure)"},
|
||||
{"id": "4.3", "text": "Nil passed to non-nil function: panic is acceptable (violated invariant, bug in caller)"},
|
||||
{"id": "4.4", "text": "Articulates the principle: panic is for bugs/invariant violations, errors are for expected failures"},
|
||||
{"id": "4.5", "text": "Mentions Must* constructor pattern as a valid panic use case (init-time convenience)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "runtime-addcleanup-over-setfinalizer",
|
||||
"description": "Tests whether the model recommends runtime.AddCleanup over runtime.SetFinalizer for Go 1.24+",
|
||||
"prompt": "I have a Go struct that wraps a C resource handle (via cgo). When the Go object is garbage collected, I need to release the C handle. I'm using Go 1.24. What's the best approach for automatic cleanup?",
|
||||
"trap": "Without the skill, the model almost always suggests runtime.SetFinalizer, which is the older and more well-known API. The skill specifically says to prefer runtime.AddCleanup (Go 1.24+).",
|
||||
"assertions": [
|
||||
{"id": "5.1", "text": "Recommends runtime.AddCleanup as the preferred approach"},
|
||||
{"id": "5.2", "text": "Mentions that AddCleanup supports multiple cleanups on the same object"},
|
||||
{"id": "5.3", "text": "Mentions that AddCleanup avoids object resurrection risk (cleanup receives a copy of the value, not the object)"},
|
||||
{"id": "5.4", "text": "Mentions that AddCleanup works even with cyclic references"},
|
||||
{"id": "5.5", "text": "Either warns against SetFinalizer or explains why AddCleanup is better"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "resource-pool-bounded-channel",
|
||||
"description": "Tests whether the model uses bounded channel-based pools and emphasizes limiting resource pool sizes",
|
||||
"prompt": "I need to implement a connection pool in Go for reusing database connections. I want it to be concurrent-safe and have a maximum size. Design the pool.",
|
||||
"trap": "Without the skill, the model may use sync.Pool (wrong: not bounded, items can be evicted) or an unbounded slice with mutex. The skill says use channels with fixed capacity for bounded allocation.",
|
||||
"assertions": [
|
||||
{"id": "6.1", "text": "Uses a buffered channel (chan *Conn with fixed capacity) as the pool mechanism"},
|
||||
{"id": "6.2", "text": "Pool has a maximum size / bounded capacity"},
|
||||
{"id": "6.3", "text": "Get operation uses select with context for timeout/cancellation"},
|
||||
{"id": "6.4", "text": "Put operation handles pool-full case (discards excess connections)"},
|
||||
{"id": "6.5", "text": "Does NOT use sync.Pool as the primary pooling mechanism (sync.Pool has no size guarantee and items can be reclaimed)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "graceful-shutdown-signal-notifycontext",
|
||||
"description": "Tests whether the model uses signal.NotifyContext for graceful shutdown",
|
||||
"prompt": "I'm building a Go HTTP server that needs to handle SIGINT and SIGTERM for graceful shutdown. Show me how to implement this properly.",
|
||||
"trap": "Without the skill, the model may use a raw os.Signal channel with signal.Notify instead of signal.NotifyContext. The skill specifically says to use signal.NotifyContext.",
|
||||
"assertions": [
|
||||
{"id": "7.1", "text": "Uses signal.NotifyContext (not raw signal.Notify with a channel)"},
|
||||
{"id": "7.2", "text": "Listens for both SIGINT and SIGTERM"},
|
||||
{"id": "7.3", "text": "Starts the HTTP server in a goroutine"},
|
||||
{"id": "7.4", "text": "Creates a separate timeout context for the shutdown phase (e.g. context.WithTimeout for draining)"},
|
||||
{"id": "7.5", "text": "Closes other resources (DB, queues) after server shutdown"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "iterator-streaming-large-data",
|
||||
"description": "Tests whether the model uses iterators/streaming instead of loading all data into memory for large datasets",
|
||||
"prompt": "I need to export 2 million user records from a PostgreSQL database to a JSON HTTP response in Go. The users table has columns: id, name, email, created_at. Write the handler.",
|
||||
"trap": "Without the skill, the model typically loads all rows into a []User slice, then json.Marshal the whole thing. The skill says to stream large transfers to prevent OOM.",
|
||||
"assertions": [
|
||||
{"id": "8.1", "text": "Does NOT load all 2M rows into a slice in memory"},
|
||||
{"id": "8.2", "text": "Streams the JSON response (writes records one at a time to the ResponseWriter)"},
|
||||
{"id": "8.3", "text": "Uses rows.Next() loop or iter.Seq2 iterator pattern"},
|
||||
{"id": "8.4", "text": "Defers rows.Close() immediately after query"},
|
||||
{"id": "8.5", "text": "Mentions OOM risk or memory concern as motivation for streaming"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "regexp-compile-once",
|
||||
"description": "Tests whether the model compiles regexps at package level, not inside functions",
|
||||
"prompt": "Write a Go function that validates email addresses using a regular expression. It will be called thousands of times per second in an HTTP handler.",
|
||||
"trap": "Without the skill, the model often compiles the regexp inside the function on every call. The skill says regexp MUST be compiled once at package level.",
|
||||
"assertions": [
|
||||
{"id": "9.1", "text": "Compiles the regexp at package level (var emailRegex = regexp.MustCompile(...))"},
|
||||
{"id": "9.2", "text": "Does NOT compile the regexp inside the validation function"},
|
||||
{"id": "9.3", "text": "Uses regexp.MustCompile (not regexp.Compile) for package-level initialization"},
|
||||
{"id": "9.4", "text": "Explains WHY: compilation is O(n) and allocates, so doing it per-call is wasteful"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "architecture-right-sizing",
|
||||
"description": "Tests whether the model avoids over-architecting small projects and asks for preferences",
|
||||
"prompt": "I'm starting a new Go project: a CLI tool that reads a CSV file, transforms the data, and writes it to stdout. It will be about 200 lines of code. What architecture and directory structure should I use?",
|
||||
"trap": "Without the skill, the model may suggest clean architecture, hexagonal patterns, handler/service/repository layers, or DI frameworks for a 200-line CLI. The skill says don't impose complex architecture on small projects.",
|
||||
"assertions": [
|
||||
{"id": "10.1", "text": "Recommends a flat or minimal structure (no multi-layer architecture)"},
|
||||
{"id": "10.2", "text": "Does NOT suggest clean architecture, hexagonal, DDD, or ports and adapters for a 200-line CLI"},
|
||||
{"id": "10.3", "text": "Does NOT suggest dependency injection frameworks"},
|
||||
{"id": "10.4", "text": "Structure has at most cmd/ and possibly internal/, not handler/service/repository layers"},
|
||||
{"id": "10.5", "text": "Mentions that architecture complexity should match project scope"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "hexagonal-vs-clean-architecture",
|
||||
"description": "Tests whether the model correctly distinguishes hexagonal from clean architecture and when each applies",
|
||||
"prompt": "I'm building a Go service (about 8K lines) that processes orders. It needs HTTP and gRPC entry points, plus a message consumer for async events. It talks to PostgreSQL, Stripe, and Redis. Should I use clean architecture or hexagonal architecture? Explain the difference and recommend one.",
|
||||
"trap": "Without the skill, the model may conflate the two architectures or fail to identify that hexagonal is better when multiple entry points are needed. The skill has distinct guides for each.",
|
||||
"assertions": [
|
||||
{"id": "11.1", "text": "Correctly explains that hexagonal uses ports (interfaces) and adapters (implementations) with primary (driving) and secondary (driven) distinction"},
|
||||
{"id": "11.2", "text": "Correctly explains that clean architecture uses dependency rule (dependencies point inward) with entities/use-cases/adapters/frameworks layers"},
|
||||
{"id": "11.3", "text": "Recommends hexagonal for this specific case (multiple entry points: HTTP, gRPC, message consumer)"},
|
||||
{"id": "11.4", "text": "Mentions that both keep domain logic pure and free from infrastructure dependencies"},
|
||||
{"id": "11.5", "text": "Provides a directory structure example with adapter/primary/ and adapter/secondary/ or equivalent hexagonal layout"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "ddd-aggregate-root-mutations",
|
||||
"description": "Tests whether the model enforces that all mutations go through the aggregate root in DDD",
|
||||
"prompt": "I'm implementing DDD in Go for an e-commerce system. I have an Order aggregate with OrderItems. A user wants to add an item to an existing order. Show me how to structure this. The order should only be editable when in Draft status.",
|
||||
"trap": "Without the skill, the model may allow direct mutation of OrderItems from outside the aggregate, or may not enforce the aggregate root pattern. The skill says all mutations go through the root.",
|
||||
"assertions": [
|
||||
{"id": "12.1", "text": "AddItem is a method on the Order aggregate root (not on OrderItem or a service)"},
|
||||
{"id": "12.2", "text": "Order fields (items, status) are unexported to prevent external mutation"},
|
||||
{"id": "12.3", "text": "AddItem validates the status constraint (only Draft orders are editable)"},
|
||||
{"id": "12.4", "text": "Repository interface is defined in the domain package, not in the infrastructure package"},
|
||||
{"id": "12.5", "text": "Domain types have no infrastructure imports (no sql, no http, no framework dependencies)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"name": "ddd-bounded-context-communication",
|
||||
"description": "Tests whether the model uses anti-corruption layers or domain events between bounded contexts, not direct imports",
|
||||
"prompt": "I have two bounded contexts in my Go DDD project: Order and Billing. When an order is placed, the billing context needs to create an invoice. How should these contexts communicate?",
|
||||
"trap": "Without the skill, the model may suggest billing directly importing order's internal types. The skill says contexts communicate through domain events or anti-corruption layers, never by importing each other's internal types.",
|
||||
"assertions": [
|
||||
{"id": "13.1", "text": "Uses domain events (e.g. OrderPlaced event) for cross-context communication"},
|
||||
{"id": "13.2", "text": "Billing context does NOT directly import order's internal domain types"},
|
||||
{"id": "13.3", "text": "Shows or describes an anti-corruption layer that translates order events to billing-specific types"},
|
||||
{"id": "13.4", "text": "Each bounded context has its own domain, application, and adapter layers"},
|
||||
{"id": "13.5", "text": "Mentions that direct type imports between contexts create tight coupling"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"name": "make-illegal-states-unrepresentable",
|
||||
"description": "Tests whether the model uses types to enforce invariants rather than runtime validation",
|
||||
"prompt": "I have a Go function that sends notifications. It accepts an email address as a string parameter. Sometimes callers pass invalid emails and we only catch it at send time. How can I prevent invalid emails from reaching the send function?",
|
||||
"trap": "Without the skill, the model typically adds validation at the top of the send function. The skill says to make illegal states unrepresentable using types.",
|
||||
"assertions": [
|
||||
{"id": "14.1", "text": "Creates a dedicated Email type (struct with unexported address field)"},
|
||||
{"id": "14.2", "text": "Email can only be created via a constructor (NewEmail) that validates the address"},
|
||||
{"id": "14.3", "text": "The send function accepts the Email type instead of a raw string"},
|
||||
{"id": "14.4", "text": "Explains the principle: make illegal states unrepresentable through the type system"},
|
||||
{"id": "14.5", "text": "The unexported field prevents creating an Email without validation (cannot set address from outside the package)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"name": "fail-fast-validate-at-boundaries",
|
||||
"description": "Tests whether the model validates at system boundaries and trusts data internally, rather than re-validating at every layer",
|
||||
"prompt": "I'm building a Go web service with three layers: HTTP handler, service, and repository. Should I validate the request body (user_id, email, age) in all three layers to be safe?",
|
||||
"trap": "Without the skill, the model may suggest defensive validation at every layer for 'safety'. The skill says validate at boundaries, trust internally -- don't re-validate the same data at every layer.",
|
||||
"assertions": [
|
||||
{"id": "15.1", "text": "Recommends validating at the HTTP handler layer (the system boundary)"},
|
||||
{"id": "15.2", "text": "Recommends that the service and repository layers trust the data is already valid"},
|
||||
{"id": "15.3", "text": "Explains WHY: re-validating at every layer clutters code and violates DRY"},
|
||||
{"id": "15.4", "text": "Does NOT suggest adding the same validation checks in all three layers"},
|
||||
{"id": "15.5", "text": "May distinguish between input validation (at boundary) and business rule validation (in domain)"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"name": "explicit-over-implicit-defaults",
|
||||
"description": "Tests whether the model favors explicit defaults in code over implicit magic (struct tags, reflection)",
|
||||
"prompt": "I want to provide default values for my Go configuration struct. I'm thinking of using struct tags like `default:\"8080\"` with a reflection-based library. Is this a good approach in Go?",
|
||||
"trap": "Without the skill, the model may endorse the struct-tag default approach as convenient. The skill says Go favors explicitness -- explicit defaults visible in code over implicit behavior hidden in struct tags and reflection.",
|
||||
"assertions": [
|
||||
{"id": "16.1", "text": "Recommends against using struct tags + reflection for defaults"},
|
||||
{"id": "16.2", "text": "Suggests explicit defaults in a constructor function (e.g. NewConfig())"},
|
||||
{"id": "16.3", "text": "Explains WHY: Go favors explicitness, struct tags hide behavior that readers cannot see without knowing the library"},
|
||||
{"id": "16.4", "text": "Shows a constructor that returns a Config with default values set explicitly"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"name": "retry-context-check",
|
||||
"description": "Tests whether retry logic checks context cancellation between attempts",
|
||||
"prompt": "Write a Go retry function that retries a given operation up to 5 times with exponential backoff. The function should be production-ready.",
|
||||
"trap": "Without the skill, the model may implement retry without checking ctx.Err() between attempts. The skill says retry logic MUST check context cancellation between attempts.",
|
||||
"assertions": [
|
||||
{"id": "17.1", "text": "Function accepts a context.Context parameter"},
|
||||
{"id": "17.2", "text": "Checks ctx.Err() or ctx.Done() between retry attempts"},
|
||||
{"id": "17.3", "text": "Uses select with ctx.Done() for the backoff delay (not time.Sleep)"},
|
||||
{"id": "17.4", "text": "Implements exponential backoff"},
|
||||
{"id": "17.5", "text": "Returns the context error if the context is cancelled during retry"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"name": "ddd-value-object-money",
|
||||
"description": "Tests whether the model implements money as a value object with cents (not float) and currency validation",
|
||||
"prompt": "I'm implementing a pricing system in Go using DDD. I need to represent monetary amounts that support addition and comparison. Design the money type.",
|
||||
"trap": "Without the skill, the model may use float64 for money (precision issues) or make it mutable. The skill shows Money as an immutable value object using int64 cents.",
|
||||
"assertions": [
|
||||
{"id": "18.1", "text": "Uses int64 (cents) not float64 for the amount -- avoids floating point precision issues"},
|
||||
{"id": "18.2", "text": "Includes a currency field"},
|
||||
{"id": "18.3", "text": "Fields are unexported (immutable value object, can only be created via constructor)"},
|
||||
{"id": "18.4", "text": "Add method validates currency match before addition"},
|
||||
{"id": "18.5", "text": "Constructor validates input (e.g. currency is required)"}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
# Architecture Patterns
|
||||
|
||||
## Choose the Right Level of Architecture
|
||||
|
||||
Architecture complexity MUST match project scope — don't over-architect small projects. When starting a new project, ask the developer what architecture they prefer:
|
||||
|
||||
| Project Size | Recommended Approach |
|
||||
| --- | --- |
|
||||
| Script / small CLI (<500 lines) | Flat `main.go` + a few files, no layers |
|
||||
| Medium service (500-5K lines) | Simple layered: `handler/`, `service/`, `repository/` |
|
||||
| Large service / monolith (5K+ lines) | Clean architecture, hexagonal, or DDD — ask the team |
|
||||
|
||||
A 100-line CLI does not need a domain layer, ports and adapters, or dependency injection frameworks. Start simple and refactor when complexity demands it.
|
||||
|
||||
## Keep Domain Pure
|
||||
|
||||
Domain logic MUST remain pure — no framework or infrastructure dependencies. The domain layer contains business logic and types:
|
||||
|
||||
```go
|
||||
// domain/order.go — pure business logic, no imports from infrastructure
|
||||
package domain
|
||||
|
||||
type Order struct {
|
||||
ID string
|
||||
Items []Item
|
||||
Status OrderStatus
|
||||
}
|
||||
|
||||
func (o *Order) AddItem(item Item) error {
|
||||
if o.Status != StatusDraft {
|
||||
return ErrOrderNotEditable
|
||||
}
|
||||
o.Items = append(o.Items, item)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Infrastructure concerns (database queries, HTTP clients, message queues) live in separate packages that depend on the domain — never the reverse.
|
||||
|
||||
## Fail Fast — Validate at Boundaries
|
||||
|
||||
Input MUST be validated at system boundaries (HTTP handlers, CLI argument parsing, message consumers). Once data enters your domain layer, trust it:
|
||||
|
||||
```go
|
||||
// Handler layer — validate here
|
||||
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateOrderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UserID == "" {
|
||||
http.Error(w, "user_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
http.Error(w, "at least one item required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Domain layer trusts this data is valid
|
||||
order, err := h.service.CreateOrder(r.Context(), req.UserID, req.Items)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Don't re-validate the same data at every layer — it clutters the code and violates DRY.
|
||||
|
||||
## Make Illegal States Unrepresentable
|
||||
|
||||
Use Go's type system to prevent invalid states from being expressible in code:
|
||||
|
||||
```go
|
||||
// Bad — status is a raw string, anything goes
|
||||
type Order struct {
|
||||
Status string // "pending"? "PENDING"? "active"? anything?
|
||||
}
|
||||
|
||||
// Good — typed enum constrains the values
|
||||
type OrderStatus int
|
||||
|
||||
const (
|
||||
OrderStatusUnknown OrderStatus = iota // 0 = invalid
|
||||
OrderStatusDraft // 1
|
||||
OrderStatusConfirmed // 2
|
||||
OrderStatusShipped // 3
|
||||
)
|
||||
|
||||
type Order struct {
|
||||
Status OrderStatus
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Bad — email is a raw string, could be anything
|
||||
func SendEmail(to string, body string) error { ... }
|
||||
|
||||
// Good — validated type enforces the constraint
|
||||
type Email struct {
|
||||
address string // unexported: can only be created via constructor
|
||||
}
|
||||
|
||||
func NewEmail(raw string) (Email, error) {
|
||||
if !isValidEmail(raw) {
|
||||
return Email{}, fmt.Errorf("invalid email: %s", raw)
|
||||
}
|
||||
return Email{address: raw}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Detailed Architecture Guides
|
||||
|
||||
For projects that warrant a formal architecture (typically 5K+ lines), see the dedicated guides:
|
||||
|
||||
- [Domain-Driven Design (DDD)](./ddd.md) — aggregates, value objects, bounded contexts
|
||||
- [Clean Architecture](./clean-architecture.md) — use cases, dependency rule, layered adapters
|
||||
- [Hexagonal Architecture](./hexagonal-architecture.md) — ports, adapters, domain core isolation
|
||||
|
||||
## 12-Factor App Principles
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-project-layout` for 12-Factor App conventions.
|
||||
|
||||
## Explicit Over Implicit
|
||||
|
||||
Go favors explicitness. Code should express its intent clearly without requiring the reader to know hidden conventions:
|
||||
|
||||
```go
|
||||
// Bad — implicit behavior hidden in struct tags and reflection
|
||||
type Config struct {
|
||||
Port int `default:"8080"`
|
||||
}
|
||||
|
||||
// Good — explicit defaults visible in code
|
||||
func NewConfig() Config {
|
||||
return Config{Port: 8080}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Bad — implicit dependency via global
|
||||
func HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
user := globalDB.FindUser(r.Context(), userID) // where does globalDB come from?
|
||||
}
|
||||
|
||||
// Good — explicit dependency via injection
|
||||
func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
user := h.db.FindUser(r.Context(), userID) // clear: db is a field on Handler
|
||||
}
|
||||
```
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-project-layout` skill for directory structure and layout patterns.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Clean Architecture in Go
|
||||
|
||||
## When to Use
|
||||
|
||||
Apply clean architecture when you need strong separation between business logic and infrastructure — typically medium-to-large services (2K+ lines) where testability, framework independence, and clear dependency direction matter. Do NOT use for small CLI tools or scripts.
|
||||
|
||||
## The Dependency Rule
|
||||
|
||||
Dependencies point inward only. Inner layers never import outer layers.
|
||||
|
||||
```
|
||||
Frameworks & Drivers → Interface Adapters → Use Cases → Entities
|
||||
(HTTP, DB, gRPC) (handlers, repos) (app logic) (domain)
|
||||
```
|
||||
|
||||
Each layer defines interfaces for what it needs. Outer layers implement those interfaces.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
order-service/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Wiring only — builds the dependency graph
|
||||
├── internal/
|
||||
│ ├── entity/
|
||||
│ │ ├── order.go # Order entity + business rules
|
||||
│ │ ├── item.go # OrderItem
|
||||
│ │ └── status.go # OrderStatus enum
|
||||
│ ├── order/
|
||||
│ │ ├── place.go # PlaceOrderUseCase
|
||||
│ │ ├── cancel.go # CancelOrderUseCase
|
||||
│ │ └── port.go # Interfaces this use case depends on
|
||||
│ ├── adapter/
|
||||
│ │ ├── handler/
|
||||
│ │ │ └── order_handler.go # HTTP handler — calls use cases
|
||||
│ │ ├── repository/
|
||||
│ │ │ └── order_postgres.go # OrderRepository — implements port
|
||||
│ │ └── gateway/
|
||||
│ │ └── payment_client.go # External payment API client
|
||||
│ └── infrastructure/
|
||||
│ ├── router.go # HTTP router setup
|
||||
│ ├── database.go # DB connection
|
||||
│ └── config.go # Config loading
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Entity — pure domain logic, zero dependencies
|
||||
|
||||
```go
|
||||
// internal/entity/order.go
|
||||
package entity
|
||||
|
||||
type Order struct {
|
||||
ID string
|
||||
Items []Item
|
||||
Status OrderStatus
|
||||
}
|
||||
|
||||
func (o *Order) Cancel() error {
|
||||
if o.Status == StatusShipped {
|
||||
return ErrCannotCancelShipped
|
||||
}
|
||||
o.Status = StatusCancelled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Order) Total() int64 {
|
||||
var sum int64
|
||||
for _, item := range o.Items {
|
||||
sum += item.Price * int64(item.Quantity)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
```
|
||||
|
||||
### Use Case — orchestrates business operations
|
||||
|
||||
```go
|
||||
// internal/order/port.go
|
||||
package order
|
||||
|
||||
// Ports — interfaces defined by the use case, implemented by adapters
|
||||
type OrderRepository interface {
|
||||
Save(ctx context.Context, order *entity.Order) error
|
||||
FindByID(ctx context.Context, id string) (*entity.Order, error)
|
||||
}
|
||||
|
||||
type PaymentGateway interface {
|
||||
Charge(ctx context.Context, orderID string, amount int64) error
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// internal/order/place.go
|
||||
package order
|
||||
|
||||
type PlaceOrderUseCase struct {
|
||||
orders OrderRepository
|
||||
payments PaymentGateway
|
||||
}
|
||||
|
||||
func NewPlaceOrderUseCase(orders OrderRepository, payments PaymentGateway) *PlaceOrderUseCase {
|
||||
return &PlaceOrderUseCase{orders: orders, payments: payments}
|
||||
}
|
||||
|
||||
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, orderID string) error {
|
||||
order, err := uc.orders.FindByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding order: %w", err)
|
||||
}
|
||||
|
||||
if err := uc.payments.Charge(ctx, order.ID, order.Total()); err != nil {
|
||||
return fmt.Errorf("charging payment: %w", err)
|
||||
}
|
||||
|
||||
order.Status = entity.StatusPlaced
|
||||
return uc.orders.Save(ctx, order)
|
||||
}
|
||||
```
|
||||
|
||||
### Adapter — implements a port
|
||||
|
||||
```go
|
||||
// internal/adapter/repository/order_postgres.go
|
||||
package repository
|
||||
|
||||
type OrderPostgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewOrderPostgres(db *sql.DB) *OrderPostgres {
|
||||
return &OrderPostgres{db: db}
|
||||
}
|
||||
|
||||
func (r *OrderPostgres) FindByID(ctx context.Context, id string) (*entity.Order, error) {
|
||||
// SQL query, scan into entity.Order
|
||||
}
|
||||
|
||||
func (r *OrderPostgres) Save(ctx context.Context, order *entity.Order) error {
|
||||
// SQL upsert
|
||||
}
|
||||
```
|
||||
|
||||
### Handler — translates HTTP to use case calls
|
||||
|
||||
```go
|
||||
// internal/adapter/handler/order_handler.go
|
||||
package handler
|
||||
|
||||
type OrderHandler struct {
|
||||
placeOrder *usecase.PlaceOrderUseCase
|
||||
}
|
||||
|
||||
func (h *OrderHandler) HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
|
||||
orderID := chi.URLParam(r, "id")
|
||||
|
||||
if err := h.placeOrder.Execute(r.Context(), orderID); err != nil {
|
||||
// Map domain errors to HTTP status codes
|
||||
http.Error(w, err.Error(), mapToHTTPStatus(err))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
```
|
||||
|
||||
## Key Principle
|
||||
|
||||
Interfaces live where they are consumed, not where they are implemented. The `usecase/order/port.go` file defines `OrderRepository` — the adapter in `adapter/repository/` implements it. This keeps the use case layer free from infrastructure imports.
|
||||
|
||||
## Wiring
|
||||
|
||||
All dependency construction happens in `cmd/server/main.go`. → See `samber/cc-skills-golang@golang-dependency-injection` skill for DI library alternatives.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Data Handling Patterns
|
||||
|
||||
## Iterators for Large Data (Go 1.23+)
|
||||
|
||||
Process large datasets without allocating everything into memory:
|
||||
|
||||
```go
|
||||
// Bad — loads all rows into memory
|
||||
func AllUsers(db *sql.DB) ([]User, error) {
|
||||
rows, err := db.Query("SELECT * FROM users")
|
||||
// ... scan all into slice
|
||||
}
|
||||
|
||||
// Good — iterator yields one at a time
|
||||
func AllUsers(db *sql.DB) iter.Seq2[User, error] {
|
||||
return func(yield func(User, error) bool) {
|
||||
rows, err := db.Query("SELECT * FROM users")
|
||||
if err != nil {
|
||||
yield(User{}, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
|
||||
yield(User{}, err)
|
||||
return
|
||||
}
|
||||
if !yield(u, nil) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Large Transfers
|
||||
|
||||
When transferring large data between services (e.g., 1M rows from DB, 1M rows in HTTP response), use streaming patterns with iterators or `github.com/samber/ro` to prevent OOM:
|
||||
|
||||
```go
|
||||
// Stream JSON array to HTTP response — constant memory
|
||||
func (h *Handler) ExportUsers(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte("["))
|
||||
|
||||
first := true
|
||||
for user, err := range h.repo.AllUsers(r.Context()) {
|
||||
if err != nil {
|
||||
slog.Error("streaming user", "error", err)
|
||||
return
|
||||
}
|
||||
if !first {
|
||||
w.Write([]byte(","))
|
||||
}
|
||||
json.NewEncoder(w).Encode(user)
|
||||
first = false
|
||||
}
|
||||
|
||||
w.Write([]byte("]"))
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# Domain-Driven Design (DDD) in Go
|
||||
|
||||
## When to Use
|
||||
|
||||
Apply DDD when the business domain is complex enough that the code structure should mirror the business model — typically services with 5K+ lines, multiple bounded contexts, or rich business rules. Do NOT use for simple CRUD apps or CLI tools.
|
||||
|
||||
## Building Blocks
|
||||
|
||||
| Concept | Go Mapping | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **Entity** | Struct with identity field | Has unique ID, mutable state, lifecycle |
|
||||
| **Value Object** | Immutable struct, compared by value | No identity — represents a measurement, quantity, or descriptor |
|
||||
| **Aggregate** | Entity + child entities/value objects | Consistency boundary — all mutations go through the root |
|
||||
| **Repository** | Interface in domain, impl in infrastructure | Persistence abstraction for aggregates |
|
||||
| **Domain Service** | Function or struct in domain package | Logic that spans multiple aggregates |
|
||||
| **Domain Event** | Struct describing a fact that happened | Decouples bounded contexts |
|
||||
|
||||
## Project Structure
|
||||
|
||||
Organize by **bounded context**, grouping domain, application, and adapters vertically. This scales across multiple contexts and clarifies ownership.
|
||||
|
||||
```
|
||||
order-service/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Wiring only
|
||||
├── internal/
|
||||
│ ├── order/ # Bounded context: Order
|
||||
│ │ ├── domain/
|
||||
│ │ │ ├── order.go # Order aggregate root
|
||||
│ │ │ ├── item.go # OrderItem entity
|
||||
│ │ │ ├── status.go # OrderStatus enum
|
||||
│ │ │ ├── repository.go # OrderRepository interface
|
||||
│ │ │ └── events.go # OrderPlaced, OrderShipped events
|
||||
│ │ ├── application/
|
||||
│ │ │ ├── place_order.go # PlaceOrderHandler (command)
|
||||
│ │ │ └── get_order.go # GetOrderHandler (query)
|
||||
│ │ └── adapters/
|
||||
│ │ ├── persistence/
|
||||
│ │ │ └── postgres.go # OrderRepository implementation
|
||||
│ │ └── http/
|
||||
│ │ └── handler.go # HTTP transport
|
||||
│ ├── billing/ # Bounded context: Billing (another example)
|
||||
│ │ ├── domain/
|
||||
│ │ ├── application/
|
||||
│ │ └── adapters/
|
||||
│ ├── shared/
|
||||
│ │ └── money.go # Value object reused across contexts
|
||||
│ └── events/
|
||||
│ └── publisher.go # Shared event bus (infrastructure)
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
**Key principles:**
|
||||
|
||||
- Group each bounded context **vertically** (domain → application → adapters), not by technical role
|
||||
- Use `adapters/` instead of `infrastructure/` to be explicit about Hexagonal Architecture
|
||||
- Make cross-context boundaries explicit (see **Bounded Contexts** section below)
|
||||
- Place shared infrastructure (event bus, logging) at `internal/{shared}/` or `internal/events/`
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Value Object — Money
|
||||
|
||||
```go
|
||||
// internal/domain/shared/money.go
|
||||
package shared
|
||||
|
||||
type Money struct {
|
||||
amount int64 // cents — avoids float precision issues
|
||||
currency string
|
||||
}
|
||||
|
||||
func NewMoney(amount int64, currency string) (Money, error) {
|
||||
if currency == "" {
|
||||
return Money{}, errors.New("currency is required")
|
||||
}
|
||||
return Money{amount: amount, currency: currency}, nil
|
||||
}
|
||||
|
||||
func (m Money) Add(other Money) (Money, error) {
|
||||
if m.currency != other.currency {
|
||||
return Money{}, fmt.Errorf("cannot add %s to %s", other.currency, m.currency)
|
||||
}
|
||||
return Money{amount: m.amount + other.amount, currency: m.currency}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Aggregate Root — Order
|
||||
|
||||
```go
|
||||
// internal/domain/order/order.go
|
||||
package order
|
||||
|
||||
type Order struct {
|
||||
id string
|
||||
items []Item
|
||||
status Status
|
||||
total shared.Money
|
||||
}
|
||||
|
||||
func NewOrder(id string) *Order {
|
||||
return &Order{id: id, status: StatusDraft}
|
||||
}
|
||||
|
||||
// All mutations go through the aggregate root
|
||||
func (o *Order) AddItem(item Item) error {
|
||||
if o.status != StatusDraft {
|
||||
return ErrOrderNotEditable
|
||||
}
|
||||
o.items = append(o.items, item)
|
||||
return o.recalculateTotal()
|
||||
}
|
||||
|
||||
func (o *Order) Place() (OrderPlaced, error) {
|
||||
if len(o.items) == 0 {
|
||||
return OrderPlaced{}, ErrEmptyOrder
|
||||
}
|
||||
o.status = StatusPlaced
|
||||
return OrderPlaced{OrderID: o.id, Total: o.total}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Interface — defined in domain
|
||||
|
||||
```go
|
||||
// internal/order/domain/repository.go
|
||||
package domain
|
||||
|
||||
type Repository interface {
|
||||
Save(ctx context.Context, order *Order) error
|
||||
FindByID(ctx context.Context, id string) (*Order, error)
|
||||
}
|
||||
```
|
||||
|
||||
The implementation lives in `internal/order/adapters/persistence/postgres.go` and depends on the domain — never the reverse.
|
||||
|
||||
### Application Service — orchestrates a use case
|
||||
|
||||
```go
|
||||
// internal/order/application/place_order.go
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"myapp/internal/order/domain"
|
||||
)
|
||||
|
||||
type PlaceOrderHandler struct {
|
||||
orders domain.Repository
|
||||
events EventPublisher
|
||||
}
|
||||
|
||||
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
|
||||
order, err := h.orders.FindByID(ctx, cmd.OrderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding order: %w", err)
|
||||
}
|
||||
|
||||
evt, err := order.Place()
|
||||
if err != nil {
|
||||
return fmt.Errorf("placing order: %w", err)
|
||||
}
|
||||
|
||||
if err := h.orders.Save(ctx, order); err != nil {
|
||||
return fmt.Errorf("saving order: %w", err)
|
||||
}
|
||||
|
||||
return h.events.Publish(ctx, evt)
|
||||
}
|
||||
```
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
Each bounded context maps to a top-level package under `internal/` with its own domain, application, and adapters. Contexts communicate through domain events or explicit anti-corruption layers — never by importing each other's internal types directly.
|
||||
|
||||
**Anti-corruption layer example:** If `billing/` needs to consume an `order.OrderPlaced` event, translate it to a billing-specific type:
|
||||
|
||||
```go
|
||||
// internal/billing/adapters/events/order_events.go
|
||||
package events
|
||||
|
||||
import (
|
||||
"myapp/internal/events"
|
||||
"myapp/internal/billing/domain"
|
||||
)
|
||||
|
||||
type OrderPlacedSubscriber struct {
|
||||
invoices domain.InvoiceRepository
|
||||
}
|
||||
|
||||
// Receives order.OrderPlaced, translates to billing domain
|
||||
func (s *OrderPlacedSubscriber) OnOrderPlaced(evt events.OrderPlaced) error {
|
||||
// Translate and create invoice
|
||||
return s.invoices.Create(evt.OrderID, evt.Total)
|
||||
}
|
||||
```
|
||||
|
||||
This prevents billing from depending on order's internal types.
|
||||
|
||||
For large systems, each context can be its own Go module in a workspace (`go.work`). See the `samber/cc-skills-golang@golang-project-layout` skill for workspace setup.
|
||||
|
||||
## Wiring
|
||||
|
||||
Wire dependencies in `cmd/server/main.go` using manual constructor injection. → See `samber/cc-skills-golang@golang-dependency-injection` skill for DI library alternatives.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user