🎯 什麼情境該想到我

當你「想用一個物件當某張表的閘道,把該表所有 SQL 集中管理」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:以一個物件作為整張資料表的閘道,該物件的方法涵蓋對這張表的所有查詢與異動,SQL 全部集中於此。

做法要點:

  • 一個 Gateway 實例服務「一整張表」,方法如 find、update、insert、delete。
  • 查詢通常回傳 Record Set(記錄集)或資料結構,供上層使用。
  • 物件本身不含業務邏輯,只負責資料存取,職責單純。
  • 常搭配 Table Module 使用(Table Module 拿記錄集做業務)。

★★ 這是 Go 生態的主流做法,而且多數人不知道它有名字

// ★ sqlc 產生的程式碼就是表格資料閘道
type Queries struct{ db DBTX }
 
func (q *Queries) GetOrder(ctx context.Context, id string) (Order, error)
func (q *Queries) ListOrdersByCustomer(ctx context.Context, customerID string) ([]Order, error)
func (q *Queries) InsertOrder(ctx context.Context, arg InsertOrderParams) (Order, error)
func (q *Queries) UpdateOrderStatus(ctx context.Context, arg UpdateOrderStatusParams) (int64, error)
// ★★ 特徵:一個型別、無狀態、每個方法對應一個 SQL、回傳扁平的 row struct

★★ sqlc/sqlx/手寫的 db 包,本質上都是表格資料閘道。
它是 Go 社群壓倒性的預設選擇,理由很具體:
★ SQL 看得見(可 review、可 EXPLAIN)、無隱形查詢、無 N+1、編譯期型別安全。

★ 為什麼它在 Go 裡勝過 主動記錄與資料對應器

表格資料閘道(sqlc)主動記錄(GORM)資料對應器(ent)
SQL 可見★★ 完全可見隱藏部分隱藏
N+1 風險★ 不可能(沒有延遲載入)★★ 高中
型別安全★★ codegen,編譯期★ 反射+字串,執行期codegen
領域模型解耦★ 不解耦(row struct 就是資料形狀)不解耦★ 解耦
學習成本★ 低(就是 SQL)中★ 高
複雜查詢★★ 直接寫 SQL★ 難(要 Raw)中

★★ 關鍵取捨:表格資料閘道不幫你解耦領域模型。
Order 這個 row struct 的形狀就是資料表的形狀。
→ ★ 要領域模型就在它上面加一層 reconstitute(見 領域模型、資料對應器),
而不是換掉它。

★★ 交易怎麼傳:Go 的核心慣例

// ★ sqlc 的 DBTX 介面讓 *sql.DB 與 *sql.Tx 都能用
type DBTX interface {
    ExecContext(context.Context, string, ...any) (sql.Result, error)
    QueryContext(context.Context, string, ...any) (*sql.Rows, error)
    QueryRowContext(context.Context, string, ...any) *sql.Row
    PrepareContext(context.Context, string) (*sql.Stmt, error)
}
 
func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{db: tx} }
// ★★ 服務層開交易,把 tx 綁到一個新的 Queries 上
tx, err := db.BeginTx(ctx, nil)
defer tx.Rollback()
qtx := q.WithTx(tx)
if err := qtx.DecStock(ctx, …); err != nil { … }
if _, err := qtx.InsertOrder(ctx, …); err != nil { … }
return tx.Commit()

★★ 這是整個 Go 資料層最重要的一個模式:
把「要不要在交易裡」變成一個參數,而不是兩套方法。
⚠️ 常見錯誤:閘道自己開交易 → 呼叫端無法把多個操作放進同一個交易。
→ ★ 閘道永遠不開交易;交易邊界屬於 服務層。

★★ 三個 Go 的實務細節

// ① ★★ NULL:用 sql.Null[T](Go 1.22+ 的泛型版本,取代 sql.NullString 那一堆)
var name sql.Null[string]
err := row.Scan(&name)
if name.Valid { use(name.V) }
// ★ 直接 Scan 到 *string 在 NULL 時會報錯:converting NULL to string is unsupported
 
// ② ★★ 「更新了幾列」是唯一能判斷樂觀鎖成敗的方式
res, err := q.UpdateOrder(ctx, params)   // WHERE id = $1 AND version = $2
n, _ := res.RowsAffected()
if n == 0 { return ErrConcurrentModification }   // ★ 沒有錯誤,但沒改到
// → 見 [[樂觀離線鎖]]
 
// ③ ★ rows 一定要 Close,而且要檢查 rows.Err()
rows, err := q.List(ctx)
defer rows.Close()
for rows.Next() { … }
if err := rows.Err(); err != nil { … }   // ★★ 迴圈正常結束 ≠ 沒有錯誤

★★ rows.Err() 是最常被漏掉的一行:迴圈因為網路中斷而提早結束時,
rows.Next() 只是回 false——你會拿到一份不完整的資料而完全不知道。

★ 兄弟辨析

差別
列資料閘道★ 一個物件代表一列;表格資料閘道是一個物件服務整張表
主動記錄列資料閘道 + 業務邏輯
資料對應器★ 在閘道之上把 row struct 轉成領域物件
查詢物件★ 動態組查詢;表格資料閘道是預先寫好的固定查詢
閘道一般化的「封裝外部資源」模式

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 只封裝資料存取、不放業務邏輯;邏輯要另找地方。

  • 若偏好「一物件對一列」的存取風格,改用列資料閘道。

  • 使用領域模型並要更徹底解耦時,改用資料對應器。

  • ★★ 閘道永遠不自己開交易。 用 DBTX 介面讓 *sql.DB 與 *sql.Tx 都能傳進來,交易邊界屬於 服務層。

  • ★★ rows.Err() 必查。 迴圈因網路中斷提早結束時 Next() 只回 false——你會拿到不完整的資料而不知道。

  • ★★ NULL 要用 sql.Null[T](Go 1.22+ 泛型版)。直接 Scan 到 *string 在 NULL 時會報錯。

  • ★★ 樂觀鎖只能靠 RowsAffected() == 0 判斷——沒有錯誤,但沒改到。

  • ★ 這個模式不幫你解耦領域模型。row struct 的形狀就是表的形狀;要領域模型就在它上面加一層。

  • ★ 不要為每張表都產生完整 CRUD。用不到的查詢是死碼,而且會誘使別人用錯的方式存取。

  • SELECT * 會在加欄位時破壞 scan。一律列出欄位(sqlc 會強制你這麼做)。

  • 跨表的查詢不屬於任何單一「表格」閘道。放一個 ReportQueries 或 查詢物件,不要硬塞。

🔗 相關工具