GoのID型定義でバグを防ぐ —— コンパイル時エラー検出、レビュー負荷軽減
こんにちは!ソフトウェアエンジニアの inari111 です。
Go でアプリケーションを開発していると、さまざまなモデルの ID を扱う機会が多くあります。
多くの場合、データベースの主キーとして uint64 や int64 を使用しますが、型安全性の観点では課題があります。
本記事では、RemitAid で実際に導入したモデル ID の型定義について、その効果と実装方法をご紹介します。
問題点
素の uint64 や int64 をモデルの ID として使用すると、以下のような問題が発生します。
1. 引数の取り違い
複数の ID を引数として受け取る関数で、誤った順序で引数を渡してしまうバグが発生します。
// 問題のあるコード例
func TransferMoney(merchantID uint64, transferID uint64, amount int64) error {
// 実装...
}
// 呼び出し時に引数を取り違える
err := TransferMoney(transferID, merchantID, 1000) // バグ!
この場合、コンパイル時にエラーが発生せず、実行時まで問題が発見されません。
2. 意図の不明確さ
リポジトリパターンを使用している場合、複数のリポジトリで同じ uint64 型を使っていると、どの ID がどのモデルに対応するかが分からず、可読性が低下します。
type MerchantRepository struct{}
type UserRepository struct{}
func (r *MerchantRepository) GetByID(id uint64) (*Merchant, error) {
// 実装...
}
func (r *UserRepository) GetByID(id uint64) (*User, error) {
// 実装...
}
func (u *usecase) GetMerchant(ctx context.Context) {
merchantID := uint64(123)
userID := uint64(456)
// 間違ったリポジトリに渡してしまう可能性
merchant, _ := u.merchantRepo.GetByID(userID)
user, _ := u.userRepo.GetByID(merchantID)
}
3. レビュー負荷の増加
型情報だけでは判断できないため、レビュー時に引数の対応関係を慎重に確認する必要があり、レビュー負荷が増加します。
ID の型を定義
上記の問題を解決するため、RemitAid では各モデルの ID に専用の型を定義しました。
基本的な実装
Merchant の ID は以下のように定義しました。
type MerchantID uint64
func (id MerchantID) Uint64() uint64 {
return uint64(id)
}
type Merchant struct {
ID MerchantID
// 略
}
型変換メソッドの必要性
Uint64()
メソッドは、既存のライブラリやデータベースドライバとの互換性を保つために必要です。
使う頻度が多いのでメソッド化しています。
メリット
ID の型を定義することで得られるメリットを簡単に説明します。
1. 型安全性: 引数取り違いをコンパイル時に検出
// 型定義後のコード
func TransferMoney(merchantID MerchantID, transferID TransferID, amount int64) error {
// 実装...
}
// コンパイル時にエラーが発生するため、バグを防げる
err := TransferMoney(transferID, merchantID, 1000) // コンパイルエラー
引数の順序を間違えた場合、コンパイル時に型エラーとして検出されるため、実行時のバグを防げます。
2. 可読性と意図の明確化
// 改善前
type MerchantRepository interface {
// この id が何を表すかが不明確
GetByID(ctx context.Context, id uint64) (*Merchant, error)
}
// 改善後
type MerchantRepository interface {
// MerchantID であることが明確
GetByID(ctx context.Context, id MerchantID) (*Merchant, error)
}
型名から ID の用途が明確になり、コードの可読性が大幅に向上します。
3. 振る舞いの付与
type MerchantID uint64
func (id MerchantID) IsZero() bool {
return id == 0
}
func (id MerchantID) String() string {
return fmt.Sprintf("merchant_%d", id)
}
// 使用例
if merchantID.IsZero() {
return errors.New("invalid merchant ID")
}
log.Printf("Processing merchant: %s", merchantID)
今回は IsZero() や String() は定義していませんが、型に関連するメソッドを定義することで、ドメインロジックを型に集約できます。
デメリット
ID の型定義にはいくつかのデメリットもあります。
1. 詰め替えの手間: 変換コードの増加
問題: DB モデル ↔ ドメインモデル 間での変換コードが増える。
// 実装イメージ
// DB モデル
type MerchantDB struct {
ID uint64 `db:"id"`
Name string `db:"name"`
}
// ドメインモデル
type Merchant struct {
ID MerchantID
Name string
}
func (r *Repository) GetMerchant(ctx context.Context, id MerchantID) (*Merchant, error) {
var dbMerchant MerchantDB
// uint64への変換が必要
row := r.db.QueryRowContext(ctx,
"SELECT id, name FROM merchants WHERE id = ?", id.Uint64())
err := row.Scan(&dbMerchant.ID, &dbMerchant.Name)
if err != nil {
return nil, err
}
// MerchantID型への変換が必要
return &Merchant{
ID: MerchantID(dbMerchant.ID),
Name: dbMerchant.Name,
}, nil
}
多少の手間は増えますが、個人的には許容範囲だと考えています。
ID の型を定義することで得られるメリットのほうが大きいと感じます。
2. 段階移行中の相互変換
問題: 移行期間中は uint64 との相互変換が目立つ。
対処法: 明確な移行計画を立て、段階的に置き換える。過渡期は変換関数を用意する。
段階的な移行戦略
既存のコードベースに ID 型を導入する際の推奨手順をご紹介します。
- 型定義の追加: まず型と基本メソッドを定義
- 新機能から適用: 新しい機能から ID 型を使用開始
- 既存機能の更新: 影響範囲を考慮しながら段階的に更新
RemitAid では既存機能の更新を行うのは少し先になりそうですが、新規機能からは ID の型を定義するようにしています。
おわりに
本記事では、Go でモデル ID の型を定義することによる型安全性の向上について、RemitAid での実践例とともにご紹介しました。
ID の型定義は一見小さな変更に見えますが、その効果は想像以上に大きいものです。コンパイル時の型チェックによりバグを事前に防ぎ、コードの可読性を向上させ、チーム全体の開発効率を高めることができます。
特に FinTech のような堅牢性が求められる分野では、このような型安全性への投資は非常に価値があると考えています。導入時にはデメリットもありますが、適切な対処法により軽減でき、長期的にはその恩恵を十分に享受できます。
RemitAid では一緒に働く仲間を募集しています。
興味がある方はこちらからどうぞ!
Podcast 「RemiTalk」を最近始めましたので、もし良ければ聴いてみてください!
Podcast 文字起こしはこちら
Discussion