mcpField
G
go-data-persistence

go-data-persistence

github.com/existential-birdsデータ・データベース8587 回インストール11 週間前に更新
リポジトリ

go-data-persistence について

go-data-persistenceは、データベースを使うアプリケーションを開発するGo開発者向けのスキルです。SQLとORMを比較し、コネクションプール、マイグレーション、リポジトリ境界、サービス層のトランザクションを解説します。

主な機能

  1. 01Raw SQLとORMの選択指針
  2. 02コネクションプールの設定
  3. 03golang-migrateの規約
  4. 04リポジトリインターフェースとドメインエラー
  5. 05サービス層のトランザクションパターン
  6. 06クエリ安全性とコンテキストの確認

活用シーン

  1. 01Goのデータアクセス方式の選択
  2. 02本番環境で共有するプールの設定
  3. 03ロールバック可能なスキーマ変更の追加
  4. 04複数操作を原子的に組み合わせる設計

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/existential-birds/beagle --skill go-data-persistence

インストール後、Claude Code で/go-data-persistenceを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
connection-pooling.md
migrations.md
transactions.md

詳細

Go のデータ永続化

クイックリファレンス

トピックリファレンス
コネクションプールの内部構造、サイズ設定、pgx プール、モニタリングreferences/connection-pooling.md
golang-migrate のセットアップ、ファイル規約、CI/CD との統合references/migrations.md
トランザクションヘルパー、サービス層のトランザクション、分離レベルreferences/transactions.md

アプローチの選択

プロジェクトのニーズに基づいて、適切なツールを選択してください。

要素Raw SQL(sqlx/pgx)ORM(Ent/GORM)
複雑なクエリ推奨扱いにくい
型安全性手動自動生成
パフォーマンスの制御完全限定的
迅速なプロトタイピング遅い速い
スキーマのマイグレーションgolang-migrate組み込み(Ent)
学習曲線SQL の知識ORM API
Raw SQL(sqlx/pgx)を使用する場合
  • クエリのパフォーマンスや実行計画を完全に制御する必要がある
  • ドメインに複雑な結合、CTE、ウィンドウ関数、または再帰クエリがある
  • 抽象化によるオーバーヘッドをなくし、PostgreSQL の機能に直接アクセスしたい
  • チームが SQL の記述と保守に慣れている
  • LISTEN/NOTIFY、アドバイザリーロック、COPY などの高度な PostgreSQL 機能が必要である

Go の PostgreSQL ドライバーとして pgx が推奨されます。ネイティブの PostgreSQL プロトコルをサポートし、database/sql より優れたパフォーマンスを提供するとともに、PostgreSQL 固有の機能を利用できます。database/sql との互換性が必要な場合や、複数のデータベースバックエンドを扱う場合は、sqlx を使用してください。

ORM(Ent/GORM)を使用する場合
  • 型安全なクエリビルダーを自動生成し、SQL の記述を避けたい
  • スキーマの大半が CRUD で、リレーションシップが単純である
  • 生成コード、コードとしてのスキーマ、自動マイグレーション(Ent)を重視する
  • 素早くプロトタイピングを行い、スキーマを迅速に反復したい

新しいプロジェクトでは、GORM より Ent が推奨されます。型安全性のためにコード生成を使用し、宣言型のスキーマ DSL と組み込みのマイグレーションサポートを備え、GraphQL と統合できます。チームがすでに GORM に慣れている場合や、プロジェクトが小規模な場合は、GORM が適しています。

接続のセットアップ

データベースに接続するすべての Go アプリケーションには、適切に設定されたコネクションプールが必要です。database/sql パッケージはプーリングを自動的に管理しますが、デフォルト設定は本番環境には適していません。

db, err := sql.Open("postgres", connStr)
if err != nil {
    return fmt.Errorf("opening db: %w", err)
}

// Connection pool configuration
db.SetMaxOpenConns(25)                 // Max simultaneous connections
db.SetMaxIdleConns(10)                 // Connections kept alive when idle
db.SetConnMaxLifetime(5 * time.Minute) // Recycle connections
db.SetConnMaxIdleTime(1 * time.Minute) // Close idle connections

// Verify connection
if err := db.PingContext(ctx); err != nil {
    return fmt.Errorf("pinging db: %w", err)
}
コネクションプール設定の説明

MaxOpenConns -- データベースへの最大オープン接続数です。これにより、同時接続が多すぎてアプリケーションがデータベースに過剰な負荷をかけるのを防ぎます。一般的な Web アプリでは、約 25 に設定します。計算するには、データベースの max_connections から管理用とレプリケーション用の予備分を差し引き、アプリケーションインスタンスの数で割ります。DB の接続数が 100 で、アプリインスタンスが 3 つあり、管理用に 10 を確保する場合は、(100 - 10) / 3 = 30 に設定します。

MaxIdleConns -- 使用されていないときにプール内で維持される接続数です。これらのウォーム接続により、リクエストごとに新しい接続を確立する際のレイテンシを回避できます。約 10、または MaxOpenConns の約 40%に設定します。これを高く設定しすぎるとデータベース接続を無駄にし、低く設定しすぎると頻繁な再接続が発生します。

