Goのジェネリクス導入で抽象が増えすぎたコードをどうレビューするか
Goのジェネリクス導入で抽象が増えすぎたコードをどうレビューするか
Goのジェネリクスは、型安全な共通処理を書くうえで有効な選択肢だ。
ただしレビューでは、型パラメータを使っているから高度な設計だと見なすのは危険である。
特に実務で問題になりやすいのは、重複排除のつもりでドメインの言葉や責務境界まで消してしまう実装だ。
T anyが広すぎて、何を要求しているのか分からない- 汎用Repositoryや汎用UseCaseが増え、業務ルールの置き場が消える
- 型パラメータの制約が実装都合で、利用者の理解を助けていない
- 結局
switch any(v).(type)やreflectionに逃げている
この記事では、ジェネリクスの使い方紹介ではなく、
抽象化が設計意図を明確にしているか、逆に隠しているかをレビューする観点を整理する。
まず止めたい実装
type Entity interface {
GetID() string
}
type Repository[T Entity] interface {
Find(ctx context.Context, id string) (T, error)
Save(ctx context.Context, value T) error
}
type UseCase[T Entity] struct {
repo Repository[T]
}
func (u *UseCase[T]) Update(ctx context.Context, id string, patch map[string]any) error {
current, err := u.repo.Find(ctx, id)
if err != nil {
return err
}
updated, err := applyPatch(current, patch)
if err != nil {
return err
}
return u.repo.Save(ctx, updated)
}
@Reviewer汎用UseCase化により、User更新とOrder更新で必要な検証・権限・状態遷移の違いが表現できません。重複排除よりも業務ルールの配置責務を優先して、具体的なUseCaseへ戻すことを検討してください。
このコードは一見きれいに抽象化されている。
しかしレビューでは、何が共通で、何が個別責務なのかが読めない。
User と Order がどちらも Entity だとしても、更新時に守るべきルールは同じとは限らない。
型が共通化できることと、責務を共通化してよいことは別問題である。
なぜ危ないのか
過剰なジェネリクスは、レビューの判断材料を減らす。
- 関数名から業務意図が消える
- 制約が
anyや薄いinterfaceになり、必要な能力が読めない - 呼び出し側で型推論されるため、実体が追いにくい
- 変更時に影響範囲が複数ドメインへ広がる
- テストケースが抽象層の動作確認に寄り、業務ルールの確認が薄くなる
抽象化は、重複行数を減らすためだけの道具ではない。
レビューアーが責務と変更理由を説明しやすくなるときにだけ価値がある。
レビューで見たい3つの判断線
1. 型パラメータが「必要な能力」を表しているか
T any は便利だが、レビューではかなり強い注意信号になる。
本当に任意の型を受けてよいのか、関数内で何を要求しているのかを確認する。
func Normalize[T any](values []T) []T {
// 中で型ごとの分岐が増えていく
return values
}@Reviewer: `T any` ではこの関数が型に何を要求しているのか分かりません。比較、順序、ID取得など必要な能力を制約で表現できないなら、具体型ごとの関数に分けたほうが読みやすいです。2. 抽象化でドメイン語彙が消えていないか
Create[T]、Update[T]、Validate[T] のような名前が増えると、処理がきれいに見える。
しかし、レビューで知りたいのは「何を作るか」「何を検証するか」である。
@Reviewer: ジェネリック化により `ActivateUser` や `CancelOrder` のような業務語彙が消えています。変更理由が異なる処理を同じ抽象に寄せると、将来の条件追加で共通層が肥大化します。3. 型安全のための導入なのに、実行時分岐へ戻っていないか
ジェネリクスを使っているのに、内部で any 変換やreflectionに逃げているなら、型安全性の利点が薄い。
func Encode[T any](value T) ([]byte, error) {
switch v := any(value).(type) {
case User:
return encodeUser(v)
case Order:
return encodeOrder(v)
default:
return nil, fmt.Errorf("unsupported type")
}
}@Reviewer: 型パラメータを受けていますが、内部で型switchしており、コンパイル時の制約になっていません。対応型が限定されるなら具体関数やinterfaceで表現してください。ジェネリクスが合う改善例
ジェネリクスが自然に効くのは、型は違っても処理の意味が同じで、制約が小さく説明できる場合だ。
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for key := range m {
keys = append(keys, key)
}
return keys
}この関数はレビューしやすい。
- 必要な能力が
K comparableだけで表現されている - 業務ルールを隠していない
- どの型でも処理の意味が同じ
- 型switchやreflectionに逃げていない
つまり、ジェネリクスの価値が「抽象のための抽象」ではなく、
型安全な共通処理として説明できる。
ドメイン処理は具体名を残す
業務ルールを含む処理では、多少の重複よりも具体名を残すほうがレビューしやすい。
type UserService struct {
users UserRepository
}
func (s *UserService) ChangeEmail(ctx context.Context, userID string, email string) error {
user, err := s.users.Find(ctx, userID)
if err != nil {
return err
}
if !user.CanChangeEmail() {
return ErrEmailChangeNotAllowed
}
user.Email = email
return s.users.Save(ctx, user)
}このコードには汎用性は少ない。
しかしレビューでは、変更理由、検証責務、エラー契約が読みやすい。
@Reviewer: この処理はユーザーのメール変更ルールを含んでいるため、汎用Updateに寄せるより `ChangeEmail` として責務を残すほうが適切です。共通化する場合も、DBアクセス補助など業務ルールを含まない層に限定してください。レビュー観点チェックリスト
- 型パラメータの制約が必要な能力を表しているか
T anyが実装都合の逃げ道になっていないか- 抽象化で業務語彙や変更理由が消えていないか
- 複数ドメインを同じUseCaseやRepositoryに押し込んでいないか
- 内部で型switchやreflectionに戻っていないか
- 重複削減よりレビュー可能性が下がっていないか
まとめ
Goのジェネリクスは、型安全な共通処理を作るための有効な道具である。
一方で、業務責務やドメイン語彙まで共通化すると、レビューで見るべき判断材料が消える。
レビューアーは「ジェネリクスを使っているか」ではなく、
型パラメータが責務を明確にしているか、責務を隠しているかを確認したい。