Kenapa saya berhenti menggunakan library ORM pada proyek Golang

ORM (Object Relational Mapping) merupakan sebuah library yang umumnya digunakan pada aplikasi back-end guna mempermudah proses query database serta schema migration. Developer hanya perlu mendefinisikan schema untuk setiap model, menjalankan migration, dan kemudian memanggil kode-kode yang telah disediakan oleh library ORM tersebut untuk menjalankan query pada database. Namun, terdapat abstraction yang berlebihan pada ORM (bahkan terkadang yang tidak diperlukan) yang dapat mengakibatkan penurunan performa saat menjalankan query data.

Terdapat pendekatan lain untuk menjalankan query database tanpa mengorbankan performa, yaitu dengan mengirimkan query SQL (Structured Query Language) langsung ke database. Namun, kelemahan dari metode ini adalah memerlukan penulisan kode yang lebih banyak dan hasil query yang tidak "type-safety".

Salah satu solusi yang saya temukan untuk kedua masalah ini adalah menggunakan "sql-to-code compiler" seperti sqlc. Berbeda dengan ORM pada umumnya, di sqlc kita perlu menuliskan terlebih dahulu query dan schema menggunakan Bahasa SQL. Selanjutnya, kode SQL tersebut akan di-compile menjadi kode Golang yang type-safety dan siap digunakan.

Pada umumnya, ORM menyediakan fitur untuk melakukan migration data (bahkan GORM dapat melakukan migration secara otomatis), namun sqlc tidak memiliki kemampuan ini secara out-of-the-box. Karena migration bukanlah fitur utama sqlc, kita perlu menggunakan tool lain untuk mempermudah proses migration.

Setelah mempertimbangkan berbagai pilihan migration tool, saya akhirnya memilih dbmate. Alasan di balik pilihan ini adalah dbmate menawarkan fitur yang paling lengkap dan command yang lebih sederhana dibandingkan dengan migration tool lainnya.

Berikut adalah perbandingan fitur yang dimiliki schema migration tool lain:

dbmategoosesql-migrategolang-migrateactiverecordsequelizeflywaysqitch
Features
Plain SQL migration files
Support for creating and dropping databases
Support for saving schema dump files
Timestamp-versioned migration files
Custom schema migrations table
Ability to wait for database to become ready
Database connection string loaded from environment variables
Automatically load .env file
No separate configuration file
Language/framework independent
Drivers
PostgreSQL
MySQL
SQLite
CliсkHouse

Sumber tabel: https://github.com/amacneil/dbmate#alternatives

Sebelumnya, saya telah menggunakan goose. Namun, saya harus mengetikkan command yang cukup panjang untuk menggunakan tool ini karena harus selalu menyertakan "database url" sebagai argumen untuk tool tersebut. Berbeda halnya dengan dbmate, yang dapat membaca database url langsung dari file .env.

# bayangkan kalian harus mengetikan command sepanjang ini setiap menggunakan goose
goose postgres "user=postgres password=postgres dbname=postgres sslmode=disable" up

Memang, proses setup sqlc membutuhkan langkah yang lebih rumit dibandingkan dengan penggunaan ORM pada umumnya. Dengan sqlc, kita perlu menulis file konfigurasi sqlc dan kode SQL untuk membuat tabel dan melakukan query CRUD, sehingga membutuhkan pemahaman yang baik tentang SQL.

# sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "./internal/infrastructure/database/postgres/query"
    schema: "./internal/infrastructure/database/postgres/migration"
    gen:
      go:
        sql_package: "pgx/v5"
        out: "./internal/infrastructure/database/postgres/sqlc"
        emit_empty_slices: true
        omit_unused_structs: true
        emit_interface: true