ConnMaxLifetime -- 接続を再利用できる最大時間です。この時間が経過すると接続が閉じられ、次のリクエスト時に新しい接続が作成されます。これにより、DNS の変更を取り込んだり(新しい IP にフェイルオーバーするクラウドデータベースでは重要です)、読み取りレプリカ間の負荷を再分散したり、接続の陳腐化を防いだりできます。一般的な値は 5 分です。インフラストラクチャで DNS ベースのフェイルオーバーを使用している場合は、より短く(1~2 分)設定します。

ConnMaxIdleTime -- 接続が閉じられるまでアイドル状態でいられる最大時間です。これにより、トラフィックの少ない時間帯に接続をデータベースへ返し、リソースを解放できます。一般的な値は 1 分です。これは ConnMaxLifetime より短くする必要があります。

ネイティブの PostgreSQL サポートを利用した pgx 固有のプーリングについては、references/connection-pooling.md を参照してください。

リポジトリパターン

テストしやすくするため、利用側でストアインターフェースを定義します。具体的なデータベースドライバーに対して実装します。このパターンにより、ドメインロジックをデータベースから切り離せます。

// Store interface for testability
type UserStore interface {
    GetUser(ctx context.Context, id string) (*User, error)
    ListUsers(ctx context.Context, limit, offset int) ([]*User, error)
    CreateUser(ctx context.Context, u *User) error
}

// sqlx implementation
type PostgresUserStore struct {
    db *sqlx.DB
}

func NewPostgresUserStore(db *sqlx.DB) *PostgresUserStore {
    return &PostgresUserStore{db: db}
}

func (s *PostgresUserStore) GetUser(ctx context.Context, id string) (*User, error) {
    var u User
    err := s.db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = $1", id)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrNotFound
    }
    return &u, err
}

func (s *PostgresUserStore) ListUsers(ctx context.Context, limit, offset int) ([]*User, error) {
    var users []*User
    err := s.db.SelectContext(ctx, &users,
        "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
        limit, offset,
    )
    return users, err
}

func (s *PostgresUserStore) CreateUser(ctx context.Context, u *User) error {
    _, err := s.db.NamedExecContext(ctx,
        `INSERT INTO users (id, email, name, created_at, updated_at)
         VALUES (:id, :email, :name, :created_at, :updated_at)`, u)
    return err
}
モデル構造体タグ

sqlx のカラムマッピングには db タグを使用し、モデルをストアの近くに配置します。

type User struct {
    ID        string    `db:"id"`
    Email     string    `db:"email"`
    Name      string    `db:"name"`
    CreatedAt time.Time `db:"created_at"`
    UpdatedAt time.Time `db:"updated_at"`
}
センチネルエラー

データベースパッケージをインポートせずに呼び出し元が判定できる、ドメイン固有のエラーを定義します。

var (
    ErrNotFound  = errors.New("not found")
    ErrConflict  = errors.New("conflict")
)

ストア層でデータベースエラーをドメインエラーにマッピングします。

func (s *PostgresUserStore) CreateUser(ctx context.Context, u *User) error {
    _, err := s.db.NamedExecContext(ctx, query, u)
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return ErrConflict
        }
        return fmt.Errorf("inserting user: %w", err)
    }
    return nil
}

マイグレーション

スキーマ変更の管理には golang-migrate を使用します。マイグレーションは SQL ファイルのペアです。一方は変更の適用(up)、もう一方は変更の取り消し(down)に使用します。

migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_user_roles.up.sql
└── 000002_add_user_roles.down.sql

アプリケーションの起動時にマイグレーションを実行します。

import "github.com/golang-migrate/migrate/v4"

func runMigrations(dbURL string) error {
    m, err := migrate.New("file://migrations", dbURL)
    if err != nil {
        return fmt.Errorf("creating migrator: %w", err)
    }

    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return fmt.Errorf("running migrations: %w", err)
    }

    return nil
}

主なルール:必ず up と down の両方のマイグレーションを作成し、冪等性のために IF NOT EXISTS/IF EXISTS を使用し、本番環境で適用済みのマイグレーションは絶対に変更しないでください。マイグレーションのパターン、CI/CD との統合、安全なマイグレーション戦略の詳細については、references/migrations.md を参照してください。

トランザクション

一貫したコミット/ロールバック処理を保証するため、トランザクションヘルパーを使用します。複数のストア操作を単一のアトミックな単位にまとめられるよう、トランザクションはストア層ではなく サービス層 で管理します。

func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("beginning transaction: %w", err)
    }

    if err := fn(tx); err != nil {
        if rbErr := tx.Rollback(); rbErr != nil {
            return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err)
        }
        return err
    }

    return tx.Commit()
}

ストアメソッドは *sql.Tx パラメーターを受け取り、呼び出し元が制御するトランザクションに参加できるようにします。

