クリーンなGoデータ層のためのGoジェネリクスCRUDリポジトリパターン
読みやすい制約でリスト/取得/作成/更新/削除のロジックを再利用する、リフレクションを使わない実践的なGoジェネリクスのCRUDリポジトリパターンを学ぶ。

なぜGoでCRUDリポジトリは散らかりがちか
CRUDリポジトリは最初はシンプルです。GetUserを書き、次にListUsers、さらにOrdersやInvoicesでも同じ流れを繰り返します。エンティティがいくつか増えると、データ層はほとんどコピーの山になり、小さな違いを見落としやすくなります。
繰り返されるのは多くの場合SQL自体ではありません。周囲の流れです:クエリを実行し、行をスキャンし、「見つからない」を扱い、データベースエラーを変換し、ページネーションのデフォルトを適用し、入力を適切な型に変換する、といった作業です。
よくあるホットスポットはお馴染みでしょう:複製されたScanコード、繰り返し出てくるcontext.Contextとトランザクションのパターン、定型的なLIMIT/OFFSET処理(総数取得を伴うことも)、「0行=見つからない」チェックの使い回し、コピー&ペーストされたINSERT ... RETURNING idのバリエーションなどです。
繰り返しが我慢できなくなると、多くのチームはリフレクションに手を伸ばします。「一度書けば良い」CRUDを約束してくれるからです:任意の構造体を受け取り、ランタイムでカラムから埋める。しかしコストは後で現れます。リフレクション多用のコードは読みづらくなり、IDEのサポートが弱まり、失敗がコンパイル時から実行時へ移ります。フィールド名を変えたりnullableカラムを追加したりすると、テストや本番でしか見つからない驚きが起きます。
型安全な再利用とは、日常の快適さ(明確なシグネチャ、コンパイラによる型チェック、補完)が失われないままCRUDの流れを共有することです。ジェネリクスを使えば Get[T] や List[T] のような操作を再利用しつつ、行を T にスキャンする方法のように推測できない部分は各エンティティに要求できます。
このパターンはデータアクセス層について意図的に扱っています。SQLとマッピングを一貫して単純に保ちます。ドメインをモデル化したりビジネスルールを強制したり、サービス層のロジックを置き換えることは目的ではありません。
設計目標(そしてここでは解決しないこと)
良いリポジトリパターンは日常的なデータベースアクセスを予測可能にします。リポジトリを見れば何をするのか、どのSQLを実行するのか、どんなエラーが返りうるのかを素早く理解できるべきです。
目標はシンプルです:
- エンドツーエンドの型安全性(IDやエンティティ、結果が
anyではない) - 意図を説明する制約(型の奇妙なトリックに頼らない)
- 重要な挙動を隠さずにボイラープレートを減らすこと
- List/Get/Create/Update/Deleteで一貫した振る舞い
非目標も同じくらい重要です。これはORMではありません。フィールドマッピングを推測したり、テーブルを自動結合したり、クエリを黙って変えたりすべきではありません。「魔法のマッピング」はリフレクションやタグやエッジケースに戻してしまいます。
通常のSQLワークフローを想定してください:明示的なSQL(または薄いクエリビルダ)、明確なトランザクション境界、理由を説明できるエラー。何かが失敗したらエラーは「not found」「conflict/constraint violation」「DB unavailable」のように分かりやすくあるべきで、曖昧な「repository error」ではいけません。
重要な決定は、ジェネリックにするものとエンティティごとに残すものをどう分けるかです。
- ジェネリックにするもの:流れ(クエリ実行、スキャン、型付き返却、共通エラーの翻訳)。
- エンティティごとにするもの:意味(テーブル名、選択するカラム、結合、SQL文字列)。
すべてのエンティティを普遍的なフィルタシステムに押し込もうとすると、コードは単に2つの明確なクエリを書くより読みづらくなることが多いです。
エンティティとIDの制約をどう選ぶか
ほとんどのCRUDコードは繰り返しが発生しますが、各テーブルは固有のフィールドを持ちます。ジェネリクスを使うトリックは、小さな形だけを共有し、それ以外は自由にしておくことです。
まず、リポジトリが実際にエンティティについて知っておくべきことを決めます。多くのチームにとって唯一の普遍的な要素はIDです。タイムスタンプは便利なことがありますが普遍的ではなく、すべての型に無理やり入れるとモデルが不自然になります。
我慢できるID型を選ぶ
ID型はDBで行を識別する方法に合っているべきです。プロジェクトによってはint64を使い、他はUUID文字列を使います。サービス間で共通のアプローチを取りたいならIDをジェネリックにします。コードベース全体で1つのID型しか使わないなら固定にしてシグネチャを短くするのも手です。
IDの良いデフォルト制約はcomparableです。IDは比較したりマップキーにしたり渡したりするので適しています。
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
エンティティの制約は最小限にする
埋め込みや ~struct{...} のような型セットのトリックでフィールドを要求するのは避けてください。強力に見えますが、ドメイン型をリポジトリパターンに結びつけてしまいます。
代わりに共有CRUDフローに本当に必要なものだけを要求します:
- IDの取得と設定(CreateがIDを返し、Update/DeleteがIDをターゲットにできるように)
後でソフトデリートや楽観的ロックを追加する場合は、小さくオプトインのインターフェース(例:GetVersion/SetVersion)を追加して、必要なところだけで使うようにしてください。小さなインターフェースは長持ちします。
読みやすさを保つジェネリックなリポジトリインターフェース
リポジトリインターフェースはアプリが必要とすることを記述すべきで、データベースが何をしているかを漏らすべきではありません。インターフェースがSQLのように見えると、詳細が全体に漏れてしまいます。
メソッド群は小さく予測可能に保ちます。context.Contextを最初に、次に主要な入力(IDやデータ)、その次にオプションのノブをまとめた構造体、といった順序が良いです。
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
Listには普遍的なフィルタ型を強制しないでください。フィルタはエンティティごとに最も異なる部分です。実用的なアプローチはエンティティごとのクエリ型と、埋め込み可能な小さな共通ページネーション形状を使うことです。
type Page struct {
Limit int
Offset int
}
エラー処理はリポジトリを騒がしくしがちな部分です。呼び出し側が分岐できるエラーをあらかじめ決めておきます。単純なセットで十分なことが多いです:
ErrNotFound:IDが存在しないときErrConflict:一意制約違反やバージョン衝突ErrValidation:入力が無効(リポジトリ側で検証する場合のみ)
その他はDB/ネットワークの低レイヤーエラーとしてラップします。この契約があれば、サービス側は「見つからない」や「衝突」をストレージがPostgreSQLか別のものかを気にせず扱えます。
リフレクションを避けつつ流れを再利用する方法
リフレクションは「任意の構造体を埋める」コードを書きたいときに忍び込みます。それはエラーを実行時に隠し、ルールを不明瞭にします。
よりきれいなアプローチは、退屈な部分だけを再利用することです:クエリを実行し、行をループし、影響行数をチェックし、エラーを一貫してラップする。構造体とのマッピングは明示的に保ちます。
責任を分ける:SQL、マッピング、共通フロー
実用的な分割は次の通りです:
- エンティティごと:SQL文字列とパラメータ順序を1か所にまとめる
- エンティティごと:行を具象構造体にスキャンする小さなマッピング関数を書く
- ジェネリック:クエリを実行してマッパーを呼ぶ共通フローを提供する
こうすればジェネリクスは繰り返しを減らしますが、データベースが何をしているかを隠しません。
以下は *sql.DB か *sql.Tx のどちらでも動くようにする小さな抽象の例です。
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
ジェネリクスがすべきこと(とすべきでないこと)
ジェネリック層は構造体を「理解」しようとしてはいけません。代わりに、あなたが提供する明示的な関数を受け入れるべきです。例えば:
- 入力をクエリ引数に変換するバインダ
- 列をエンティティに読み取るスキャナ
たとえばCustomerリポジトリはSQLを定数として持ち(selectByID、insert、update)、scanCustomer(rows) を1回実装します。ジェネリックな List はループ、コンテキスト、エラーラップを処理し、scanCustomer が型安全で明快なマッピングを担当します。
カラムを追加したらSQLとスキャナを更新します。コンパイラが壊れた箇所を見つける手助けをしてくれます。
手順に沿った実装
目標はList/Get/Create/Update/Deleteの再利用可能な流れを1つにし、各リポジトリは自分のSQLと行マッピングに正直でいることです。
1) コア型の定義
必要最小限の制約から始めます。コードベースに合うID型と予測可能なリポジトリインターフェースを選びます。
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2) DBとトランザクション用の実行者を追加
ジェネリックコードを直接*sql.DBや*sql.Txに縛らないでください。呼び出すメソッドに合わせた小さな実行者インターフェースに依存します。こうすればサービスはDBかトランザクションを渡すだけでリポジトリコードを変えずに済みます。
3) 共有フローを持つジェネリックなベースを作る
baseRepo[E,K] を作り、実行者といくつかの関数フィールドを持たせます。ベースは退屈な部分を処理します:クエリ呼び出し、"見つからない"の扱い、影響行数のチェック、一貫したエラー返却など。
4) エンティティ固有の部分を実装
各エンティティリポジトリはジェネリックにできないものを提供します:
- list/get/create/update/delete 用のSQL
scan(row)関数(行をEに変換)bind(...)関数(クエリ引数を返す)
5) 具体的なリポジトリを組み立ててサービスから使う
NewCustomerRepo(exec Executor) *CustomerRepo のように baseRepo を埋め込むかラップするコンストラクタを作ります。サービス層は Repo[E,K] インターフェースに依存し、トランザクションをいつ開始するかを決めます。リポジトリは与えられた実行者を使うだけです。
予想外を避けるためのList/Get/Create/Update/Deleteの扱い
メソッドがどこでも同じ振る舞いをするなら、ジェネリックリポジトリは役に立ちます。多くの問題は小さな不整合から生じます:あるリポジトリは created_at でソートし、別のは id、あるものは欠損時に nil, nil を返し別のはエラーを返す、といった具合です。
List:ページングと順序が揺れないこと
1つのページング方式を選び一貫して適用してください。管理画面などにはオフセットページング(limit/offset)が単純で向いています。エンドレススクロールにはカーソルページングが良いですが、安定したソートキーが必要です。
どれを選んでも、ソートは明示的かつ安定的にしてください。一意のカラム(多くの場合主キー)でソートすれば、新しい行が追加されてもページ間で項目が移動しにくくなります。
Get:明確な「見つからない」シグナル
Get(ctx, id) は型付きエンティティと明確な欠損シグナルを返すべきです。通常は ErrNotFound のような共有のセントネルエラーを返し、ゼロ値と nil を返すのは避けます。呼び出し側が状態をエラーで分岐できるようにするのが習慣です。
型はデータのため、エラーは状態のために使います。
メソッドを実装する前にいくつかの決定をして一貫性を保ってください:
Create:IDやタイムスタンプを持たない入力型を受け取るか、完全なエンティティを受け取るか。多くはCreate(ctx, CreateX)を好みます(サーバ所有フィールドの設定を防ぐため)。Update:完全置換かパッチか。パッチならゼロ値が曖昧にならないようポインタやnullable型、明示的フィールドマスクを使います。Delete:ハードデリートかソフトデリートか。ソフトデリートならGetがデフォルトで削除済みを隠すかどうかも決めます。
書き込みメソッドが何を返すかも決めます。驚きの少ない選択肢は更新後のエンティティを返すか、IDだけを返すか、何もしなかったら ErrNotFound を返すなどです。
ジェネリック部分とエンティティ固有部分のテスト戦略
このアプローチが効果を発揮するには信頼しやすいことが必要です。コードと同様にテストも分けます:共通ヘルパーは一度だけテストし、各エンティティのSQLとスキャンは別個にテストします。
ページネーションのバリデーションや、許可されたカラムへのソートキーのマッピング、WHERE句断片の組み立てなどは小さな純粋関数にして高速なユニットテストでカバーします。
Listクエリはテーブル駆動テストが有効です。空のフィルタ、未知のソートキー、limit 0、最大を超えるlimit、負のoffset、次ページ境界(1行余分に取る)などのケースをカバーします。
エンティティごとのテストは本当に固有の部分に集中させます:期待するSQLが実行されるか、行がエンティティ型にスキャンされるか。SQLモックか軽量なテストDBを使い、スキャンロジックがnullやオプショナルカラム、型変換に耐えられるか確認します。
トランザクションをサポートするなら、偽の実行者でコミット/ロールバックの挙動をテストします:
- Beginはtxスコープの実行者を返す
- エラー時はRollbackが正確に1回呼ばれる
- 成功時はCommitが正確に1回呼ばれる
- Commitが失敗したらそのエラーがそのまま返る
また各リポジトリが満たすべき小さな「契約テスト」も追加できます:作成→取得が一致する、更新で期待したフィールドが変わる、削除後は ErrNotFound、Listは同じ入力で順序が安定している、など。
よくある間違いと罠
ジェネリクスは「すべてを支配する一つのリポジトリ」を作りたくさせます。データアクセスには小さな差異が多く、それらは重要です。
よく出る罠:
- オーバーゼネラライズしてすべてのメソッドが巨大なオプション袋(ジョイン、検索、権限、ソフトデリート、キャッシュ)を取るようにしてしまう。そうなると第二のORMを作ったのと同じです。
- 読者が何を実装すべきかを理解するために型セットを解読しなければならないような過度に賢い制約。抽象が役に立つどころかコストを生みます。
- 入力型をDBモデルと同じ扱いにすること。CreateとUpdateで同じ構造体を使うとDBの詳細がハンドラやテストに漏れ、スキーマ変更がアプリ全体に波及します。
Listの無言の挙動:ソートが不安定、デフォルトが一貫しない、ページングルールがエンティティごとに違う、など。not-foundの扱いでエラー文字列を解析させる設計。errors.Isを使えるようにします。
具体例:ListCustomers が ORDER BY を設定しておらず、毎回異なる順序で顧客を返していたため、ページングで重複や欠落が発生することがあります。デフォルトの順序(たとえば主キーでのソート)を明示し、テストするようにしてください。
採用前のクイックチェックリスト
ジェネリックリポジトリを全パッケージに導入する前に、重複を削りつつ重要なDB挙動を隠していないことを確認してください。
まず一貫性から始めてください。あるリポジトリが context.Context を取り、別が取らない、あるものが (T, error) を返し別が (*T, error) を返す、といった不整合はサービス、テスト、モックで痛みになります。
各エンティティにSQLの「1つの明白な場所」が残るようにしてください。ジェネリクスはフロー(スキャン、検証、エラーのマッピング)を再利用し、文字列断片にクエリを散らかしてはいけません。
問題を防ぐためのチェック:
- List/Get/Create/Update/Deleteのシグネチャ規約が1つに揃っている
- すべてのリポジトリで使われる予測可能な not-found ルールがある
- ドキュメント化されテストされた安定したListの順序がある
*sql.DBと*sql.Txの両方で同じコードが動く(実行者インターフェース経由)- ジェネリックコードとエンティティルールの境界が明確(検証やビジネスチェックはジェネリック層の外)
もし内部ツールをAppMasterで素早く作り、後で生成されたGoコードをエクスポートしたり拡張したりするなら、これらのチェックはデータ層を予測可能でテストしやすく保つのに役立ちます。
現実的な例:Customerリポジトリを作る
型安全を保ちつつ巧妙になり過ぎない小さなCustomerリポジトリの形を示します。
まず保存モデルから始めます。IDは強く型付けしておくと別のIDと混同しません:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
APIが受け取るものと保存するものを分けます。ここが Create と Update を分ける理由です。
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
ジェネリックのベースは共有フロー(SQL実行、スキャン、エラー変換)を扱い、CustomerリポジトリはCustomer特有のSQLとマッピングを持ちます。サービス層から見るとインターフェースはシンプルに見えます:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
List はフィルタとページネーションを最初からリクエストオブジェクトとして扱ってください。呼び出し側が読みやすく、リミットを忘れるミスを減らせます。
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
このパターンは次のエンティティでもスケールします:構造をコピーし、入力を保存モデルと分け、スキャンを明示的に保てば変更が明確になりコンパイラが助けてくれます。
よくある質問
ジェネリクスは「流れ」自体(クエリの実行、行のループ処理、not-foundの扱い、ページネーションのデフォルト、エラー変換)を再利用するために使います。一方でSQLや行マッピングは各エンティティごとに明示的に置いておくべきです。こうすることで、データ層が実行時の“魔法”になって壊れるのを防げます。
リフレクションはマッピングルールを隠してしまい、エラーをコンパイル時から実行時へ移します。コンパイラのチェックやIDEの補完が効かなくなり、スキーマの小さな変更がテストや本番での驚きになることがよくあります。ジェネリクスと明示的なスキャナ関数を組み合わせれば、型安全性を保ちつつ反復作業を共有できます。
多くの場合、comparable が良いデフォルトです。IDは比較したりマップのキーにしたり渡したりするので、comparable で十分です。もしプロジェクトで int64 と UUID 文字列など複数のIDスタイルが混在するなら、ID型をジェネリックにして全リポジトリに一つの選択を強制しないのが実用的です。
最小限にしておきましょう。通常は共有CRUDフローが必要とするものだけ、例えば GetID() と SetID() などです。埋め込みや複雑な型集合でフィールドを強制すると、ドメイン型がリポジトリパターンに縛られてリファクタが難しくなります。
呼び出すメソッドだけを含む小さな実行者インターフェース(DBTXなど)を用意し、QueryContext、QueryRowContext、ExecContext といった必要なメソッドだけを定義します。これで *sql.DB でも *sql.Tx でも同じリポジトリコードで動かせます。
Get(ctx, id) は型付きのエンティティと明確な「存在しない」シグナルを返すべきです。一般的には ErrNotFound のような共有セントネルエラーを返し、ゼロ値と nil を返すのは避けます。こうすると errors.Is で確実に分岐できます。
入力と保存モデルを分けておきましょう。Create(ctx, CreateInput) や Update(ctx, id, UpdateInput) の方が安全です。これにより呼び出し側がIDやサーバ所有のフィールドを勝手に設定するのを防げます。パッチ更新にはポインタ型やnullable型、明示的なフィールドマスクを使って「未設定」と「ゼロ値」を区別します。
必ず安定した明示的な ORDER BY を設定してください。通常は主キーなど一意のカラムでソートするのが簡単で確実です。ORDER BY がないとページングで同じ行が重複したり抜けたりしてしまいます。
サービスが分岐できる小さなエラー集合を公開します。たとえば ErrNotFound や ErrConflict といったものだけを呼び出し側が直接扱い、他は低レイヤーのエラーとしてラップして返します。文字列解析に頼らず errors.Is を使える設計にします。
共通のヘルパー(ページネーション正規化、not-foundのマッピング、影響行数チェックなど)は一度だけテストし、各エンティティ固有のSQLとスキャンロジックは個別にテストします。さらに各リポジトリが満たすべき『契約テスト』も用意すると良いです:作成→取得が一致する、更新が期待通りに変わる、削除後は ErrNotFound が返る、Listの順序が安定している、など。


