# Echo × データベース本番設計：pgx・sqlc・GORM の選び方、コネクションプール、トランザクション境界、context 伝播

> Go Echo（v5）のデータベース層を本番品質で設計する実装ガイド。pgx・sqlc・GORM の選定、pgxpool のコネクションプール調整、c.Request().Context() を貫通させる context 伝播、安全なトランザクション境界（WithTx）、Repository パターン、SQLインジェクション対策、マイグレーション、サーバーレスの接続枯渇までを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, PostgreSQL, 型安全, アーキテクチャ設計, セキュリティ
- URL: https://tomodahinata.com/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- ハンドラの c.Request().Context() を全クエリに渡す。これでタイムアウト・キャンセル・トレースが DB まで貫通し、切断された処理が DB を占有し続けない
- 型安全を最優先なら sqlc（SQLからGoを生成）、低レベル制御なら pgx、開発速度ならGORM。3者の損益で選ぶ
- コネクションプール（pgxpool）の MaxConns はDBの max_connections とインスタンス数から逆算する。サーバーレスでは接続枯渇に RDS Proxy 等で備える
- トランザクションは defer tx.Rollback（コミット後はno-op）の WithTx ヘルパに集約し、握りつぶさず1箇所で commit/rollback する
- クエリは必ずプレースホルダ（$1）で組み立てる。文字列連結は SQLインジェクション。冪等性は一意制約でDB側に担保させる

---

API の信頼性は、最終的に**データベース層の設計**で決まります。コネクションプールを誤れば高負荷で枯渇し、トランザクション境界を誤れば不整合が残り、`context` を渡し忘れれば**クライアントが切断したのに DB は処理を続ける**——こうした事故は「動くデモ」では見えず、本番のピーク時に初めて牙を剥きます。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)のデータベース編です。Echo の役割は薄い HTTP 層なので、**DB 設計の大半は Go 標準と各ドライバの作法**です。本記事は「Echo ハンドラから DB まで、何をどう繋ぐか」を、型安全・回復性・セキュリティの観点で実装します。

> **この記事のルール**：Echo の API は **公式ドキュメント（v5・2026年6月時点）** に基づきます。DB ライブラリ（`pgx` v5 / `sqlc` / GORM v2 / `golang-migrate`）は活発に更新されるため、本番投入前に各公式で最新 API を確認してください。**接続情報（DSN・認証情報）は環境変数／シークレットマネージャ前提**で、コードやイメージに焼き込みません。

---

## 1. 最重要：`context` をハンドラから DB まで貫通させる

Echo ハンドラから DB を呼ぶとき、**必ず `c.Request().Context()` を渡します**。これが本記事で唯一「Echo 特有」かつ最も効くポイントです。

```go
func (h *UserHandler) Get(c *echo.Context) error {
	ctx := c.Request().Context() // ← リクエストの context を取り出す

	user, err := h.repo.FindByID(ctx, c.Param("id")) // ← DB まで渡す
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, user)
}
```

なぜ重要か。この `ctx` には**リクエストのライフサイクル**が乗っています。

- **クライアント切断**：ブラウザが離脱すると `ctx` がキャンセルされ、進行中のクエリも中断される。**切断された処理が DB コネクションを占有し続ける**事故を防ぐ。
- **タイムアウト**：`context.WithTimeout` を噛ませれば、遅いクエリを期限で打ち切れる（v5 で `middleware.Timeout` が削除されたいま、タイムアウトは context で表現する）。
- **トレース伝播**：OpenTelemetry のトレース ID が `ctx` を通って DB スパンまで繋がり、[可観測性](/blog/opentelemetry-observability-production-tracing-metrics-logs)が一本の糸になる。

```go
// 重いクエリには期限を付ける
ctx, cancel := context.WithTimeout(c.Request().Context(), 3*time.Second)
defer cancel()
rows, err := pool.Query(ctx, "SELECT ...")
```

`context.Background()` を DB 呼び出しに使うのは**アンチパターン**です。リクエスト由来の `ctx` を捨てた瞬間、上記の恩恵をすべて失います。

---

## 2. ライブラリ選定：pgx / sqlc / GORM を損益で選ぶ

「とりあえず GORM」で始めて後悔する、はよくある話です。3つの選択肢を**軸**で選びます。

| 観点 | `database/sql` + **pgx** | **sqlc** | **GORM** |
| --- | --- | --- | --- |
| 抽象度 | 低（生 SQL） | 低（生 SQL + 型生成） | 高（ORM） |
| 型安全 | 手動マッピング | **SQL から Go を自動生成（最強）** | 構造体タグ依存 |
| 学習コスト | 中 | 中 | 低〜中（独自 DSL） |
| パフォーマンス | ◎ | ◎ | ○（リフレクション） |
| 複雑クエリ | ◎（SQL そのまま） | ◎（SQL そのまま） | △（生 SQL へ退避しがち） |
| 向く場面 | 細かい制御・高性能 | **型安全と SQL の両立** | CRUD 中心・開発速度優先 |

**判断の目安**：