Meskipun begitu, penggunaan sqlc memiliki beberapa keunggulan, di antaranya:

  • Performa yang lebih cepat karena tidak tergantung pada third-party library (namun masih memerlukan database driver, yang merupakan hal yang wajar).

    Hasil fetch menggunakan ORM (4.13 ms):

    Hasil fetch menggunakan ORM (4.13 ms)

    Hasil fetch menggunakan sqlc (1.44 ms) 🚀:

    Hasil fetch menggunakan sqlc (1.44 ms 🚀)

  • Kemampuan untuk memanfaatkan fitur bawaan dari DBMS secara maksimal. Sebagai contoh, di PostgreSQL, kita dapat memanfaatkan fitur enum.

      -- 20230819005847_create_user_table.sql 
      -- berikut adalah kode untuk schema/migration yang diperlukan oleh sqlc dan dbmate
      -- migrate:up
      CREATE TYPE role AS ENUM ('user', 'admin');
      CREATE TABLE "user" (
        id SERIAL PRIMARY KEY NOT NULL,
        full_name VARCHAR(50) NOT NULL,
        username VARCHAR(16) NOT NULL UNIQUE,
        email VARCHAR(255) NOT NULL UNIQUE,
        password TEXT NOT NULL,
        role role DEFAULT 'user',
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      );
    
      -- migrate:down
      DROP TABLE "user";
      DROP TYPE role;
    
  • Tidak ada abstraction yang tersembunyi, karena kode yang dihasilkan sepenuhnya didasarkan pada kode SQL yang telah dituliskan sebelumnya.

      -- user_query.sql
      -- name: CreateUser :one
      INSERT INTO "user" (
        full_name, username, email, password, role
      ) VALUES (
        $1, $2, $3, $4, $5
      ) RETURNING id, full_name, username, email, role, created_at;
    
      -- name: FindAllUsers :many
      SELECT id, full_name, username, email, role, created_at, updated_at 
      FROM "user";
    
      -- name: FindOneUserByID :one
      SELECT * FROM "user" 
      WHERE id = $1 LIMIT 1;
    
      -- name: FindOneUserByEmail :one
      SELECT * FROM "user" 
      WHERE email = $1 LIMIT 1;
    
      -- name: FindAdmin :many
      SELECT * FROM "user"
      WHERE role = 'admin'::role;
    
      -- name: UpdateUser :one
      UPDATE "user"
      SET 
        full_name = $2,
        username = $3,
        updated_at = $4
      WHERE id = $1
      RETURNING id, full_name, username, email, role, created_at, updated_at;
    
      -- name: UpdateEmail :one
      UPDATE "user"
      SET 
        email = $2,
        updated_at = $3
      WHERE id = $1
      RETURNING id, full_name, username, email, role, created_at, updated_at;
    
      -- name: UpdatePassword :exec
      UPDATE "user"
      SET 
        password = $2,
        updated_at = $3
      WHERE id = $1;
    
      -- name: DeleteUser :exec
      DELETE FROM "user"
      WHERE id = $1;
    

    Kode Golang hasil compile sqlc:

      // models.go
      // Code generated by sqlc. DO NOT EDIT.
      // versions:
      //   sqlc v1.19.1
    
      package sqlc
    
      import (
          "database/sql/driver"
          "fmt"
    
          "github.com/jackc/pgx/v5/pgtype"
      )
    
      type Role string
    
      const (
          RoleUser  Role = "user"
          RoleAdmin Role = "admin"
      )
    
      func (e *Role) Scan(src interface{}) error {
          switch s := src.(type) {
          case []byte:
              *e = Role(s)
          case string:
              *e = Role(s)
          default:
              return fmt.Errorf("unsupported scan type for Role: %T", src)
          }
          return nil
      }
    
      type NullRole struct {
          Role  Role
          Valid bool // Valid is true if Role is not NULL
      }
    
      // Scan implements the Scanner interface.
      func (ns *NullRole) Scan(value interface{}) error {
          if value == nil {
              ns.Role, ns.Valid = "", false
              return nil
          }
          ns.Valid = true
          return ns.Role.Scan(value)
      }
    
      // Value implements the driver Valuer interface.
      func (ns NullRole) Value() (driver.Value, error) {
          if !ns.Valid {
              return nil, nil
          }
          return string(ns.Role), nil
      }
    
      type ChangeEmailToken struct {
          Token     string
          NewEmail  string
          ExpiresAt pgtype.Timestamp
          UserID    pgtype.Int4
      }
    
      type RefreshToken struct {
          Token  string
          UserID pgtype.Int4
      }
    
      type ResetPasswordToken struct {
          Token     string
          ExpiresAt pgtype.Timestamp
          UserID    pgtype.Int4
      }
    
      type User struct {
          ID        int32
          FullName  string
          Username  string
          Email     string
          Password  string
          Role      NullRole
          CreatedAt pgtype.Timestamp
          UpdatedAt pgtype.Timestamp
      }
    
      ...
    
      const createUser = `-- name: CreateUser :one
      INSERT INTO "user" (
        full_name, username, email, password, role
      ) VALUES (
        $1, $2, $3, $4, $5
      ) RETURNING id, full_name, username, email, role, created_at
      `
    
      type CreateUserParams struct {
          FullName string
          Username string
          Email    string
          Password string
          Role     NullRole
      }
    
      type CreateUserRow struct {
          ID        int32
          FullName  string
          Username  string
          Email     string
          Role      NullRole
          CreatedAt pgtype.Timestamp
      }
    
      func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
          row := q.db.QueryRow(ctx, createUser,
              arg.FullName,
              arg.Username,
              arg.Email,
              arg.Password,
              arg.Role,
          )
          var i CreateUserRow
          err := row.Scan(
              &i.ID,
              &i.FullName,
              &i.Username,
              &i.Email,
              &i.Role,
              &i.CreatedAt,
          )
          return i, err
      }
    
      ...
    

Pada post berikutnya, saya akan menjelaskan langkah-langkah menggunakan sqlc dan dbmate dalam proyek golang sederhana. Jika kalian ingin melihat bagaimana saya menggunakan sqlc dan dbmate pada proyek saya yang sudah meng-implementasikan clean architecture, kalian bisa cek repository saya https://codeberg.org/tfkhdyt/blog-api.

Jangan lupa untuk subscribe ke blog ini agar kalian tidak melewatkan setiap post baru yang saya buat.

Akhir kata saya ucapkan terima kasih telah membaca post ini. Semoga post ini dapat membangkitkan minat kalian untuk mencoba sqlc dan dbmate dalam proyek back-end kalian.