Why We Chose GORM Over Raw SQL for Data Management in a FinTech Backend

Ingeniero de Software Dublín

Ingeniero de Software Dublín

Ingeniero Construido Para Escalar

Más Allá Del Código

Lo Que Dicen

Construyamos Juntos

Mensaje Recibido

Política de Privacidad

Términos de Uso

Política de Cookies

Aviso Legal

Últimas Noticias

Trabajo Seleccionado

Showcase

Lo Que Ofrecemos

Industrias Que Servimos

Más Allá De La Pantalla

Construido Juntos

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • Leer artículo Leer artículo

    Packages 13 mins

    Why We Chose GORM Over Raw SQL for Data Management in a FinTech Backend

    Nben M. 07 Apr, 2026 13 mins

    Why We Chose GORM Over Raw SQL for Data Management in a FinTech Backend

    The conventional wisdom in Go backend engineering is that raw SQL is the correct choice for anything serious, and ORMs are a convenience for developers who have not yet paid the cost of their abstractions. That view has merit. ORMs hide what they are doing, generate queries you did not write, and produce surprising behavior when the abstraction does not map cleanly to what the database actually does.

    We held that view when we started building the FinTech backend. We began with pgx and raw SQL for the first few months. The data layer was explicit and predictable. It was also the source of a disproportionate amount of development friction as the schema grew: boilerplate scanning, manual transaction management repeated across packages, no shared pattern for soft deletes or audit timestamps, and migration management that required coordination every time a new engineer joined.

    The decision to move to GORM was not made because raw SQL is inferior. It was made because the specific costs raw SQL imposed on this team, at this stage, were higher than the costs GORM would impose. This article describes that reasoning, the patterns we adopted to make GORM work correctly in a financial context, and the places where we kept raw SQL because GORM genuinely did not belong.

    What Made Raw SQL Expensive at Scale

    Raw SQL's explicitness is its strength and its cost. Every query is written by hand, every result is scanned by hand, and every schema change propagates through every query that touches the affected table by hand. For a small schema with a stable structure, that cost is low. For a schema that grew from twelve tables to over forty in eight months, across a team of six engineers, it became significant.

    The specific friction points were predictable in retrospect. Scanning results into structs required repetitive field lists that diverged from the struct definitions when columns were added or renamed. Soft delete logic, audit timestamps and tenant scoping were implemented inconsistently across packages because each engineer made independent decisions about how to apply them. Transaction management was verbose and slightly different in every service that used it.

    None of these problems are inherent to raw SQL. They are problems of consistency and convention, which any sufficiently large raw SQL codebase eventually faces. The question was whether GORM's conventions were worth adopting, or whether we should build our own. We evaluated both options and concluded that building our own consistency layer would produce a subset of what GORM already provided, with worse documentation and higher maintenance burden.

    How We Configured GORM for a Financial Context

    GORM's defaults are not suitable for production financial systems without modification. The default configuration logs nothing, has no connection pool limits, and applies conventions that can produce unexpected behavior when the schema does not conform to GORM's expectations.

    go
    // internal/store/db.go
    package store
    
    import (
        "log/slog"
        "time"
    
        "gorm.io/driver/postgres"
        "gorm.io/gorm"
        gormlogger "gorm.io/gorm/logger"
        "gorm.io/gorm/schema"
    )
    
    func Connect(dsn string, logger *slog.Logger) (*gorm.DB, error) {
        gormLogger := gormlogger.New(
            newSlogWriter(logger),
            gormlogger.Config{
                SlowThreshold:             200 * time.Millisecond,
                LogLevel:                  gormlogger.Warn,
                IgnoreRecordNotFoundError: true,
                ParameterizedQueries:      true,
            },
        )
    
        db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
            Logger:                                   gormLogger,
            NamingStrategy:                           schema.NamingStrategy{SingularTable: true},
            DisableForeignKeyConstraintWhenMigrating: true,
            PrepareStmt:                              true,
            SkipDefaultTransaction:                   true,
        })
        if err != nil {
            return nil, err
        }
    
        sqlDB, err := db.DB()
        if err != nil {
            return nil, err
        }
    
        sqlDB.SetMaxOpenConns(25)
        sqlDB.SetMaxIdleConns(5)
        sqlDB.SetConnMaxLifetime(5 * time.Minute)
        sqlDB.SetConnMaxIdleTime(1 * time.Minute)
    
        return db, nil
    }

    Several configuration decisions require explanation.

    SkipDefaultTransaction disables GORM's behavior of wrapping every create, update and delete in an implicit transaction. In a financial system, transaction boundaries are explicit and meaningful. An implicit transaction on a single insert is unnecessary overhead and conceals the actual transaction structure from anyone reading the code.

    ParameterizedQueries in the logger prevents query values from appearing in log output. In a financial system, query parameters frequently contain account IDs, amounts and customer identifiers. Logging them creates a compliance exposure. Parameterised logging shows the query structure without the values.

    IgnoreRecordNotFoundError prevents GORM from logging a warning every time a record is not found. A not-found result is expected behavior, not a warning condition. Logging it at warning level fills your log aggregator with noise that buries genuine warnings.

    PrepareStmt caches prepared statements, reducing query planning overhead on frequently executed queries. For a financial service executing thousands of similar queries per second, this is a measurable throughput improvement.

    DisableForeignKeyConstraintWhenMigrating prevents GORM from creating database-level foreign key constraints during auto-migration. We manage constraints explicitly through migrations. Letting GORM create them implicitly produces constraints with generated names that are difficult to track and modify.

    The Base Model

    Every model in the system embeds a base model that enforces the fields every financial record must carry. Audit timestamps and soft deletes are not optional in a regulated environment.

    go
    // internal/domain/base.go
    package domain
    
    import (
        "time"
    
        "github.com/google/uuid"
        "gorm.io/gorm"
    )
    
    type BaseModel struct {
        ID        uuid.UUID      `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
        CreatedAt time.Time      `gorm:"not null;index"                                  json:"created_at"`
        UpdatedAt time.Time      `gorm:"not null"                                        json:"updated_at"`
        DeletedAt gorm.DeletedAt `gorm:"index"                                            json:"-"`
    }

    gen_random_uuid() generates UUIDs at the database layer, not the application layer. UUID generation at the application layer requires the application to be involved in every insert. Database-level generation means the insert works correctly even if the record is created through a migration script, a database tool or a direct SQL statement during incident recovery.

    DeletedAt gorm.DeletedAt activates GORM's soft delete behaviour. A record with a non-null deleted_at is excluded from all standard queries automatically. In a financial system, no financial record is ever hard-deleted. Soft delete is the only legitimate delete operation.

    go
    // internal/domain/account.go
    package domain
    
    import (
        "github.com/google/uuid"
    )
    
    type Account struct {
        BaseModel
        OwnerID      uuid.UUID `gorm:"type:uuid;not null;index"    json:"owner_id"`
        Currency     string    `gorm:"type:char(3);not null"        json:"currency"`
        BalanceCents int64     `gorm:"not null;default:0"           json:"balance_cents"`
        Status       string    `gorm:"type:varchar(50);not null"    json:"status"`
        AccountType  string    `gorm:"type:varchar(50);not null"    json:"account_type"`
    }
    
    func (Account) TableName() string {
        return "account"
    }

    TableName() overrides GORM's naming convention explicitly. GORM's default table naming is predictable but introduces a runtime dependency on that convention holding. Explicit table names mean a model rename does not break the mapping to the existing table, and the database schema does not change because a developer renamed a Go type.

    BalanceCents int64 stores monetary values as integer cents. Never float64. Never decimal.Decimal at the model layer. The database column is an integer, the Go type is an integer, and conversion to a decimal for arithmetic happens explicitly in the service layer where the arithmetic actually occurs.

    Scoped Repositories

    We did not use GORM directly in handlers or service methods. Every data access operation goes through a repository that owns a specific model and exposes typed methods. GORM is an implementation detail of the repository, invisible to the service layer.

    go
    // internal/store/account_store.go
    package store
    
    import (
        "context"
        "errors"
    
        "github.com/google/uuid"
        "gorm.io/gorm"
    
        "payments/internal/apierr"
        "payments/internal/domain"
    )
    
    type AccountStore interface {
        GetByID(ctx context.Context, id uuid.UUID) (*domain.Account, error)
        GetByOwnerAndCurrency(ctx context.Context, ownerID uuid.UUID, currency string) (*domain.Account, error)
        Create(ctx context.Context, account *domain.Account) error
        UpdateBalance(ctx context.Context, id uuid.UUID, deltaCents int64) error
        ListByOwner(ctx context.Context, ownerID uuid.UUID) ([]domain.Account, error)
    }
    
    type pgAccountStore struct {
        db *gorm.DB
    }
    
    func NewAccountStore(db *gorm.DB) AccountStore {
        return &pgAccountStore{db: db}
    }
    
    func (s *pgAccountStore) GetByID(ctx context.Context, id uuid.UUID) (*domain.Account, error) {
        var account domain.Account
        result := s.db.WithContext(ctx).First(&account, "id = ?", id)
        if result.Error != nil {
            if errors.Is(result.Error, gorm.ErrRecordNotFound) {
                return nil, apierr.NotFound("account not found", nil)
            }
            return nil, apierr.Internal(result.Error)
        }
        return &account, nil
    }
    
    func (s *pgAccountStore) Create(ctx context.Context, account *domain.Account) error {
        result := s.db.WithContext(ctx).Create(account)
        if result.Error != nil {
            if isUniqueViolation(result.Error) {
                return apierr.Conflict("account already exists for this owner and currency")
            }
            return apierr.Internal(result.Error)
        }
        return nil
    }
    
    func (s *pgAccountStore) UpdateBalance(ctx context.Context, id uuid.UUID, deltaCents int64) error {
        result := s.db.WithContext(ctx).
            Model(&domain.Account{}).
            Where("id = ?", id).
            UpdateColumn("balance_cents", gorm.Expr("balance_cents + ?", deltaCents))
        if result.Error != nil {
            return apierr.Internal(result.Error)
        }
        if result.RowsAffected == 0 {
            return apierr.NotFound("account not found", nil)
        }
        return nil
    }

    UpdateBalance uses UpdateColumn with gorm.Expr to perform an atomic increment at the database layer. UPDATE account SET balance_cents = balance_cents + $1 WHERE id = $2 is a single atomic operation. Reading the balance, adding the delta in Go, and writing the result back is a race condition. The database expression eliminates it.

    RowsAffected == 0 catches the case where the update matched no rows. Without this check, an update against a non-existent ID returns no error and silently does nothing, which in a financial system is indistinguishable from a successful operation.

    Transactions for Multi-Step Financial Operations

    Any operation that modifies more than one record requires an explicit transaction. GORM's transaction API is clean and integrates naturally with Go's context propagation.

    go
    // internal/service/payment_service.go
    func (s *PaymentService) Transfer(ctx context.Context, req TransferRequest) (*domain.Payment, error) {
        var payment *domain.Payment
    
        err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
            // Debit source account with pessimistic lock
            var source domain.Account
            if err := tx.Set("gorm:query_option", "FOR UPDATE").
                First(&source, "id = ?", req.SourceAccountID).Error; err != nil {
                if errors.Is(err, gorm.ErrRecordNotFound) {
                    return apierr.NotFound("source account not found", nil)
                }
                return apierr.Internal(err)
            }
    
            if source.BalanceCents < req.AmountCents {
                return apierr.Conflict("insufficient funds")
            }
    
            if err := tx.Model(&source).
                UpdateColumn("balance_cents", gorm.Expr("balance_cents - ?", req.AmountCents)).
                Error; err != nil {
                return apierr.Internal(err)
            }
    
            // Credit destination account with pessimistic lock
            var destination domain.Account
            if err := tx.Set("gorm:query_option", "FOR UPDATE").
                First(&destination, "id = ?", req.DestinationAccountID).Error; err != nil {
                if errors.Is(err, gorm.ErrRecordNotFound) {
                    return apierr.NotFound("destination account not found", nil)
                }
                return apierr.Internal(err)
            }
    
            if err := tx.Model(&destination).
                UpdateColumn("balance_cents", gorm.Expr("balance_cents + ?", req.AmountCents)).
                Error; err != nil {
                return apierr.Internal(err)
            }
    
            // Record the payment
            payment = &domain.Payment{
                SourceAccountID:      req.SourceAccountID,
                DestinationAccountID: req.DestinationAccountID,
                AmountCents:          req.AmountCents,
                Currency:             req.Currency,
                Status:               domain.PaymentStatusSettled,
            }
            if err := tx.Create(payment).Error; err != nil {
                return apierr.Internal(err)
            }
    
            return nil
        })
    
        if err != nil {
            return nil, err
        }
    
        return payment, nil
    }

    FOR UPDATE acquires row-level locks on both accounts before modifying either. Concurrent transfers involving the same accounts wait for the lock rather than racing against it. The debit and credit are atomic within the transaction. If either fails, the transaction rolls back and neither account is modified.

    The transaction closure returns a typed apierr error. GORM rolls back the transaction when the closure returns any non-nil error, and the classified error survives the transaction boundary and is visible to the caller without additional wrapping.

    Where We Kept Raw SQL

    GORM does not replace raw SQL for every use case. We kept raw SQL for three categories of query.

    Complex reporting queries that join multiple tables, use window functions, or require CTEs are written in raw SQL. GORM's query builder becomes verbose and harder to reason about than the SQL it produces for anything beyond straightforward selects. A query that is clearer as SQL should be written as SQL.

    go
    // internal/store/report_store.go
    func (s *pgReportStore) MonthlyVolumeByCurrency(ctx context.Context, ownerID uuid.UUID) ([]VolumeRow, error) {
        query := `
            SELECT
                currency,
                DATE_TRUNC('month', created_at) AS month,
                COUNT(*)                         AS transaction_count,
                SUM(amount_cents)                AS total_cents
            FROM payment
            WHERE
                source_account_id IN (SELECT id FROM account WHERE owner_id = ?)
                AND deleted_at IS NULL
            GROUP BY currency, DATE_TRUNC('month', created_at)
            ORDER BY month DESC, currency
        `
        var rows []VolumeRow
        result := s.db.WithContext(ctx).Raw(query, ownerID).Scan(&rows)
        return rows, result.Error
    }

    Queries that require Postgres-specific features GORM does not expose through its API, such as advisory locks or partial index hints, are also raw SQL. Any query where the generated SQL must be reviewed and approved as part of a compliance process is raw SQL. A fixed SQL string is a reviewable, auditable artifact. A GORM query builder call generates SQL at runtime, which is harder to sign off with precision.

    Migrations

    We used golang-migrate rather than GORM's AutoMigrate for schema management. AutoMigrate creates tables and adds columns but cannot drop columns, modify column types, or manage indexes and constraints with precision. For a financial schema under regulatory requirements, migrations must be explicit, reversible and auditable.

    markdown
    migrations/
      000001_create_account.up.sql
      000001_create_account.down.sql
      000002_create_payment.up.sql
      000002_create_payment.down.sql
      000003_add_account_status_index.up.sql
      000003_add_account_status_index.down.sql
    sql
    -- migrations/000001_create_account.up.sql
    CREATE TABLE IF NOT EXISTS account (
        id            UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
        owner_id      UUID         NOT NULL,
        currency      CHAR(3)      NOT NULL,
        balance_cents BIGINT       NOT NULL DEFAULT 0,
        status        VARCHAR(50)  NOT NULL,
        account_type  VARCHAR(50)  NOT NULL,
        created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
        updated_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
        deleted_at    TIMESTAMPTZ,
        CONSTRAINT uq_account_owner_currency UNIQUE (owner_id, currency)
    );
    
    CREATE INDEX idx_account_owner_id ON account (owner_id);
    CREATE INDEX idx_account_deleted_at ON account (deleted_at) WHERE deleted_at IS NULL;

    The partial index on deleted_at covers only rows where deleted_at IS NULL. In a table where the majority of rows are not soft-deleted, this produces a smaller, faster index than a full index on the column. GORM cannot create partial indexes through its API. This is a concrete case where explicit SQL is necessary regardless of which ORM you use.

    Conclusion

    GORM in a FinTech backend is a defensible choice when the configuration is precise, the conventions are enforced consistently, and the cases where it does not belong are identified and kept clear. The default GORM configuration is not that. It produces implicit transactions, logs sensitive query values, and applies naming conventions that introduce runtime dependencies on Go type names.

    The decision was not GORM versus raw SQL as a universal preference. It was GORM for the cases it handles correctly, raw SQL for the cases it does not, and a firm set of patterns that prevented GORM from doing things a financial system cannot afford. Repository abstraction kept GORM out of the service layer. Explicit table names decoupled the schema from Go type names. Integer storage for monetary values kept precision exact. Explicit migration management kept the schema auditable.

    Those decisions are what made GORM viable in production. The ORM itself was the starting point, not the answer.