- **型安全を最重視**するなら **sqlc**。`.sql` ファイルに書いたクエリから、引数・戻り値が型付けされた Go コードが生成されます。「SQL を書く自由」と「コンパイル時の型保証」を両立でき、本サイトが掲げる[型安全の規律](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security)と最も相性が良い選択です。
- **低レベルな制御・最高性能**が要るなら **pgx**（PostgreSQL 専用ドライバ）。
- **CRUD 中心で開発速度優先**なら **GORM**。ただし複雑クエリで結局生 SQL に逃げるなら、最初から sqlc/pgx の方が一貫します。

---

## 3. pgx：コネクションプールを正しく設定する

`pgxpool` は本番で必須のコネクションプールです。**プールの上限設計**を誤ると、高負荷時に `too many connections` でサービスが落ちます。

```go
import "github.com/jackc/pgx/v5/pgxpool"

func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(dsn) // DSN は os.Getenv("DATABASE_URL")
	if err != nil {
		return nil, err
	}
	cfg.MaxConns = 10                       // このインスタンスが握る最大接続数
	cfg.MinConns = 2                        // 常時温めておく最小接続数
	cfg.MaxConnLifetime = time.Hour         // 接続を作り直す周期（DB側の制限対策）
	cfg.MaxConnIdleTime = 30 * time.Minute  // アイドル接続の回収
	cfg.HealthCheckPeriod = time.Minute     // 死んだ接続の検出

	return pgxpool.NewWithConfig(ctx, cfg)
}
```

> **`MaxConns` の決め方**：`DB の max_connections` を**全アプリインスタンス数で割り、管理接続分の余裕を引いた値**が上限です。例：`max_connections=100`、アプリ 4 インスタンス、予約 20 なら `(100 - 20) / 4 = 20` が天井。ここを「とりあえず大きく」すると、オートスケールで接続が一気に増えて DB が先に死にます。

プールは**アプリ起動時に一度だけ生成**し、Echo ハンドラ間で共有します。`lifespan` 相当の初期化は `main` で行い、`defer pool.Close()` で[グレースフルに閉じます](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide)。

### 3.1 型安全に近い行マッピング（pgx v5）

pgx v5 は、行を構造体に詰めるヘルパを備えます。手書きの `rows.Scan` 地獄を避けられます。

```go
type User struct {
	ID    string `db:"id"`
	Name  string `db:"name"`
	Email string `db:"email"`
}

func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
	rows, err := r.pool.Query(ctx, `SELECT id, name, email FROM users WHERE id = $1`, id)
	if err != nil {
		return nil, err
	}
	user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrUserNotFound // ドメインエラーへ変換（後述）
	}
	return &user, err
}
```

---

## 4. トランザクション境界：`WithTx` ヘルパに集約する

トランザクションは「複数の書き込みを全か無かにする」ための機構です。`Begin / Commit / Rollback` を各所に手書きすると、**rollback の書き忘れ**で接続がリークします。**1つのヘルパに集約**します。

```go
// fn の中の処理を1トランザクションで実行する。エラー/panic なら rollback
func (r *Store) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error {
	tx, err := r.pool.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx) // commit 済みなら no-op。panic 時も確実に巻き戻る

	if err := fn(tx); err != nil {
		return err // defer の Rollback が走る
	}
	return tx.Commit(ctx)
}
```

使う側は、トランザクションの**意図だけ**を書けます。

```go
func (uc *OrderUseCase) Place(ctx context.Context, order Order) error {
	return uc.store.WithTx(ctx, func(tx pgx.Tx) error {
		if err := insertOrder(ctx, tx, order); err != nil {
			return err
		}
		return decrementStock(ctx, tx, order.Items) // どちらか失敗すれば両方巻き戻る
	})
}
```

> **冪等性は DB に担保させる**：リトライや二重送信に対しては、アプリのフラグ管理より**一意制約（unique index）**が堅牢です。`INSERT ... ON CONFLICT DO NOTHING` や冪等キー列で「同じ操作は1回だけ」を DB の制約として表現します。決済のように厳密さが要る領域での冪等設計は、[二重課金ゼロの決済信頼性](/blog/payment-double-charge-prevention-idempotency-procurement-guide)で深掘りしています。

---

## 5. Repository パターン：DB をインターフェースの裏に隠す

UseCase 層が pgx や GORM を直接知ると、DB を差し替えられず、テストに本物の DB が要ります。**Repository をインターフェースで定義**し、実装を裏に隠します。これは[クリーンアーキテクチャ + DI](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)の中核でもあります。

```go
// ドメイン層：インターフェース（DB 技術を知らない）
type UserRepository interface {
	FindByID(ctx context.Context, id string) (*User, error)
	Create(ctx context.Context, u *User) error
}

// インフラ層：pgx 実装（差し替え可能）
type pgUserRepository struct {
	pool *pgxpool.Pool
}

func NewUserRepository(pool *pgxpool.Pool) UserRepository {
	return &pgUserRepository{pool: pool}
}
```

これで UseCase は `UserRepository` にのみ依存し、テストでは[モックに差し替え](/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide)、本番では pgx 実装を注入する——という疎結合が成立します。