func (s *OrderService) PlaceOrder(ctx context.Context, order *Order) error {
    return WithTx(ctx, s.db, func(tx *sql.Tx) error {
        if err := s.orderStore.CreateWithTx(ctx, tx, order); err != nil {
            return fmt.Errorf("creating order: %w", err)
        }
        if err := s.inventoryStore.DecrementWithTx(ctx, tx, order.Items); err != nil {
            return fmt.Errorf("updating inventory: %w", err)
        }
        return nil
    })
}

分離レベル、デッドロックの防止、コンテキストの伝播、テスト戦略については、references/transactions.md を参照してください。

参照資料を読み込むタイミング

次の場合は connection-pooling.md を読み込みます。

  • pgx のネイティブプール(pgxpool.Pool)を設定するとき
  • 本番環境のワークロードに合わせてコネクションプールのサイズを決めるとき
  • クラウドデータベース、PgBouncer、または接続数の制限を扱うとき
  • プールの健全性やメトリクスを監視するとき

次の場合は migrations.md を読み込みます。

  • golang-migrate を初めて設定するとき
  • 新しいマイグレーションファイルを作成するとき
  • マイグレーションを CI/CD パイプラインに統合するとき
  • マイグレーションの失敗やロールバックに対処するとき

次の場合は transactions.md を読み込みます。

  • アトミックである必要がある複数ステップの操作を実装するとき
  • サービス層のトランザクション境界を設計するとき
  • トランザクションの分離レベルを選ぶとき
  • デッドロックや長時間実行されるトランザクションをデバッグするとき

ゲート(マージ前の客観的なチェック)

次の順に実行してください。失敗した手順を正当化して先に進んではいけません。

  1. マイグレーション

    1. 追加または依存するマイグレーションファイルのパスを列挙してください(各バージョンの .up.sql と .down.sql の両方)。
    2. 合格: 各新バージョンについて、命名規則が一貫した対応ペアがディスク上にあります(マイグレーションを参照)。
    3. 合格: 気にかける環境(本番環境や共有開発環境など)ですでに適用されているマイグレーションの内容を書き換えていません。代わりに新しいバージョンを追加しています。
  2. クエリの安全性

    1. 差分を確認し、リクエストフィールド、JSON、その他の外部入力を含む動的 SQL を fmt.Sprintf、+、または文字列連結で構築していないか調べてください。
    2. 合格: そのようなクエリはすべてバインドパラメーター($1、:name)またはパラメーター化されたステートメントを生成する ORM/クエリビルダーを使用しています。動的にする必要のある識別子(テーブル名/カラム名)には、ユーザーの生の文字列ではなく、明示的な許可リストを使用しています。
  3. プールとコンテキスト

    1. データベースプールの構築(sql.Open、pgxpool.New など)がプロセスの起動時に一度だけ行われ、リクエストごとのハンドラー内ではなく共有されていることを確認してください。
    2. 合格: キャンセルやタイムアウトを考慮すべきコードパスでは、context.Context に関連する処理に Query/Exec ではなく、QueryContext、ExecContext、GetContext または同等のメソッドを使用しています。

アンチパターン

クエリで文字列連結を使用する
// BAD -- SQL injection vulnerability
query := "SELECT * FROM users WHERE name = '" + name + "'"

常にパラメーター化クエリ($1、$2 など)または名前付きパラメーター(:name)を使用してください。

データベースの型をハンドラーに漏らす
// BAD -- handler depends on sql.ErrNoRows
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := s.store.GetUser(ctx, id)
    if errors.Is(err, sql.ErrNoRows) { // handler knows about sql package
        http.NotFound(w, r)
        return
    }
}

代わりに、ストアからドメインエラー(ErrNotFound)を返し、ハンドラーでそれを確認してください。

リクエストごとに新しい接続を開く
// BAD -- bypasses connection pooling entirely
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    db, _ := sql.Open("postgres", connStr) // new pool per request!
    defer db.Close()
}

起動時にデータベース接続を一度だけ開き、アプリケーション全体でプールを共有してください。

本番コードでの SELECT *
// BAD -- fragile, breaks when columns change
err := db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = $1", id)

必要なカラムを明示的に列挙してください。これにより、スキーマ変更に対してクエリの耐性が高まり、不要なデータの取得を避けられます。

コンテキストのキャンセルを処理しない
// BAD -- ignores context, query runs even if client disconnects
rows, err := db.Query("SELECT * FROM large_table")

常に Context バリアント(QueryContext、ExecContext、GetContext)を使用し、リクエストのコンテキストを渡してください。これにより、呼び出し元が処理を断念した際にクエリがキャンセルされます。

ストアメソッド内のトランザクション
// BAD -- store controls transaction, caller cannot compose
func (s *UserStore) CreateUser(ctx context.Context, u *User) error {
    tx, _ := s.db.BeginTx(ctx, nil)
    // ... insert user ...
    return tx.Commit()
}

トランザクションはサービスレイヤーで管理し、*sql.Tx をストアメソッドに渡してください。正しいパターンについては、references/transactions.mdを参照してください。

コメント