Claude CodeでGo開発を進める実践ガイド:go.mod・テスト・並行処理まで
Claude CodeでGo開発を安全に進める手順。go.mod、go work、API/CLI、テスト、並行処理を実例で解説。
Claude CodeでGo開発を任せるとき、いきなり「APIを作って」「テストを書いて」と頼むと、動くコードは出ても、既存のgo.mod、パッケージ境界、エラー方針、contextの伝播、並行処理の安全性が崩れやすくなります。Goは書き味がシンプルな分、設計のズレが小さな差分に見えてしまうのが厄介です。
この記事では、Claude CodeをGoの実装係ではなく、リポジトリ調査、設計変更、テスト、検証まで進める開発エージェントとして使う手順をまとめます。モジュールは依存関係を管理する単位、ワークスペースは複数モジュールを同時に扱う足場、contextはキャンセルや期限を関数間で伝える仕組みです。初出の用語をこの粒度で言い換えておくと、Claude Codeにも人間のレビュアーにも伝わりやすくなります。
Masaが小さなGo製タスクAPIをClaude Codeで改修したとき、最初は「handlerを追加して」とだけ依頼しました。その結果、HTTP handlerは動いたものの、テストがなく、キャンセル時の扱いも曖昧で、共有mapをgoroutineから書き込む危険な差分が出ました。依頼を「リポジトリを読んで、go test -raceまで通す」形に変えたところ、レビュー観点がかなり明確になりました。
まずリポジトリの地図を作る
Go開発で最初にやるべきことは、コード生成ではなく地図作りです。地図とは、どのディレクトリがコマンドで、どこが内部パッケージで、どのgo.modが依存を管理していて、CIが何を確認しているかの短い一覧です。Claude Codeには、最初の10分でこの地図を作らせます。
pwd
find . -name go.mod -o -name go.work -o -name "*.go" | sort | sed -n '1,120p'
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タスクで安全に触れる最小ファイル
Goの公式情報としては、パッケージ構成はOrganizing a Go module、モジュールの詳細はGo Modules Referenceを基準にします。Claude Codeの基本動作はClaude Code overviewを参照してください。
flowchart LR
A["repo map"] --> B["go.mod / go.work"]
B --> C["API or CLI change"]
C --> D["table-driven tests"]
D --> E["go test -race"]
E --> F["benchmark and review"]
関連する初回調査の型は既存コードベースの地図作りとCLAUDE.mdベストプラクティスにまとめています。Goでも同じで、最初に足場を固定すると、Claude Codeが余計なファイルを触りにくくなります。
go.modとgo workを勝手に増やさせない
go.modは、そのモジュールの名前、Goバージョン、依存モジュールを記録するファイルです。小さなアプリなら1つのgo.modで十分です。一方、API、CLI、共有ライブラリを別モジュールとして同時に編集する場合は、go.workを使うと複数モジュールをまとめて扱えます。公式チュートリアルはGetting started with multi-module workspacesです。
Claude Codeに依存追加を許す前に、次のコマンドで現在地を確認します。
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 ../..
cd tools/taskctl
go mod init example.com/acme/taskctl
cd ../..
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の指示にも「新しいモジュールや依存を増やす前に理由を説明して」と書きます。
APIとCLIは同じservice層を使う
Goではcmd/配下に実行コマンドを置き、業務ロジックはinternal/に寄せる構成が扱いやすいです。次の例は、HTTP APIとCLIの両方から使えるStoreを中心にした最小APIです。標準ライブラリだけで動くので、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"
)
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"`
}
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() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := NewStore()
mux := http.NewServeMux()
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)
}
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 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"}'
CLIを足す場合も、handlerの中身をコピーせず、同じStoreやservice層を呼びます。CLI開発の観点はClaude CodeでCLIツール開発にも分けてあります。
table-driven testを完了条件にする
table-driven testは、入力と期待値を表のように並べて同じテスト関数で確認するGoの定番パターンです。Claude Codeには「テストもお願いします」ではなく、「正常系、validation失敗、キャンセル、エラーのwrapをtable-driven testで確認して」と頼みます。
// 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 {
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 ./...
Goのtestingパッケージは単体テストとベンチマークの基本です。詳細はtesting packageを確認してください。ベンチマークは「速くなった気がする」を避けるための測定で、b.N回の処理をGo側が調整して実行します。
context cancellationを境界で落とさない
context.Contextは、リクエストのキャンセル、期限、値を関数に渡す仕組みです。公式ドキュメントでは、サーバーに入ったリクエストから下流の呼び出しへContextを伝える考え方が説明されています。詳しくはcontext packageを参照します。
Claude Codeでよくある失敗は、handlerではr.Context()を受け取っているのに、serviceやrepositoryでcontext.Background()を作り直すことです。これをやると、クライアントが切断してもDB問い合わせや外部API呼び出しが止まりません。プロンプトには「新しいcontext.Background()を下位層で作らず、呼び出し元のctxを渡す」と明記します。
もう1つの失敗は、WithTimeoutのcancelを呼ばないことです。Goの公式ドキュメントにも、派生ContextのCancelFuncは関連リソースを解放するために呼ぶべきだと説明されています。Claude Codeには「context.WithTimeoutを使った箇所はdefer cancel()を入れ、go vetでCancelFunc漏れを確認して」と依頼します。
並行処理はrace detectorで確認する
Goのgoroutineは軽量ですが、共有データを同時に書くとdata raceが起きます。data raceは、複数のgoroutineが同じ変数に同時アクセスし、少なくとも一方が書き込みで、同期がない状態です。Go公式のData Race Detectorは、go test -raceで実行できます。ただし、実行されたコードパスのraceしか見つけられません。
次のような共有mapへの並行書き込みは避けます。
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]++
}()
}
wg.Wait()
return counts
}
修正例は、sync.Mutexで共有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
}
Claude Codeへの指示では、「goroutineを増やして高速化して」だけでは不十分です。共有変数、channelのclose責任、contextキャンセル、WaitGroupのDone漏れ、race detectorの結果まで確認させます。レビュー観点はClaude Codeコードレビュー実践にもつなげられます。
安全なClaude Codeプロンプト
Goで使いやすいプロンプトは、編集範囲、守る契約、検証コマンド、禁止事項を明確にします。
目的: taskapiにPOST /tasksのvalidationとテストを追加する。
編集してよい範囲:
- cmd/taskapi/main.go
- cmd/taskapi/store_test.go
守ること:
- go.modのmodule pathを変更しない
- 新しい外部依存を追加しない
- publicな型名とJSON field名を変えない
- context.Contextはhandlerからserviceまで伝播する
- errorはfmt.Errorf("%w")でwrapし、呼び出し側はerrors.Isで判定する
完了条件:
- gofmt -w cmd/taskapi
- go test ./...
- go test -race ./...
- 変更内容と残リスクを短く報告する
依存を増やす必要がある場合は、先に理由を出させます。
外部ライブラリが必要だと判断した場合は、実装前に候補、理由、標準ライブラリで代替できない理由、go.modへの影響を説明してください。承認なしにgo getしないでください。
この書き方にすると、Claude Codeが勝手にCLIフレームワーク、router、ORM、mockライブラリを足す事故を減らせます。Goは標準ライブラリで十分進められる場面が多いため、依存追加は常にレビュー対象にします。
実務ユースケースと落とし穴
1つ目のユースケースは、既存APIへの小さなendpoint追加です。net/httpや既存routerに1本足し、service層、table-driven test、go test -raceまで同じタスクに含めます。落とし穴は、handlerだけを増やしてserviceの境界やエラー形式を崩すことです。
2つ目は、CLIとAPIの共通ロジック化です。運用担当がCLIで実行する処理と、管理画面から叩くAPIが同じ業務ルールを使う場合、internal/serviceへ寄せます。落とし穴は、CLI用にコピーした処理が後でAPIとズレることです。
3つ目は、遅い処理の並行化です。外部APIを3本呼ぶdashboard集計などではgoroutineが効きます。落とし穴は、共有sliceやmapへの同時書き込み、channelを複数箇所でcloseする設計、キャンセルを無視したgoroutine漏れです。
4つ目は、性能改善です。Claude Codeに「速くして」と頼む前に、go test -bench=. -benchmemで基準を取ります。落とし穴は、測定せずにキャッシュやgoroutineを足し、メモリ使用量や複雑さだけ増やすことです。
5つ目は、multi-moduleリポジトリの変更です。go.workでローカル編集は楽になりますが、CIの再現性を壊さないように、go env GOWORKとgo test ./...の実行場所を確認します。落とし穴は、ローカルのgo.workだけで通る状態を本番品質と勘違いすることです。
収益化CTAと次の一歩
Go開発でClaude Codeを使う読者は、単にコード生成を試したいだけでなく、「チームの既存リポジトリに安全に入れたい」という悩みを持っていることが多いです。まず個人でプロンプトと検証コマンドを固めるなら無料チートシートが使えます。CLAUDE.md、権限、レビュー観点、Go向けチェックリストをまとめて整えるなら教材・テンプレート一覧へ進んでください。チームのGo API、CLI、CI、レビュー規約まで含めて導入したい場合はClaude Code研修・導入相談で実リポジトリ前提に整理できます。
まとめ
Claude CodeでGo開発を安定させるコツは、最初にリポジトリの地図を作り、go.modとgo.workの境界を確認し、API/CLIの変更をservice層に寄せ、table-driven test、context cancellation、race detector、benchmarkを完了条件にすることです。Goのシンプルさに甘えて「あとでテスト」「あとでrace確認」にすると、生成速度の分だけ不具合も速く入ります。
この記事で紹介した内容を実際に試した結果、最も効果があったのはプロンプトに検証コマンドを入れることでした。gofmt、go test ./...、go test -race ./...、go test -bench=. -benchmemを完了条件にしただけで、Claude Codeの報告が「実装しました」から「何を確認し、どこに残リスクがあるか」に変わりました。公開前レビューでは、公式リンク、内部リンク、本文量、コードフェンス、updatedDate、heroImageも合わせて確認してください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。