---

## 6. セキュリティ：SQLインジェクションを構造的に潰す

**外部入力を SQL に文字列連結してはいけません。** 必ずプレースホルダ（`$1`, `$2`…）を使います。これは「気をつける」ではなく「**そうとしか書けない構造にする**」のが正解です。

```go
// ❌ 絶対にやらない：文字列連結（SQLインジェクション）
q := "SELECT * FROM users WHERE email = '" + email + "'" // 攻撃者が ' OR '1'='1 を注入できる

// ✅ プレースホルダ（ドライバが値を安全にエスケープ／バインド）
rows, err := pool.Query(ctx, "SELECT * FROM users WHERE email = $1", email)
```

`ORDER BY` のカラム名のように**プレースホルダが使えない箇所**は、ユーザー入力を直接埋めず、**許可リスト（allowlist）**で受けます。

```go
allowed := map[string]string{"created_at": "created_at", "name": "name"}
col, ok := allowed[c.QueryParam("sort")]
if !ok {
	col = "created_at" // 既定値。未知の入力は SQL に渡さない
}
query := fmt.Sprintf("SELECT ... ORDER BY %s", col) // col は allowlist 由来なので安全
```

入力の検証は[バインディング＆バリデーション](/blog/go-echo-request-binding-validation-error-handling-guide)で境界に効かせ、SQL 層では「もう信用できる値しか来ない」状態を二重に守ります。

---

## 7. GORM を選んだ場合の本番注意点

GORM を使うなら、最低限この2つは押さえます。

```go
import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

// ① プールは内部の *sql.DB で設定する（GORM 自体には無い）
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(10)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetConnMaxLifetime(time.Hour)

// ② context を必ず渡す（WithContext）。N+1 は Preload で潰す
var users []User
err = db.WithContext(ctx).Preload("Orders").Find(&users).Error // 関連を1回でまとめて取得
```

GORM の最大の罠は **N+1 問題**（一覧取得後に関連を1件ずつ引いて N 回追加クエリが飛ぶ）です。`Preload`／`Joins` で**まとめて取得**します。`WithContext(ctx)` を忘れると[第1章](#1-最重要contextをハンドラからdbまで貫通させる)の恩恵を失う点も同じです。

---

## 8. マイグレーション：スキーマもコードで管理する

スキーマ変更は手動 SQL ではなく、バージョン管理されたマイグレーションで行います（`golang-migrate` / `goose` が定番）。

```bash
# golang-migrate：up/down のペアでバージョンを進める
migrate -database "$DATABASE_URL" -path ./migrations up
```

CI で**マイグレーションの適用と整合性**を検証し、本番デプロイ前に流す運用にします。無停止でのスキーマ進化（後方互換を保った段階的変更）は、[ゼロダウンタイム・マイグレーションの設計](/blog/alembic-zero-downtime-migrations-sqlalchemy)（Python 例だが原則は共通）が体系的です。

---

## 9. サーバーレス／オートスケールでの接続枯渇

Lambda や Cloud Run のように**インスタンスが多数・短命**な環境では、各インスタンスがプールを持つと**接続数が爆発**します。`max_connections` を即座に食い潰し、DB が落ちます。

- **常駐サーバー（ECS/Cloud Run min>0）**：`pgxpool` でプールを共有すれば素直に機能する。
- **Lambda 等の関数**：接続プロキシ（**RDS Proxy** 等）を挟み、関数側はプロキシに繋ぐ。コネクションの多重化はプロキシに任せる。

この罠と対策は、[Lambda × RDS/Aurora の接続管理（RDS Proxy）](/blog/aws-lambda-rds-aurora-connection-management-rds-proxy-vpc-guide)で詳説しています。Echo を ECS/Fargate で常駐運用するなら通常は `pgxpool` で十分で、これが Echo を「関数」ではなく「常駐サーバー」で動かす利点でもあります。

---

## まとめ：DB 層を本番品質にする7原則

1. **`c.Request().Context()` を全クエリに貫通**させる。`context.Background()` を DB に使わない。
2. **型安全重視なら sqlc、制御なら pgx、速度なら GORM**——損益で選ぶ。
3. **`pgxpool` の `MaxConns` は DB 上限÷インスタンス数から逆算**。大きすぎる設定が DB を殺す。
4. **トランザクションは `WithTx` に集約**。`defer tx.Rollback` で巻き戻しを構造化する。
5. **Repository をインターフェースで隠す**。DB 差し替えとテスト容易性を両立。
6. **必ずプレースホルダ**。連結禁止。プレースホルダ不可の箇所は allowlist。
7. **冪等性は一意制約で DB に担保**させ、サーバーレスでは接続プロキシで枯渇を防ぐ。

DB 層は「動く」と「本番で耐える」の差が最も大きい領域です。Echo の薄さは、この層を Go の標準的な作法で堅く作れることの裏返しでもあります。次は、この上に載る[認証・認可](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide)と、全体を束ねる[クリーンアーキテクチャ](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)へ。
