Master GORM Plugins: Build an Asynchronous Audit Log Extension in Go

Learn how to extend GORM with custom plugins by implementing the Plugin interface, registering lifecycle callbacks, and creating an asynchronous audit‑log module that records Create, Update, Delete, and Query actions to a dedicated table, with configurable batch size, flush interval, and buffer settings.

Code Wrench
Code Wrench
Code Wrench
Master GORM Plugins: Build an Asynchronous Audit Log Extension in Go

What Is a GORM Plugin

GORM plugins are wrappers around GORM lifecycle callbacks (Create, Update, Delete, Query). By implementing a plugin you can inject custom logic before or after these operations, modularize extensions for reuse across projects, and keep a development experience consistent with community plugins such as the Prometheus monitor.

Plugin Interface

type Plugin interface {
    Name() string
    Initialize(*gorm.DB) error
}

Name() returns a unique identifier for the plugin. Initialize(db) registers callbacks, mounts configuration, or performs any setup required by the plugin.

Basic Steps to Write a Plugin

Define the plugin struct : hold configuration or dependencies.

Implement the Plugin interface : write Name() and Initialize() methods.

Register callbacks : use db.Callback().Create().Before("gorm:create").Register(...) (or After) to insert your logic.

Use the plugin : load it into a GORM instance with db.Use(plugin).

Case Study: Asynchronous Audit Log Plugin

This plugin records every Create, Update, Delete, and Query operation to an operation_logs table. Logs are written asynchronously in batches to minimise impact on the main transaction flow.

1. Plugin Definition

package audit

func (ap *AuditPlugin) Name() string { return "audit_plugin" }

func (ap *AuditPlugin) Initialize(db *gorm.DB) error {
    // Auto‑migrate the log table
    db.AutoMigrate(&OperationLog{})
    // Initialise asynchronous channel
    ap.logChan = make(chan OperationLog, ap.config.BufferSize)
    ticker := time.NewTicker(ap.config.FlushInterval)
    go func() {
        buffer := make([]OperationLog, 0, ap.config.BatchSize)
        for {
            select {
            case log := <-ap.logChan:
                buffer = append(buffer, log)
                if len(buffer) >= ap.config.BatchSize {
                    db.Session(&gorm.Session{NewDB: true}).Create(&buffer)
                    buffer = buffer[:0]
                }
            case <-ticker.C:
                if len(buffer) > 0 {
                    db.Session(&gorm.Session{NewDB: true}).Create(&buffer)
                    buffer = buffer[:0]
                }
            }
        }
    }()
    // Register callbacks for each operation type
    db.Callback().Create().After("gorm:create").Register("audit:create", ap.afterCreate)
    db.Callback().Update().After("gorm:update").Register("audit:update", ap.afterUpdate)
    db.Callback().Delete().After("gorm:delete").Register("audit:delete", ap.afterDelete)
    db.Callback().Query().After("gorm:query").Register("audit:query", ap.afterQuery)
    return nil
}

func (ap *AuditPlugin) afterCreate(db *gorm.DB) { ap.enqueueLog(db, "create") }
func (ap *AuditPlugin) afterUpdate(db *gorm.DB) { ap.enqueueLog(db, "update") }
func (ap *AuditPlugin) afterDelete(db *gorm.DB) { ap.enqueueLog(db, "delete") }
func (ap *AuditPlugin) afterQuery(db *gorm.DB)  { ap.enqueueLog(db, "query") }

func (ap *AuditPlugin) enqueueLog(db *gorm.DB, action string) {
    if db.Statement.Table == "operation_logs" { return }
    log := OperationLog{
        TableName: db.Statement.Table,
        Action:    action,
        RecordID:  getPrimaryKey(db),
        SQL:       db.Dialector.Explain(db.Statement.SQL.String(), db.Statement.Vars...),
        CreatedAt: time.Now(),
    }
    select {
    case ap.logChan <- log:
        // enqueued successfully
    default:
        fmt.Println("[AUDIT] log channel is full, dropping log")
    }
}

func getPrimaryKey(db *gorm.DB) string {
    if len(db.Statement.Schema.PrimaryFields) > 0 {
        if pk, ok := db.Statement.Schema.PrimaryFields[0].ValueOf(db.Statement.Context, db.Statement.ReflectValue); ok {
            return fmt.Sprintf("%v", pk)
        }
    }
    return ""
}

2. Using the Plugin

package main

import (
    "fmt"
    "time"
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "your_project/audit"
)

type User struct {
    ID   uint
    Name string
}

func main() {
    db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    // Register plugin with custom configuration
    plugin := audit.NewAuditPlugin(audit.Config{BatchSize: 50, FlushInterval: 3 * time.Second, BufferSize: 500})
    db.Use(plugin)
    db.AutoMigrate(&User{})
    // Test operations
    user := User{Name: "Tom"}
    db.Create(&user)
    db.Model(&user).Update("Name", "Jerry")
    db.First(&user, user.ID)
    db.Delete(&user)
    // Wait for asynchronous batch write
    time.Sleep(4 * time.Second)
    var logs []audit.OperationLog
    db.Find(&logs)
    fmt.Println("Operation logs:", logs)
}

After running, the operation_logs table contains entries for all CRUD actions. Logs are written in asynchronous batches; batch size, flush interval, and buffer capacity are configurable.

Best Practices & Performance Tuning

BatchSize : Recommended 50‑500 depending on DB write throughput. Larger batches improve write efficiency but increase log latency.

FlushInterval : Recommended 1‑5 seconds to ensure timely persistence during traffic spikes. Too short adds DB pressure; too long delays logs.

BufferSize : Set to 10‑20× the BatchSize for high‑concurrency scenarios. Too small may drop logs; too large consumes memory.

Database Optimisation : Add indexes on operation_logs (e.g., TableName, CreatedAt). For heavy write loads consider dedicated log stores like ClickHouse or Elasticsearch.

Alternative Async Consumers : If log reliability is critical, replace direct DB writes with a message queue (Kafka, RabbitMQ) feeding a specialised log system.

Further Extensions

The plugin mechanism can be reused for many scenarios, such as:

Automatic field population (e.g., CreatedAt, UpdatedAt).

Multi‑tenant support by appending a TenantID condition.

Enhanced soft‑delete tracking (who deleted, reason).

Monitoring hooks (Prometheus, OpenTelemetry) for observability.

By leveraging GORM's plugin architecture together with asynchronous batch processing and configurable parameters, developers can achieve high code reuse, maintainability, and production‑grade performance for audit‑logging and other cross‑cutting concerns.

GORM Plugin Diagram
GORM Plugin Diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

pluginGoORMGORMaudit
Code Wrench
Written by

Code Wrench

Focuses on code debugging, performance optimization, and real-world engineering, sharing efficient development tips and pitfall guides. We break down technical challenges in a down-to-earth style, helping you craft handy tools so every line of code becomes a problem‑solving weapon. 🔧💻

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.