Avoid ORM #
ORM menjanjikan produktivitas: tidak perlu menulis SQL, relasi antar tabel dikelola otomatis, dan kode terlihat bersih dengan method chaining. Di awal proyek, semua ini terasa benar. Masalah mulai muncul ketika sistem tumbuh — ketika tabel punya jutaan baris, ketika query butuh JOIN tiga tabel dengan kondisi kompleks, ketika laporan analytics butuh window function, atau ketika N+1 tiba-tiba muncul di production dan developer butuh berjam-jam untuk menyadarinya karena SQL yang dihasilkan ORM tidak pernah dilihat. Judul artikel ini bukan “jangan pakai ORM sama sekali” — ORM punya tempat yang tepat. Yang perlu dihindari adalah penggunaan ORM secara refleks untuk semua query, tanpa pernah melihat SQL yang dihasilkan, tanpa memahami trade-off yang dibawa. Artikel ini membahas masalah konkret yang ditimbulkan ORM, perbandingan GORM vs sqlx di Go, pola hybrid yang scalable, dan panduan kapan harus turun ke raw SQL.
Hidden Query: ORM Menyembunyikan Biaya yang Nyata #
Masalah terbesar ORM bukan pada SQL yang buruk secara teknis — tapi pada fakta bahwa developer tidak tahu SQL apa yang dieksekusi. Setiap kali ini terjadi, ada potensi performa tersembunyi yang menunggu untuk menjadi insiden di production.
Contoh Hidden Query di GORM #
// Kode Go yang terlihat bersih
var users []User
db.Preload("Orders").Preload("Orders.Items").Find(&users)
// SQL yang sebenarnya dieksekusi (dengan 100 user):
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM orders WHERE user_id IN (1,2,3,...,100)
// Query 3: SELECT * FROM order_items WHERE order_id IN (...)
//
// Ini OK — Preload yang benar menggunakan batch query
// Tapi perhatikan Preload tanpa scoping:
db.Preload("Orders").Find(&users)
// Query 2 di atas tidak ada LIMIT atau filter status
// Jika user punya 500 order masing-masing:
// → Query 2 mengembalikan 50.000 baris ke memory aplikasi
// → Transfer network: mungkin puluhan MB
// → Semua masuk ke memory sebagai slice Go
// Developer tidak sadar karena kodenya terlihat "satu baris"
SELECT * yang ORM Lakukan Secara Default #
// GORM: Find selalu SELECT *
var products []Product
db.Where("category_id = ?", categoryID).Find(&products)
// SQL yang dihasilkan:
// SELECT * FROM products WHERE category_id = 5
//
// Tabel products punya kolom:
// id, name, description (TEXT 5KB), price, stock, weight,
// dimensions (JSON), metadata (JSON), image_urls (JSON),
// created_at, updated_at, deleted_at
//
// API hanya butuh: id, name, price
// Tapi kita menarik semua kolom termasuk description 5KB per produk
// Untuk 200 produk: 200 × 5KB = 1MB data tak berguna per request
// Solusi GORM: gunakan Select() eksplisit
db.Select("id, name, price").Where("category_id = ?", categoryID).Find(&products)
// SQL: SELECT id, name, price FROM products WHERE category_id = 5
// Lebih baik, tapi banyak developer lupa melakukan ini
Query yang ORM Hasilkan tapi Tidak Bisa Kamu Kontrol #
// Soft delete di GORM: otomatis menambahkan WHERE deleted_at IS NULL
// di SEMUA query
var orders []Order
db.Where("user_id = ?", userID).Find(&orders)
// SQL: SELECT * FROM orders WHERE user_id = 42 AND deleted_at IS NULL
// Masalah: jika composite index kamu adalah (user_id, status, created_at)
// tapi GORM menambahkan deleted_at IS NULL di belakang,
// query planner mungkin tidak menggunakan index tersebut secara optimal
// karena urutan kolom dalam kondisi WHERE berbeda dari urutan di index
// Kamu tidak bisa mengontrol kondisi yang GORM tambahkan secara otomatis
// tanpa workaround yang mengotori kode
// Contoh GORM yang menghasilkan kondisi tidak index-friendly:
db.Where("DATE(created_at) = ?", today).Find(&orders)
// SQL: ... WHERE DATE(created_at) = '2026-04-18' AND deleted_at IS NULL
// DATE() di kolom → index tidak terpakai (sudah dibahas di SQL Function Overuse)
// Dan kondisi ini sering muncul karena ORM memudahkan penulisan yang "terlihat wajar"
Lima Masalah Konkret ORM di Production #
Masalah 1: N+1 yang Tersembunyi di Balik Kode yang Rapi #
// Kode yang terlihat bersih, tapi berbahaya
func GetUserList(ctx context.Context) ([]UserResponse, error) {
var users []User
db.Find(&users) // 1 query
var result []UserResponse
for _, u := range users {
// Akses u.Profile memicu query baru jika belum di-load!
// GORM lazy load: satu query per user
result = append(result, UserResponse{
Name: u.Name,
City: u.Profile.City, // ← hidden query: SELECT * FROM profiles WHERE user_id = ?
Country: u.Profile.Country, // ← tidak ada query tambahan (sudah di-load)
})
}
return result, nil
}
// Untuk 1.000 user: 1 + 1.000 = 1.001 query
// Developer mungkin tidak sadar sampai melihat slow query log
// GORM dengan Preload (lebih baik):
db.Preload("Profile").Find(&users)
// 1 query users + 1 batch query profiles = 2 query total
// Tapi tetap SELECT * dari kedua tabel
Masalah 2: Overfetching yang Terakumulasi #
// Contoh nyata: endpoint list produk dengan ORM
func GetProducts(categoryID int) []ProductResponse {
var products []Product
db.Where("category_id = ?", categoryID).
Where("deleted_at IS NULL").
Find(&products)
// Menarik SEMUA kolom termasuk: description (TEXT 5KB),
// metadata (JSON 2KB), image_urls (JSON 1KB), dll.
var responses []ProductResponse
for _, p := range products {
responses = append(responses, ProductResponse{
ID: p.ID,
Name: p.Name,
Price: p.Price,
// Hanya butuh 3 kolom dari 15 yang di-load!
})
}
return responses
}
// Biaya nyata untuk 200 produk:
// Data yang ditarik: 200 × (5KB + 2KB + 1KB + ...) = ~2MB
// Data yang dipakai: 200 × (8B + 200B + 8B) = ~44KB
// Efisiensi: ~2%
// Sisa 98% data dibuang setelah mapping ke DTO
Masalah 3: Query Kompleks yang ORM Ekspresikan Buruk #
// Query yang perlu ditulis: ambil produk terlaris per kategori
// (window function: RANK() OVER PARTITION BY)
// Dengan GORM — tidak bisa dilakukan dengan bersih:
// Harus pakai Raw() yang mengalahkan tujuan ORM
var result []struct {
CategoryID int
ProductID int
SalesRank int
}
db.Raw(`
SELECT category_id, id AS product_id,
RANK() OVER (PARTITION BY category_id ORDER BY total_sold DESC) AS sales_rank
FROM products
WHERE status = 'active'
`, ).Scan(&result)
// Kamu menulis SQL mentah di dalam ORM
// Tidak ada manfaat ORM di sini, hanya overhead abstraksi
// Lebih baik langsung dengan sqlx:
sqlx.SelectContext(ctx, db, &result, `
SELECT category_id, id AS product_id,
RANK() OVER (PARTITION BY category_id ORDER BY total_sold DESC) AS sales_rank
FROM products
WHERE status = 'active'
`)
// Lebih jelas, tidak ada layer ORM yang tidak berguna
Masalah 4: Magic Condition yang Mengganggu Index #
// GORM soft delete menambahkan kondisi otomatis
// yang bisa mengganggu index yang sudah dirancang dengan hati-hati
// Index yang dirancang untuk query pagination:
// CREATE INDEX idx_orders ON orders (user_id, status, created_at DESC)
// Query GORM yang dihasilkan:
// SELECT * FROM orders
// WHERE user_id = 42 AND status = 'paid'
// AND deleted_at IS NULL ← ditambahkan GORM
// ORDER BY created_at DESC
// LIMIT 20
// Masalah: kondisi deleted_at IS NULL menginterupsi urutan kolom
// yang optimal untuk index (user_id, status, created_at)
// Index mungkin masih terpakai tapi tidak seoptimal jika
// deleted_at juga ada di index atau tidak ada kondisi tersebut
Masalah 5: ORM Mendorong Pengabaian EXPLAIN #
Ini adalah masalah budaya, bukan teknis semata:
Pola yang terjadi di tim yang terlalu bergantung ORM:
──────────────────────────────────────────────────────────────
Developer menulis kode ORM
→ "Terlihat bersih, langsung commit"
→ Tidak ada kebiasaan melihat SQL yang dihasilkan
→ Tidak ada kebiasaan menjalankan EXPLAIN
→ Tidak ada review apakah index terpakai
Masalah performa muncul 3 bulan kemudian
→ "Database tiba-tiba lambat"
→ Investigasi: ditemukan query tanpa index, N+1, SELECT *
→ Solusi: refactor besar-besaran yang bisa dicegah dari awal
Dengan raw SQL / sqlx:
Developer menulis SQL eksplisit
→ Lebih mudah untuk menjalankan EXPLAIN di development
→ Review SQL menjadi bagian natural dari code review
→ Masalah performa terdeteksi lebih awal
──────────────────────────────────────────────────────────────
GORM vs sqlx di Go: Perbandingan Konkret #
Di ekosistem Go, pilihan yang paling umum adalah antara GORM (ORM penuh) dan sqlx (thin wrapper di atas database/sql). Berikut perbandingan untuk kasus yang sama.
Kasus: Ambil Daftar Order dengan Data User #
// === Pendekatan GORM ===
type Order struct {
gorm.Model
UserID uint
Total float64
Status string
User User `gorm:"foreignKey:UserID"`
}
func GetOrdersGORM(userID uint, status string) ([]Order, error) {
var orders []Order
result := db.Preload("User").
Where("user_id = ? AND status = ?", userID, status).
Order("created_at DESC").
Limit(20).
Find(&orders)
return orders, result.Error
}
// SQL yang dihasilkan:
// SELECT * FROM orders WHERE user_id = 42 AND status = 'paid'
// AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20
// SELECT * FROM users WHERE id IN (42) ← batch Preload
//
// Masalah tersembunyi:
// - SELECT * dari orders (semua kolom) + SELECT * dari users
// - deleted_at IS NULL ditambahkan otomatis
// - Tidak mudah untuk pilih kolom spesifik
// === Pendekatan sqlx ===
type OrderWithUser struct {
OrderID int64 `db:"order_id"`
Total float64 `db:"total"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UserName string `db:"user_name"`
UserEmail string `db:"user_email"`
}
func GetOrdersSQLX(ctx context.Context, db *sqlx.DB, userID int64, status string) ([]OrderWithUser, error) {
var orders []OrderWithUser
err := db.SelectContext(ctx, &orders, `
SELECT
o.id AS order_id,
o.total,
o.status,
o.created_at,
u.name AS user_name,
u.email AS user_email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.user_id = ?
AND o.status = ?
AND o.deleted_at IS NULL
ORDER BY o.created_at DESC
LIMIT 20
`, userID, status)
return orders, err
}
// SQL yang dieksekusi: persis seperti yang ditulis
// Hanya kolom yang dibutuhkan yang ditarik
// JOIN dalam satu query — tidak ada query kedua untuk user
// Kondisi eksplisit dan terkontrol penuh
Kapan GORM Masih Masuk Akal #
// GORM tetap berguna untuk operasi CRUD sederhana
// yang tidak butuh kolom spesifik atau query kompleks
// Contoh yang cocok untuk GORM:
func CreateUser(user *User) error {
return db.Create(user).Error // INSERT sederhana
}
func UpdateUserStatus(userID uint, status string) error {
return db.Model(&User{}).
Where("id = ?", userID).
Update("status", status).Error
// UPDATE users SET status = ?, updated_at = ? WHERE id = ?
}
func DeleteUser(userID uint) error {
return db.Delete(&User{}, userID).Error
// UPDATE users SET deleted_at = ? WHERE id = ? (soft delete)
}
// Untuk operasi di atas, GORM menyederhanakan kode
// dan menghasilkan query yang memadai
Pola Hybrid: Repository Layer yang Tepat #
Solusi terbaik bukan memilih antara “hanya ORM” atau “tidak ada ORM sama sekali” — tapi mendesain repository layer yang menggunakan tool yang tepat untuk setiap jenis operasi.
Struktur Repository yang Disarankan #
// Repository yang menggunakan GORM untuk write, sqlx untuk read kompleks
type OrderRepository struct {
gormDB *gorm.DB // untuk write operations
sqlxDB *sqlx.DB // untuk read operations yang butuh kontrol
}
// WRITE: gunakan GORM — sederhana dan aman
func (r *OrderRepository) Create(ctx context.Context, order *Order) error {
return r.gormDB.WithContext(ctx).Create(order).Error
}
func (r *OrderRepository) UpdateStatus(ctx context.Context, orderID int64, status string) error {
return r.gormDB.WithContext(ctx).
Model(&Order{}).
Where("id = ?", orderID).
Update("status", status).Error
}
// READ SEDERHANA: bisa GORM
func (r *OrderRepository) FindByID(ctx context.Context, id int64) (*Order, error) {
var order Order
err := r.gormDB.WithContext(ctx).First(&order, id).Error
return &order, err
}
// READ KOMPLEKS: gunakan sqlx dengan SQL eksplisit
func (r *OrderRepository) GetUserOrderSummary(ctx context.Context, userID int64) ([]OrderSummary, error) {
var summaries []OrderSummary
err := r.sqlxDB.SelectContext(ctx, &summaries, `
SELECT
o.id,
o.status,
o.total,
o.created_at,
COUNT(oi.id) AS item_count,
SUM(oi.qty) AS total_qty,
p.name AS first_product_name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.user_id = ?
AND o.deleted_at IS NULL
GROUP BY o.id, o.status, o.total, o.created_at, p.name
ORDER BY o.created_at DESC
LIMIT 10
`, userID)
return summaries, err
}
// READ DENGAN PAGINATION DAN FILTER DINAMIS
func (r *OrderRepository) List(ctx context.Context, params ListOrderParams) ([]OrderListItem, error) {
// Bangun query secara aman dengan parameter
query := `
SELECT o.id, o.total, o.status, o.created_at, u.name AS user_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.deleted_at IS NULL
`
args := []interface{}{}
if params.Status != "" {
query += " AND o.status = ?"
args = append(args, params.Status)
}
if params.UserID != 0 {
query += " AND o.user_id = ?"
args = append(args, params.UserID)
}
if !params.StartDate.IsZero() {
query += " AND o.created_at >= ?"
args = append(args, params.StartDate)
}
query += " ORDER BY o.created_at DESC LIMIT ? OFFSET ?"
args = append(args, params.Limit, params.Offset)
var items []OrderListItem
err := r.sqlxDB.SelectContext(ctx, &items, query, args...)
return items, err
}
DTO dan Projection: Ambil Hanya yang Dibutuhkan #
// Definisikan struct terpisah untuk setiap kebutuhan read
// Bukan satu struct User/Order yang dipakai untuk semua
// Untuk list endpoint — hanya kolom yang ditampilkan di tabel
type OrderListItem struct {
ID int64 `db:"id"`
Total float64 `db:"total"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UserName string `db:"user_name"`
}
// Untuk detail endpoint — lebih banyak kolom
type OrderDetail struct {
ID int64 `db:"id"`
Total float64 `db:"total"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
UserName string `db:"user_name"`
UserEmail string `db:"user_email"`
ShippingAddr string `db:"shipping_addr"`
}
// Untuk analytics/report — kolom agregasi
type OrderAnalytics struct {
Date string `db:"order_date"`
TotalRevenue float64 `db:"total_revenue"`
OrderCount int `db:"order_count"`
AvgOrderSize float64 `db:"avg_order_size"`
}
// Setiap struct hanya punya field yang benar-benar dibutuhkan
// Query SQL bisa ditulis dengan SELECT yang persis sesuai struct
// Tidak ada overfetching, tidak ada mapping yang membuang data
Mengaktifkan SQL Logging sebagai Kebiasaan #
Salah satu perubahan budaya yang paling penting adalah membiasakan melihat SQL yang sebenarnya dieksekusi, terutama selama development.
// GORM: aktifkan SQL logging di development
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
// LogMode(logger.Info) → log semua query
// LogMode(logger.Warn) → hanya log query lambat
// LogMode(logger.Silent) → tidak ada log (production default)
})
// sqlx dengan custom logger wrapper
type LoggingDB struct {
*sqlx.DB
log *zap.Logger
}
func (ldb *LoggingDB) SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
start := time.Now()
err := ldb.DB.SelectContext(ctx, dest, query, args...)
ldb.log.Debug("sql",
zap.String("query", query),
zap.Any("args", args),
zap.Duration("elapsed", time.Since(start)),
zap.Error(err),
)
return err
}
// Workflow yang disarankan:
// 1. Tulis kode ORM / sqlx
// 2. Jalankan dengan logging aktif
// 3. Baca SQL yang dihasilkan
// 4. Jalankan EXPLAIN untuk query penting
// 5. Baru commit ke repo
Panduan: Kapan ORM, Kapan Raw SQL #
Decision matrix: ORM vs Raw SQL
──────────────────────────────────────────────────────────────────────
Operasi │ ORM │ Raw SQL/sqlx
──────────────────────────────────────────────────────────────────────
INSERT baris tunggal │ ✓ ORM │ Bisa keduanya
UPDATE satu atau beberapa kolom │ ✓ ORM │ Bisa keduanya
DELETE / Soft delete │ ✓ ORM │ Bisa keduanya
SELECT by primary key │ ✓ ORM │ Bisa keduanya
SELECT dengan WHERE sederhana │ ~ ORM* │ ✓ sqlx (lebih kontrol)
SELECT dengan JOIN │ ✗ ORM │ ✓ sqlx
SELECT dengan kolom spesifik │ ✗ ORM │ ✓ sqlx
Aggregation (SUM, COUNT, AVG) │ ✗ ORM │ ✓ sqlx
Window function │ ✗ ORM │ ✓ sqlx
Subquery kompleks │ ✗ ORM │ ✓ sqlx
CTE (Common Table Expression) │ ✗ ORM │ ✓ sqlx
Bulk INSERT (multi-row) │ ✗ ORM │ ✓ sqlx
Upsert (ON DUPLICATE KEY) │ ~ ORM* │ ✓ sqlx
Pagination dengan filter dinamis │ ✗ ORM │ ✓ sqlx
Analytics / reporting query │ ✗ ORM │ ✓ sqlx
──────────────────────────────────────────────────────────────────────
* ORM bisa dipakai tapi perlu verifikasi SQL yang dihasilkan
Prinsip umum:
Jika query bisa ditulis dengan mudah di ORM DAN kamu sudah
memverifikasi SQL-nya optimal: gunakan ORM.
Jika query membutuhkan kontrol kolom, JOIN, aggregation,
atau SQL yang ORM hasilkan tidak optimal: gunakan sqlx.
──────────────────────────────────────────────────────────────────────
Anti-Pattern yang Harus Dihindari #
// ✗ Anti-pattern 1: Find() tanpa Select() di tabel dengan kolom besar
db.Where("category_id = ?", id).Find(&products)
// → SELECT * termasuk kolom description, metadata, dll.
// ✓ Solusi: db.Select("id, name, price").Where(...).Find(&products)
// atau gunakan sqlx dengan SELECT eksplisit
// ✗ Anti-pattern 2: akses relasi tanpa Preload (lazy loading N+1)
for _, u := range users {
fmt.Println(u.Orders) // trigger query per user
}
// ✓ Solusi: db.Preload("Orders").Find(&users)
// atau lebih baik: sqlx dengan JOIN atau batch IN
// ✗ Anti-pattern 3: ORM untuk query analytics yang butuh aggregasi
db.Raw("SELECT DATE(created_at) as date, SUM(total) as revenue, COUNT(*) as count FROM orders GROUP BY DATE(created_at)").Scan(&result)
// Pakai Raw() → manfaat ORM hilang, tapi masih ada overhead abstraksi
// ✓ Solusi: langsung gunakan sqlx
// ✗ Anti-pattern 4: tidak pernah mengaktifkan SQL logging
// Developer tidak tahu query apa yang dieksekusi
// ✓ Solusi: aktifkan logger.Info di GORM atau buat wrapper logging di sqlx
// Jadikan ini mandatory di development environment
// ✗ Anti-pattern 5: mapping langsung ke entity besar untuk response API
var orders []Order
db.Find(&orders) // menarik semua kolom dari semua relasi
json.NewEncoder(w).Encode(orders)
// ✓ Solusi: definisikan DTO terpisah untuk setiap use case,
// SELECT hanya kolom yang ada di DTO tersebut
Checklist Penggunaan ORM yang Sehat #
SAAT MENULIS QUERY ORM:
□ Sudah lihat SQL yang dihasilkan? (aktifkan logging)
□ Tidak ada SELECT * untuk tabel dengan banyak kolom besar?
□ Tidak ada akses relasi tanpa Preload?
□ Query sudah diverifikasi dengan EXPLAIN di development?
SAAT CODE REVIEW:
□ Query ORM punya Select() eksplisit jika table punya kolom besar?
□ Preload digunakan dengan benar (bukan lazy loading di loop)?
□ Query kompleks (JOIN, aggregation, window function) menggunakan raw SQL/sqlx?
□ Ada DTO terpisah untuk setiap use case read?
ARSITEKTUR:
□ Repository layer memisahkan ORM (write) dari sqlx (read kompleks)?
□ SQL logging aktif di development?
□ Ada query count monitoring per request di production?
□ Developer tim memahami SQL yang dihasilkan ORM (bukan hanya ORM syntax)?
Ringkasan #
- ORM menyembunyikan SQL — ini masalah, bukan fitur — developer yang tidak tahu SQL apa yang dieksekusi tidak bisa memastikan apakah index terpakai, apakah ada N+1, atau apakah ada overfetching. Selalu aktifkan SQL logging dan baca outputnya.
- SELECT * adalah default ORM yang berbahaya — tabel dengan kolom TEXT, JSON, atau BLOB yang di-
Find()tanpaSelect()akan menarik semua data tersebut ke memory, meskipun API hanya butuh dua atau tiga kolom. Selalu specify kolom yang dibutuhkan.- ORM untuk write, sqlx untuk read kompleks — pola hybrid ini memberikan produktivitas ORM untuk INSERT/UPDATE/DELETE sederhana, sekaligus kontrol penuh SQL untuk query yang butuh JOIN, aggregation, window function, atau kolom spesifik.
- DTO/projection per use case menghilangkan overfetching — definisikan struct yang berbeda untuk list endpoint, detail endpoint, dan analytics. Setiap struct hanya punya field yang benar-benar dibutuhkan, dan query SELECT mengikuti struct tersebut.
- Preload ORM harus diverifikasi — Preload yang benar menggunakan batch query (2-3 query total), tapi Preload tanpa scoping bisa menarik ribuan baris yang tidak diperlukan. Selalu verifikasi SQL yang dihasilkan.
- Lazy loading adalah anti-pattern — akses relasi ORM tanpa Preload eksplisit akan memicu satu query per baris (N+1) yang tersembunyi di balik kode yang terlihat bersih.
- ORM untuk query kompleks berarti Raw() — kalahkan tujuan ORM — jika kamu butuh Raw() untuk aggregation, window function, atau CTE, lebih baik langsung gunakan sqlx yang lebih eksplisit dan lebih mudah di-maintain.
- Budaya melihat SQL adalah kunci — tanpa kebiasaan melihat dan memahami SQL yang dihasilkan, masalah performa ORM akan selalu muncul di production, bukan di development.