Go のコードを「読みやすく・保守しやすく・安全に」書くための実践的な指針をまとめたものです。 公式の Effective Go、Go Code Review Comments、Google Go Style Guide の考え方をベースにしています。
目次
- 基本方針
- フォーマットとツール
- 命名規則
- パッケージ設計
- プロジェクト構成
- エラーハンドリング
- インターフェース
- 構造体と初期化
- 並行処理
- context の扱い
- ジェネリクス
- テスト
- パフォーマンス
- ロギング
- 依存関係管理
- よくあるアンチパターン
- チェックリスト
1. 基本方針
- シンプルさを優先する:賢いコードより、誰が読んでもすぐ分かるコードを書く。
- 明示的であること:暗黙の挙動(魔法)より、多少冗長でも明示的なコードを選ぶ。
- ゼロ値を有用にする:型のゼロ値がそのまま使えるように設計する(例:
sync.Mutex、bytes.Buffer)。 - “A little copying is better than a little dependency”:小さな共通化のために依存を増やさない。
- 早期リターン:ネストを深くせず、異常系を先に処理して正常系を左端に揃える。
// Good: 早期リターンで正常系が左端に揃う
func process(u *User) error {
if u == nil {
return errors.New("user is nil")
}
if !u.Active {
return ErrInactive
}
return save(u)
}
// Bad: ネストが深い
func process(u *User) error {
if u != nil {
if u.Active {
return save(u)
} else {
return ErrInactive
}
}
return errors.New("user is nil")
}2. フォーマットとツール
| ツール | 用途 |
|---|---|
gofmt / goimports | コード整形・import 整理(必須。議論の余地なし) |
go vet | 典型的なバグの静的検出 |
staticcheck | より強力な静的解析 |
golangci-lint | 複数リンターの統合実行(CI 向け) |
govulncheck | 依存パッケージの既知脆弱性チェック |
go test -race | データ競合の検出 |
- エディタ保存時に
goimports(またはgopls)で自動整形する設定にする。 - CI では
go vet、golangci-lint、go test -race ./...、govulncheck ./...を回す。
.golangci.yml の最小例:
linters:
enable:
- errcheck
- govet
- staticcheck
- unused
- ineffassign
- gosec
- revive
- errorlint
- bodyclose3. 命名規則
基本
- MixedCaps を使う(
snake_caseは使わない)。公開は先頭大文字、非公開は先頭小文字。 - 頭字語は大文字・小文字を揃える:
URL,ID,HTTP→userID,ServeHTTP,parseURL(UserId,ParseUrlは NG)。 - スコープが狭いほど短く:ループ変数は
i、レシーバはsなど。スコープが広いほど説明的に。
パッケージ名
- 小文字・単数形・短く:
user,http,strconv util,common,helpers,miscのような意味の薄い名前は避ける。- パッケージ名と重複する名前を付けない(stutter を避ける)。
// Bad
package user
type UserService struct{} // user.UserService
// Good
package user
type Service struct{} // user.Serviceゲッター / インターフェース
- ゲッターに
Getを付けない:Owner()(GetOwner()ではない)。セッターはSetOwner()。 - 1メソッドのインターフェースは
-erで命名:Reader,Writer,Stringer。
レシーバ名
- 型名の1〜2文字の略語を使い、型内で統一する。
thisやselfは使わない。
func (c *Client) Do(req *Request) (*Response, error)エラー
- エラー変数は
Errプレフィックス:ErrNotFound - エラー型は
Errorサフィックス:ValidationError
4. パッケージ設計
- 機能(ドメイン)単位で分割する。
models/,controllers/のようなレイヤー単位の分割は循環依存を招きやすい。 - 循環 import は許されないので、依存の方向を意識して設計する。
internal/を活用する:外部に公開したくないコードはinternal/配下に置くとコンパイラが外部からの import を禁止してくれる。- パッケージの公開 API は最小限に。公開すると後から変更しづらい。
init()の多用は避ける。暗黙の副作用はテストやデバッグを難しくする。- グローバル変数(特に可変なもの)は避け、依存は引数や構造体フィールドで渡す。
5. プロジェクト構成
公式の推奨は Organizing a Go module を参照。小さく始めて必要に応じて分割するのが基本です。
myapp/
├── go.mod
├── go.sum
├── main.go # 小規模なら直下でOK
├── cmd/ # 複数バイナリがある場合
│ ├── server/
│ │ └── main.go
│ └── cli/
│ └── main.go
├── internal/ # 外部非公開のアプリケーションコード
│ ├── user/
│ │ ├── service.go
│ │ ├── repository.go
│ │ └── service_test.go
│ └── order/
└── pkg/ # 外部公開ライブラリ(必要な場合のみ)
mainパッケージは薄く保ち、設定読み込み・依存の組み立て・起動だけを行う。pkg/は必須ではない。公開する意図がなければinternal/で十分。- 最初から過剰なディレクトリ構成(“standard layout” の丸写し)にしない。
6. エラーハンドリング
基本原則
- エラーは必ず処理する。
_で捨てるのは意図が明確な場合のみ(コメントを添える)。 - エラーは戻り値の最後に置く。
- エラーメッセージは小文字始まり・句読点なし(連結されるため)。
// Good
return fmt.Errorf("open config: %w", err)
// Bad
return fmt.Errorf("Failed to open config file.: %w", err)ラップとコンテキスト付与
%wでラップして原因を保持し、呼び出し側がerrors.Is/errors.Asで判定できるようにする。- 「何をしようとして失敗したか」を付け加える。“failed to” は冗長なので省略してよい。
var ErrNotFound = errors.New("not found")
func (r *Repo) Find(id string) (*User, error) {
u, err := r.db.Get(id)
if err != nil {
return nil, fmt.Errorf("find user %s: %w", id, err)
}
return u, nil
}
// 呼び出し側
if errors.Is(err, ErrNotFound) {
// 404 を返す
}
var verr *ValidationError
if errors.As(err, &verr) {
// フィールド情報を使う
}err == ErrXでの比較やerr.Error()の文字列比較は避け、errors.Is/errors.Asを使う。- 複数エラーをまとめる場合は
errors.Joinを使う。
一度だけ処理する
- ログに出して、さらに return するのは NG(同じエラーが何度もログに出る)。ログに出すか返すか、どちらか一方。
// Bad
if err != nil {
log.Printf("query failed: %v", err)
return err
}
// Good
if err != nil {
return fmt.Errorf("query: %w", err)
}panic
panicは「プログラマのバグ」や「起動時の回復不能な設定ミス」に限定する。通常のエラーには使わない。- ライブラリは panic を外部に漏らさない。
Must系関数(regexp.MustCompileなど)はパッケージ変数の初期化など、失敗が即バグを意味する場面でのみ使う。
7. インターフェース
- インターフェースは使う側(consumer)で定義する。実装側で先回りして定義しない。
- 小さく保つ。1〜3メソッド程度が理想。大きなインターフェースは小さなものの合成で表現する。
- “Accept interfaces, return structs”:引数はインターフェース、戻り値は具体型。
- 実装が1つしかないのに「将来のため」にインターフェースを作らない(テストで差し替えが必要になった時点で作る)。
// 使う側のパッケージで、必要なメソッドだけを定義
package notifier
type userGetter interface {
GetUser(ctx context.Context, id string) (*user.User, error)
}
type Notifier struct {
users userGetter
}
func New(users userGetter) *Notifier {
return &Notifier{users: users}
}- コンパイル時に実装を保証したい場合:
var _ io.Writer = (*MyWriter)(nil)interface{}よりanyを使う(同義だが読みやすい)。ただしanyの多用自体は型安全性を損なうので避ける。
8. 構造体と初期化
コンストラクタ
- 初期化が必要なら
NewXxx関数を用意する。ゼロ値で使えるならコンストラクタは不要。
Functional Options パターン
オプションが多い場合の定番パターン:
type Server struct {
addr string
timeout time.Duration
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
srv := NewServer(":8080", WithTimeout(10*time.Second))※ オプションが少なければ設定用構造体(Config)を渡す方がシンプルな場合も多い。
構造体リテラル
- フィールド名を明示する(位置指定は型変更に弱い)。
// Good
u := User{Name: "Alice", Age: 30}
// Bad
u := User{"Alice", 30}レシーバの選択(値 vs ポインタ)
- 状態を変更する、構造体が大きい、
sync.Mutexなどコピー不可なフィールドを含む → ポインタレシーバ - 小さく不変な値型(
time.Timeのような) → 値レシーバ - 1つの型の中でレシーバの種類を混在させない(迷ったらポインタ)。
スライスとマップ
- 要素数が分かっている場合は容量を事前確保する。
- nil スライスと空スライスはほぼ同様に扱えるが、JSON では
nullと[]で違いが出る点に注意。 - 外部から受け取ったスライス・マップを内部に保持する場合や返す場合は、必要に応じてコピーする(意図しない共有を防ぐ)。
users := make([]User, 0, len(ids))
m := make(map[string]int, len(keys))
// 防御的コピー
func (s *Store) SetItems(items []Item) {
s.items = slices.Clone(items)
}9. 並行処理
原則
- “Don’t communicate by sharing memory; share memory by communicating.”
ただし単純な共有状態の保護には
sync.Mutexの方が素直なことも多い。適材適所で選ぶ。 - goroutine を起動したら、終了方法を必ず決めておく。止められない goroutine はリークする。
- 並行処理は必要になってから導入する。同期 API を提供し、並行化は呼び出し側に任せるのが基本。
goroutine のライフサイクル管理
sync.WaitGroup または golang.org/x/sync/errgroup で終了を待つ。
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // 同時実行数の制限
results := make([]Result, len(urls))
for i, url := range urls {
g.Go(func() error {
r, err := fetch(ctx, url)
if err != nil {
return err
}
results[i] = r // インデックスごとに書き込むのでロック不要
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}※ Go 1.22 以降、for ループ変数はイテレーションごとに新しく作られるため、i := i のようなコピーは不要。
Mutex
type Counter struct {
mu sync.Mutex
n map[string]int
}
func (c *Counter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.n[key]++
}- Mutex は保護対象のフィールドの直上に置く。
- Mutex を含む構造体はコピーしない(
go vetが検出してくれる)。 - 読み取りが圧倒的に多い場合のみ
sync.RWMutexを検討する。 - 単純なカウンタやフラグは
sync/atomicの型(atomic.Int64など)も有効。
チャネル
- チャネルを close するのは送信側。受信側が close してはいけない。
- バッファサイズは原則 0 か 1。それ以上にする場合は根拠を持つ。
- 関数シグネチャでは方向付きチャネル(
<-chan T,chan<- T)を使い、意図を明確にする。 selectではctx.Done()を必ず監視し、キャンセル可能にする。
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
results <- process(j)
}
}
}その他
- テストでは常に
-raceを付けて実行する。 - 一度だけ実行したい初期化には
sync.Once(またはsync.OnceValue/sync.OnceFunc)を使う。
10. context の扱い
context.Contextは関数の第1引数にctxという名前で渡す。- 構造体のフィールドに保持しない(リクエストスコープを越えてしまう)。
nilの context を渡さない。不明な場合はcontext.TODO()、トップレベルではcontext.Background()。WithTimeout/WithCancelで得たcancelは必ずdefer cancel()する。context.WithValueはリクエストスコープのメタデータ(トレースID、認証情報など)に限定する。関数の必須パラメータを context で渡さない。キーには非公開の独自型を使う。
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return s.repo.Find(ctx, id)
}
type ctxKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}11. ジェネリクス
- まずは具体型で書き、同じロジックが複数の型で重複したらジェネリクスを検討する。
- コレクション操作、データ構造(スタック・キューなど)、型安全なユーティリティに向いている。
- メソッドの振る舞いだけを抽象化したいならインターフェースで十分なことが多い。
- 標準ライブラリの
slices,maps,cmpパッケージをまず活用する。
// 標準ライブラリで足りるケース
slices.Sort(xs)
idx := slices.Index(xs, target)
keys := slices.Sorted(maps.Keys(m))
m := max(a, b) // 組み込み関数(Go 1.21+)
// 自前で書く場合
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, 0, len(s))
for _, v := range s {
out = append(out, f(v))
}
return out
}- 型制約が複雑になりすぎたら、設計を見直すサイン。
12. テスト
テーブル駆動テスト
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{name: "positive", a: 1, b: 2, want: 3},
{name: "negative", a: -1, b: -2, want: -3},
{name: "zero", a: 0, b: 0, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}ポイント
- 失敗メッセージは “got X, want Y” 形式で、入力値も含めると原因が分かりやすい。
- 以降の検証が無意味になる場合のみ
t.Fatal、それ以外はt.Errorで継続する。 - ヘルパー関数には
t.Helper()を付けて、失敗行を呼び出し元で表示させる。 - 後片付けは
t.Cleanup()、一時ディレクトリはt.TempDir()、環境変数はt.Setenv()。 - 構造体の比較には
github.com/google/go-cmp/cmpのcmp.Diffが便利。 - 外部依存はインターフェースで差し替える。モックライブラリより手書きのフェイクの方がシンプルなことが多い。
- HTTP のテストには
net/http/httptestを使う。 - ブラックボックステストには
package foo_testを使い、公開 API のみを検証する。 - テストデータは
testdata/ディレクトリに置く(ビルド対象外になる)。 - 入力の網羅性が重要な関数にはファジング(
func FuzzXxx(f *testing.F))を検討する。
func newTestServer(t *testing.T) *Server {
t.Helper()
s := NewServer(":0")
t.Cleanup(func() { s.Close() })
return s
}よく使うコマンド
go test ./... # 全テスト
go test -race ./... # 競合検出付き
go test -run TestAdd/positive ./... # 特定テストのみ
go test -cover ./... # カバレッジ
go test -bench=. -benchmem ./... # ベンチマーク13. パフォーマンス
- 推測するな、計測せよ。最適化の前に
pprofとベンチマークでボトルネックを特定する。 - まず読みやすいコードを書き、必要な箇所だけ最適化する。
主なテクニック
- スライス・マップの容量を事前確保する(
make([]T, 0, n))。 - 文字列の連結には
strings.Builderを使う。 - ホットパスでは
fmt.Sprintfよりstrconvの方が速い。 - 頻繁に生成・破棄される一時オブジェクトには
sync.Poolを検討する(計測してから)。 - 大きな構造体を値渡しで大量にコピーしない。
deferのオーバーヘッドは現在ほぼ無視できるので、可読性を優先して使ってよい。
ベンチマーク
func BenchmarkConcat(b *testing.B) {
for b.Loop() { // Go 1.24+。古いバージョンでは for i := 0; i < b.N; i++
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("x")
}
_ = sb.String()
}
}プロファイリング
go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof -http=:8080 cpu.outサーバーアプリなら net/http/pprof を import して実行中にプロファイルを取得できる(本番では公開範囲に注意)。
14. ロギング
- 標準ライブラリの
log/slog(構造化ロギング) を使う。 - メッセージ文字列にデータを埋め込まず、キー・バリューで属性として渡す。
- ライブラリ側ではロガーを直接生成せず、注入できるようにする(またはログを出さずエラーを返す)。
- パスワード・トークンなどの機密情報をログに出さない。
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
logger.Info("user created",
slog.String("user_id", u.ID),
slog.Int("age", u.Age),
)
// リクエスト単位の共通属性
reqLogger := logger.With(slog.String("request_id", reqID))15. 依存関係管理
- Go Modules(
go.mod/go.sum)を使い、両方をバージョン管理にコミットする。 go mod tidyで不要な依存を削除し、CI で差分がないことを確認する。- 依存は慎重に追加する。標準ライブラリで済むなら標準ライブラリを使う。
- 依存の更新は定期的に行い、
govulncheckで脆弱性をチェックする。 - 開発用ツールのバージョン固定には
go.modのtoolディレクティブ(Go 1.24+)が使える。
go get example.com/pkg@latest # 追加・更新
go mod tidy # 整理
go get -tool golang.org/x/tools/cmd/stringer # ツールとして追加(Go 1.24+)16. よくあるアンチパターン
| アンチパターン | 問題点 | 対策 |
|---|---|---|
エラーを _ で握りつぶす | 障害の原因が追えない | 必ず処理するか、理由をコメントで残す |
| ログ出力してからエラーを return | 同じエラーが重複してログに出る | どちらか一方にする |
| 止める手段のない goroutine | goroutine リーク | context や done チャネルで終了させる |
| context を構造体に保持 | スコープが曖昧になる | 引数で渡す |
| 実装側で巨大なインターフェースを定義 | 不要な結合、テストしづらい | 使う側で小さく定義する |
util / common パッケージ | 何でも入る「ゴミ箱」化 | 機能に応じた名前のパッケージに分ける |
| 可変なグローバル変数 | テスト困難、競合の温床 | 依存性注入 |
panic を通常のエラー処理に使う | 呼び出し側が回復できない | error を返す |
http.Response.Body を閉じない | コネクション・リソースリーク | defer resp.Body.Close() |
タイムアウトなしの http.Client | 無限に待ち続ける可能性 | Timeout を設定する(http.DefaultClient は無制限) |
time.After を長寿命ループの select で使う | 古い Go ではタイマーが溜まりやすい | time.NewTimer / time.Ticker を再利用 |
err.Error() の文字列比較 | メッセージ変更で壊れる | errors.Is / errors.As |
// HTTP クライアントの例
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()17. チェックリスト
コードレビュー時に確認する項目:
-
gofmt/goimportsで整形されている -
go vetとgolangci-lintが通る -
go test -race ./...が通る - すべてのエラーが処理されている(握りつぶしがない)
- エラーに十分なコンテキストが付与され、
%wでラップされている - 命名が Go の慣習に沿っている(頭字語、stutter なし、
Getなし) - 起動した goroutine に終了手段がある
-
context.Contextが第1引数で渡され、構造体に保持されていない - インターフェースは使う側で小さく定義されている
- 公開 API は必要最小限で、ドキュメントコメントがある
- リソース(ファイル、レスポンスボディ、ロックなど)が確実に解放されている
- 外部通信にタイムアウトが設定されている
- 機密情報がログやエラーメッセージに含まれていない
- テストがテーブル駆動などで読みやすく、失敗時に原因が分かる