Claude CodeでGoを書くと共有mapが壊れる:goroutineとエラー値の任せ方
Claude CodeにGoを任せると共有mapへの並行書き込みやcontext切れが起きがち。goroutine/channel、error値、go test -raceで安全に進める手順を実例で。
「タスクAPIにhandlerを1本足して」。Goの小さなサービスで、僕はClaude Codeにそれだけ頼みました。
返ってきたコードは動きました。コンパイルも通った。でもgo test -raceを回した瞬間、真っ赤になったんです。共有しているmapに、複数のgoroutineが同時に書き込んでいた。テストすら無かったので、最初は赤くなる場所すら無かったわけですが。
Goは書き味がシンプルです。だからこそ、設計のズレが「小さな差分」に見えて通り抜けてしまう。mapへの並行書き込み、contextの取り違え、errorの握りつぶし。どれも数行で、レビューでうっかり見逃します。Claude Codeは速い分、そういう不具合も速く入れてくる。今日はそこを止める話です。
この記事の要点
- Goでまずやるのはコード生成ではなく「リポジトリの地図作り」。
go.modの境界とテストの有無を先に押さえる - 共有
mapやsliceへのgoroutine同時書き込みはgo test -raceで必ず炙り出す。-raceは完了条件に入れる errorはGoの戻り値。fmt.Errorf("%w", err)で包み、呼ぶ側はerrors.Isで判定するとClaude Codeの実装がブレないcontextは下位層でcontext.Background()を作り直さず、呼び出し元のものを渡す。これだけでキャンセル漏れが激減する- 標準ライブラリで足りる場面が多いので、外部依存の追加は「理由を説明してから」をルールにする
まず動くものを1つ:標準ライブラリだけのタスクAPI
説明より先に、コピペで動くものを置きます。net/httpとencoding/jsonだけ、外部依存ゼロのタスクAPIです。POST /tasksで作ってGET /tasksで一覧。後の章の「並行処理の事故」も「テストの書き方」も、全部このコードを土台に話します。
cmd/taskapi/main.goに丸ごと貼ってください。
// cmd/taskapi/main.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"time"
)
// ErrValidation は入力エラー。呼ぶ側は errors.Is で判定する
var ErrValidation = errors.New("validation failed")
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
// Store はメモリ上の保管庫。mu で map を守る
type Store struct {
mu sync.Mutex
next int
tasks map[string]Task
}
func NewStore() *Store {
return &Store{next: 1, tasks: make(map[string]Task)}
}
func (s *Store) Create(ctx context.Context, title string) (Task, error) {
// 呼び出し元がキャンセル済みなら、ここで手早く降りる
select {
case <-ctx.Done():
return Task{}, fmt.Errorf("create task canceled: %w", ctx.Err())
default:
}
title = strings.TrimSpace(title)
if title == "" {
return Task{}, fmt.Errorf("%w: title is required", ErrValidation)
}
s.mu.Lock()
defer s.mu.Unlock()
task := Task{
ID: fmt.Sprintf("task-%06d", s.next),
Title: title,
Status: "open",
CreatedAt: time.Now().UTC(),
}
s.next++
s.tasks[task.ID] = task
return task, nil
}
func (s *Store) List(ctx context.Context) ([]Task, error) {
select {
case <-ctx.Done():
return nil, fmt.Errorf("list tasks canceled: %w", ctx.Err())
default:
}
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks, nil
}
func main() {
// Ctrl+C を受けたら ctx 経由で全体に伝える
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := NewStore()
mux := http.NewServeMux()
// "GET /tasks" 形式の method 付きルーティングは Go 1.22 以降
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks, err := store.List(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, tasks)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
task, err := store.Create(r.Context(), body.Title)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("taskapi listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
// シグナルが来るまで待ち、来たら猶予つきで閉じる
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// error の種類で HTTP ステータスを振り分ける
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrValidation):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusRequestTimeout, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
動かすのはこれだけ。Go 1.22以降が入っていれば動きます(執筆時点の最新安定版はGo 1.26)。
go mod init example.com/taskapi
mkdir -p cmd/taskapi
go run ./cmd/taskapi
# 別ターミナルで
curl -s http://localhost:8080/tasks
curl -s -X POST http://localhost:8080/tasks \
-H 'content-type: application/json' \
-d '{"title":"write table-driven tests"}'
この100行ちょっとに、今日の論点がほぼ詰まっています。Store.muが共有mapの門番、ctxがキャンセルの通り道、ErrValidationがerror設計の起点。順に見ていきます。
いきなり書かせず、リポジトリの地図を作らせる
新人にいきなり「本番のhandler直して」と言わないのと同じで、Claude Codeにも最初の10分は調査だけさせます。どこがコマンドで、どこが内部パッケージで、どのgo.modが依存を握っていて、CIが何を見ているか。この短い一覧を僕は「地図」と呼んでいます。
モジュールは依存関係を管理する単位、ワークスペースは複数モジュールを同時に扱う足場、contextはキャンセルや期限を関数間で伝える仕組み。初出の用語をこの粒度で言い換えておくと、Claude Codeにも人間のレビュアーにも話が通ります。
まず自分の手で現在地を確認します。
go env GOMOD GOWORK
go list -m
go list ./...
go test ./...
そのうえで、Claude Codeへの最初の依頼はスコープを絞ります。「編集はまだしないで」と先に釘を刺すのがコツです。
このGoリポジトリを調査してください。まだファイルは編集しないでください。
確認してほしいこと:
- go.mod と go.work の有無
- cmd, internal, pkg, api, migrations, testdata の役割
- public な型・関数と、変更すると互換性が壊れる箇所
- 既存のエラーハンドリング方針
- context.Context を受け取る境界
- go test ./... の結果
- 次の1タスクで安全に触れる最小ファイル
パッケージ構成の判断は公式のOrganizing a Go module、モジュールの細かい挙動はGo Modules Referenceを基準にします。Claude Code自体の動きは公式ドキュメントが早いです。
この「最初に足場を固定する」型は、Goに限った話ではありません。汎用の手順は既存コードベースの地図作りとCLAUDE.mdベストプラクティスに分けて書いてあります。地図を先に渡すと、Claude Codeが関係ないファイルを触りにくくなります。
go.modとgo.workを勝手に増やさせない
go.modは、そのモジュールの名前・Goバージョン・依存モジュールを記録するファイルです。小さなアプリなら1つで十分。一方、API・CLI・共有ライブラリを別モジュールとして同時に編集したいときはgo.workで複数モジュールをまとめて扱えます。公式チュートリアルはGetting started with multi-module workspacesです。
依存を足す前に、毎回ここを見ます。
go env GOMOD GOWORK
go list -m all
go mod tidy
go test ./...
複数モジュールを本当に同時に触るときだけ、ワークスペースを切ります。
mkdir -p services/taskapi tools/taskctl
( cd services/taskapi && go mod init example.com/acme/taskapi )
( cd tools/taskctl && go mod init example.com/acme/taskctl )
go work init ./services/taskapi ./tools/taskctl
go work use ./services/taskapi ./tools/taskctl
go work sync
ここで一度やらかしました。go.workを「依存解決の裏技」に使ってしまったんです。自分のローカルではgo.workのおかげで通る。なのにCIと同僚の環境では壊れる。原因が分からず半日溶かしました。チームでgo.workをコミットするのか、個人の作業用に留めるのかを先に決める。そしてClaude Codeへの指示に「新しいモジュールや依存を増やす前に、理由を先に説明して」と必ず書く。これで再発しなくなりました。
errorは戻り値:%wで包み、errors.Isで判定する
Goには例外がありません。errorは普通の戻り値で、関数は(結果, error)を返します。だからこそ、握りつぶすのも簡単です。Claude Codeは放っておくとif err != nil { return err }をただ積むか、最悪_ = errで捨てます。
僕が落ち着いた書き方はこうです。下位層ではfmt.Errorf("文脈: %w", err)で元のエラーを包む。%wで包むと、呼ぶ側がerrors.Isで正体を当てられます。上のAPIコードのwriteErrorがまさにそれで、ErrValidationなら400、context.Canceledなら408、それ以外は500、と一箇所で振り分けています。
プロンプトには、抽象論ではなく契約として書きます。
error は fmt.Errorf("...: %w", err) で文脈を足して包む。
呼び出し側は errors.Is / errors.As で種類を判定する。
error を握りつぶす (_ = err や黙って return nil) のは禁止。
センチネル error (ErrValidation など) を定義し、HTTP ステータスはその種類で決める。
「ログに出すだけでreturn nil」もよくある事故です。エラーを呑んだ関数は、呼ぶ側からは成功に見える。%wで上まで返す癖をつけるだけで、原因の特定がぐっと速くなります。
table-driven testを完了条件にする
「テストもお願いします」だと、Claude Codeはハッピーパス1本だけ書いて満足しがちです。Goにはtable-driven testという定番があります。入力と期待値を表のように並べて、同じテスト関数で回す書き方です。
cmd/taskapi/store_test.goに置けば、上のAPIにそのまま効きます。
// cmd/taskapi/store_test.go
package main
import (
"context"
"errors"
"fmt"
"testing"
)
func TestStoreCreate(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{name: "valid title", title: "ship release notes"},
{name: "blank title", title: " ", wantErr: ErrValidation},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
task, err := store.Create(context.Background(), tt.title)
if tt.wantErr != nil {
// %w で包まれていても errors.Is なら正体を当てられる
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.ID == "" || task.Status != "open" {
t.Fatalf("unexpected task: %+v", task)
}
})
}
}
func TestStoreCreateHonorsCanceledContext(t *testing.T) {
store := NewStore()
ctx, cancel := context.WithCancel(context.Background())
cancel() // 先にキャンセルしておく
_, err := store.Create(ctx, "will not be created")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func BenchmarkStoreCreate(b *testing.B) {
store := NewStore()
ctx := context.Background()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := store.Create(ctx, fmt.Sprintf("task-%d", i)); err != nil {
b.Fatal(err)
}
}
}
検証コマンドもまとめてClaude Codeの完了条件に入れます。ここを口頭の「動きました」で済ませないのが肝心です。
gofmt -w cmd/taskapi
go test ./...
go test -race ./...
go test -run TestStoreCreate -count=1 ./...
go test -bench=. -benchmem ./...
testingパッケージは単体テストとベンチマークの土台です。詳細はtesting packageを見てください。ベンチマークは「速くなった気がする」を潰すための測定で、b.N回ぶんをGo側が自動調整して回します。
context cancellationを境界で落とさない
context.Contextは、リクエストのキャンセル・期限・値を関数間で運ぶ仕組みです。サーバーに入ったリクエストから下流の呼び出しへ、同じctxをバケツリレーする。これが基本形です。詳しくはcontext packageに書かれています。
Claude Codeでいちばん多い取り違えが、handlerではr.Context()を受けているのに、serviceやrepositoryでcontext.Background()を作り直すパターンです。こうなると、クライアントが切断してもDB問い合わせや外部API呼び出しが止まりません。リレーが途中で切れているわけです。プロンプトには「下位層で新しいcontext.Background()を作らず、呼び出し元のctxを渡す」と一行入れます。
もう一つはWithTimeoutのcancelを呼び忘れること。派生ContextのCancelFuncは、関連リソースを解放するために必ず呼びます。上のAPIでもdefer cancel()を入れています。「context.WithTimeoutを使った箇所はdefer cancel()を付け、go vetでCancelFunc漏れを確認して」と頼むと、ここが安定します。
goroutineと共有map:go test -raceで炙り出す
冒頭の事故の正体がこれです。Goのgoroutineは軽くて、つい増やしたくなる。でも共有データを同時に書くとdata raceが起きます。data raceは、複数のgoroutineが同じ変数に同時アクセスし、少なくとも一方が書き込みで、同期が無い状態のこと。怖いのは、運が良いと動いてしまう点です。
たとえば、こういう「ステータス別の件数集計」をgoroutineで並列化したコードが返ってきたとします。一見それっぽい。
// 危険: 共有 map に複数 goroutine が同時書き込み
func CountByStatusBad(tasks []Task) map[string]int {
counts := make(map[string]int)
var wg sync.WaitGroup
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
counts[task.Status]++ // ここで race
}()
}
wg.Wait()
return counts
}
これをgo test -raceで回すと一発でDATA RACEが出ます。修正は単純で、sync.Mutexでmapを囲うだけ。さっきのStoreと同じ考え方です。
// 安全: mu で共有 map を守る
func CountByStatus(tasks []Task) map[string]int {
counts := make(map[string]int)
var (
mu sync.Mutex
wg sync.WaitGroup
)
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counts[task.Status]++
mu.Unlock()
}()
}
wg.Wait()
return counts
}
ここで大事な注意が一つ。go test -raceは、実際に実行されたコードパスのraceしか見つけられません。テストが通っていない経路のraceは素通りします。だからテストの網羅とセットで効く。公式のData Race Detectorも同じことを言っています。
Claude Codeへの指示は「goroutineを増やして高速化して」では足りません。共有変数の保護、channelをcloseする責任は誰にあるか、contextキャンセルの伝播、WaitGroupのDone漏れ、そしてgo test -raceの結果まで、ひとまとめで確認させます。レビューの観点はClaude Codeコードレビュー実践にもつなげられます。
安全に任せるためのプロンプト雛形
ここまでを1枚にまとめると、こうなります。編集範囲・守る契約・検証コマンド・禁止事項。この4点を毎回書くだけで、Claude Codeの暴走がだいぶ収まります。
目的: taskapi に POST /tasks の validation とテストを追加する。
編集してよい範囲:
- cmd/taskapi/main.go
- cmd/taskapi/store_test.go
守ること:
- go.mod の module path を変更しない
- 新しい外部依存を追加しない
- public な型名と JSON field 名を変えない
- context.Context は handler から service まで伝播する(下位で Background を作らない)
- error は fmt.Errorf("%w") で包み、呼び出し側は errors.Is で判定する
- 共有 map / slice への並行書き込みは Mutex で守る
完了条件:
- gofmt -w cmd/taskapi
- go test ./...
- go test -race ./...
- 変更内容と残リスクを短く報告する
依存を増やしたくなったときは、実装より先に理由を出させます。
外部ライブラリが必要だと判断した場合は、実装前に、候補・理由・
標準ライブラリで代替できない理由・go.mod への影響を説明してください。
承認なしに go get しないでください。
この一文があるだけで、Claude Codeが勝手にCLIフレームワークやrouter、ORM、mockライブラリを足す事故が減ります。Goは標準ライブラリで進められる場面が多いので、依存追加は常にレビュー対象にしておくのが楽です。
実務で効く場面と、それぞれの落とし穴
僕が実際にClaude Codeへ任せて、効果と事故の両方を見た順に並べます。
- 既存APIへの小さなendpoint追加。
net/httpや既存routerに1本足し、service層・table-driven test・go test -raceまで同じタスクに含める。落とし穴は、handlerだけ増やしてserviceの境界やエラー形式を崩すこと。 - CLIとAPIの共通ロジック化。運用担当がCLIで叩く処理と、管理画面のAPIが同じ業務ルールを使うなら
internal/serviceへ寄せる。落とし穴は、CLI用にコピーした処理が後でAPIとズレること。CLI側の作法はClaude CodeでCLIツール開発に分けてあります。 - 遅い処理の並行化。外部APIを3本呼ぶダッシュボード集計などはgoroutineが効く。落とし穴は、共有sliceやmapへの同時書き込み、channelを複数箇所でcloseする設計、キャンセルを無視したgoroutine漏れ。
- 性能改善。「速くして」の前に
go test -bench=. -benchmemで基準を取る。落とし穴は、測定せずにキャッシュやgoroutineを足し、メモリと複雑さだけ増やすこと。 - multi-moduleリポジトリの変更。
go.workでローカル編集は楽になるが、go env GOWORKとgo test ./...の実行場所を確認する。落とし穴は、ローカルのgo.workだけで通る状態を本番品質と勘違いすること。
| 場面 | Claude Codeに任せやすさ | 必ず付ける完了条件 |
|---|---|---|
| endpoint追加 | 高い | table-driven test + go test -race |
| CLI/API共通化 | 中くらい | service層への集約 + 既存テスト緑 |
| 並行化 | 低い(要レビュー) | go test -race + キャンセル伝播の確認 |
| 性能改善 | 中くらい | go test -bench の前後比較 |
| multi-module | 低い | go env GOWORK + CI再現性の確認 |
並行化だけは、僕はいまだに人の目を厚めに入れています。-raceが緑でも安心しきらない、くらいでちょうどいいです。
よくある質問
Q. Claude CodeはどのGoバージョンを前提に書きますか。
A. 指定しないと最近の安定版前提で書きがちです。この記事のGET /tasks形式のルーティングやr.PathValueはGo 1.22以降の機能。プロンプトに「Go 1.xx想定」と明記し、go.modのgoディレクティブと食い違わせないのが安全です。
Q. go test -raceが緑なら、並行処理は安全と考えていいですか。
A. いいえ。-raceは実際に走ったコードパスのraceしか検出しません。通っていない分岐のraceは見逃します。テストの網羅とセットで初めて効く、と考えてください。
Q. errorは全部fmt.Errorf("%w")で包むべきですか。
A. 呼び出し側が種類を判定したい、あるいはどこで起きたか追いたいなら包みます。逆に内部実装を外へ漏らしたくない境界では、あえて包まずに別のセンチネルerrorへ変換することもあります。errors.Isで判定する設計か、で決めるのが分かりやすいです。
Q. 依存ライブラリの追加をClaude Codeに任せても大丈夫ですか。
A. 自動では任せません。「実装前に理由とgo.modへの影響を説明し、承認なしにgo getしない」と先に制約を置きます。Goは標準ライブラリで足りる場面が多く、依存はそのまま運用コストになるためです。
Q. context.Background()はいつ使っていいですか。
A. main関数やトップレベルのバックグラウンド処理など、上流に親ctxが存在しない起点だけです。handlerやserviceの内部で新たに作り直すと、リクエストのキャンセルが下流に伝わらなくなります。
まとめ:速さに事故を合わせない
Claude CodeでGoを安定させるコツは、地味な順番を崩さないことに尽きます。最初にリポジトリの地図を作る。go.modとgo.workの境界を確認する。API/CLIの変更はservice層に寄せる。そしてtable-driven test、contextの伝播、go test -race、benchmarkを「完了条件」に書く。
冒頭で僕は、共有mapへの並行書き込みを-raceの赤で知りました。あのとき効いたのは、賢いモデルでも凝ったプロンプトでもなく、gofmt・go test ./...・go test -race ./...・go test -bench=. -benchmemをタスクの完了条件に入れただけのことです。それだけで、Claude Codeの報告が「実装しました」から「何を確認して、どこに残リスクがあるか」に変わりました。Goはシンプルだから速い。その速さに、不具合まで合わせないようにする。それが今の僕のやり方です。
個人でプロンプトと検証コマンドを固めるなら無料チートシートが手早いです。CLAUDE.md・権限・レビュー観点・Go向けチェックリストまでまとめて整えるなら教材・テンプレート一覧へ。チームのGo API/CLI/CI/レビュー規約ごと導入したいときはClaude Code研修・導入相談で、実リポジトリ前提に詰められます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。