DEV Community

Akkarapon Phikulsri
Akkarapon Phikulsri

Posted on

Comprehensive Research: Audit Log Paradigms & Go/PostgreSQL/GORM Design Patterns

Table of Contents

  1. Audit Log Paradigms
  2. Event Sourcing vs Audit Logging
  3. Real-World Use Cases
  4. Database Design Patterns for Audit Trails
  5. State Transition Tracking
  6. API Audit Logging Best Practices
  7. Go/PostgreSQL/GORM Implementation Patterns
  8. Complete Code Examples

1. Audit Log Paradigms

Core Concepts

Audit logging is the practice of recording changes to data in a system to provide:

  • Traceability: Who did what, when, and why
  • Compliance: Meeting regulatory requirements (GDPR, HIPAA, SOC 2, PCI DSS)
  • Security: Detecting suspicious activities and unauthorized access
  • Debugging: Understanding system behavior and data evolution
  • Recovery: Ability to rollback or replay changes

Key Principles

1. Append-Only Structure

  • Audit logs should NEVER be modified or deleted
  • All entries are immutable after creation
  • Use separate tables/storage from operational data

2. Essential Audit Fields

Every audit log entry should capture:

  • Timestamp: When the action occurred (with timezone)
  • Actor: Who performed the action (user ID, service account)
  • Action: What was done (CREATE, UPDATE, DELETE, READ)
  • Resource: What entity was affected (table name, record ID)
  • Before/After State: Previous and new values
  • Context: Additional metadata (IP address, session ID, request ID)

3. Performance Considerations

  • Audit logging should not significantly impact main operations
  • Use async/background processing for logging when possible
  • Implement log rotation and archival strategies
  • Index strategically for query performance

2. Event Sourcing vs Audit Logging

Comparison Matrix

Feature Event Sourcing Audit Log
Purpose Maintain complete history of state changes as source of truth Track user actions for security/compliance
Granularity Very granular, every state change High-level, significant actions
Structure Append-only log of immutable events Append-only log of action records
Events Recorded Facts that occurred (irrefutable) Account of actions performed
Reconstruction Can reconstruct system state by replaying events Not designed for reconstructing state
Querying Often requires event replay Direct querying for audit purposes
System Recovery Facilitates rollback/recovery by replaying Primarily for investigation/compliance
Complexity Higher complexity, careful design needed Generally simpler, auxiliary feature
Use Case Core business logic and state management Compliance, security, debugging

When to Use What?

Use Event Sourcing when:

  • Business logic depends on complete history
  • Need to replay events to rebuild state
  • Domain complexity requires detailed tracking
  • Audit trail is a critical business requirement

Use Audit Logging when:

  • Need compliance/security tracking only
  • Don't need state reconstruction
  • Want simpler implementation
  • Audit is secondary concern

Hybrid Approach:
Many systems use event sourcing for critical domains and traditional audit logging for other areas.


3. Real-World Use Cases

1. Financial Services

Scenario: Banking transaction processing

Use Case: Track every transaction, balance change, and access attempt
Requirements:
- Immutable records of all transactions
- 7-10 year retention for compliance
- Millisecond-precision timestamps
- Ability to prove no tampering

Implementation:
- Separate audit database with write-only access
- Cryptographic signatures on audit entries
- Regular backup to immutable storage (S3 Glacier)
Enter fullscreen mode Exit fullscreen mode

2. Healthcare (HIPAA Compliance)

Scenario: Patient record access tracking

Use Case: Track who viewed/modified patient records
Requirements:
- Log every access to protected health information (PHI)
- Capture: user, timestamp, patient ID, action, reason
- Alert on suspicious access patterns
- Retain for 6+ years

Implementation:
- Real-time audit logging on every PHI access
- Automated alerting for unusual patterns
- Encrypted audit logs
- Regular compliance reports
Enter fullscreen mode Exit fullscreen mode

3. E-Commerce Order Management

Scenario: Order lifecycle tracking

Use Case: Track order state transitions from creation to delivery
Requirements:
- Capture state changes: created → paid → shipped → delivered
- Record who authorized changes
- Support dispute resolution
- Enable order reconstruction

Implementation:
- State machine with audit trail
- Transition validation rules
- Rollback capability for specific states
Enter fullscreen mode Exit fullscreen mode

4. API Access Control

Scenario: RESTful API request tracking

Use Case: Monitor API usage, detect abuse, support debugging
Requirements:
- Log API requests: endpoint, method, user, parameters
- Track rate limiting violations
- Capture response status and errors
- Enable analytics and billing

Implementation:
- Middleware-based request/response logging
- Async logging to not block requests
- Log aggregation (ELK stack, Splunk)
- Real-time alerting on anomalies
Enter fullscreen mode Exit fullscreen mode

5. Multi-Tenant SaaS

Scenario: User action tracking across tenants

Use Case: Provide audit trails to enterprise customers
Requirements:
- Per-tenant audit logs
- Customer-accessible audit UI
- GDPR right-to-erasure support
- Role-based audit log access

Implementation:
- Tenant-isolated audit tables or schemas
- Audit API endpoints for customers
- Export functionality (CSV, JSON)
- Data retention policies per tenant
Enter fullscreen mode Exit fullscreen mode

6. Incident Management System

Scenario: Tracking incident status and updates

Use Case: Track incident lifecycle and all updates
Requirements:
- Every status change logged
- Update history with timestamps
- Severity changes tracked
- Automated timeline generation

Implementation:
- Event-sourced incident aggregate
- Rich timeline reconstruction
- Audit log as secondary index
- Real-time status board
Enter fullscreen mode Exit fullscreen mode

4. Database Design Patterns for Audit Trails

Pattern 1: Row Versioning (In-Place Audit)

Concept: Add versioning fields directly to the main table

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    order_number VARCHAR(50) NOT NULL,
    customer_id BIGINT NOT NULL,
    total_amount DECIMAL(10,2),
    status VARCHAR(20),

    -- Audit fields
    version INT NOT NULL DEFAULT 1,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by VARCHAR(100) NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by VARCHAR(100) NOT NULL,
    deleted_at TIMESTAMPTZ,
    deleted_by VARCHAR(100)
);
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Simple to implement
  • No extra tables
  • Easy to query current state

Cons:

  • Loses history of changes
  • No details on what changed
  • Can't rollback to previous versions

Pattern 2: Shadow/History Tables

Concept: Separate table mirrors main table with full history

-- Main table (current state)
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    order_number VARCHAR(50) NOT NULL,
    customer_id BIGINT NOT NULL,
    total_amount DECIMAL(10,2),
    status VARCHAR(20),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- History table (all versions)
CREATE TABLE orders_history (
    history_id BIGSERIAL PRIMARY KEY,
    id BIGINT NOT NULL,
    order_number VARCHAR(50),
    customer_id BIGINT,
    total_amount DECIMAL(10,2),
    status VARCHAR(20),

    -- Audit metadata
    operation VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
    changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    changed_by VARCHAR(100) NOT NULL,

    -- Track what changed
    change_reason TEXT
);

-- Trigger to populate history
CREATE OR REPLACE FUNCTION orders_audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
    IF (TG_OP = 'DELETE') THEN
        INSERT INTO orders_history (id, order_number, customer_id, total_amount, 
                                    status, operation, changed_by)
        VALUES (OLD.id, OLD.order_number, OLD.customer_id, OLD.total_amount,
                OLD.status, 'DELETE', current_user);
        RETURN OLD;
    ELSIF (TG_OP = 'UPDATE') THEN
        INSERT INTO orders_history (id, order_number, customer_id, total_amount,
                                    status, operation, changed_by)
        VALUES (NEW.id, NEW.order_number, NEW.customer_id, NEW.total_amount,
                NEW.status, 'UPDATE', current_user);
        RETURN NEW;
    ELSIF (TG_OP = 'INSERT') THEN
        INSERT INTO orders_history (id, order_number, customer_id, total_amount,
                                    status, operation, changed_by)
        VALUES (NEW.id, NEW.order_number, NEW.customer_id, NEW.total_amount,
                NEW.status, 'INSERT', current_user);
        RETURN NEW;
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_audit_trigger();
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Complete history preserved
  • Main table stays clean
  • Easy to query historical data

Cons:

  • Doubles storage requirements
  • Schema changes need updating both tables
  • Trigger overhead on every operation

Pattern 3: Generic Audit Log Table

Concept: Single table logs changes to any entity

CREATE TABLE audit_logs (
    id BIGSERIAL PRIMARY KEY,

    -- What was changed
    table_name VARCHAR(100) NOT NULL,
    record_id VARCHAR(100) NOT NULL,

    -- What changed
    field_name VARCHAR(100),
    old_value TEXT,
    new_value TEXT,

    -- Who and when
    operation VARCHAR(10) NOT NULL, -- CREATE, UPDATE, DELETE, READ
    changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    changed_by VARCHAR(100) NOT NULL,

    -- Context
    ip_address INET,
    user_agent TEXT,
    session_id VARCHAR(100),
    request_id VARCHAR(100),

    -- Additional metadata
    metadata JSONB
);

-- Indexes for common queries
CREATE INDEX idx_audit_logs_table_record ON audit_logs(table_name, record_id);
CREATE INDEX idx_audit_logs_changed_at ON audit_logs(changed_at DESC);
CREATE INDEX idx_audit_logs_changed_by ON audit_logs(changed_by);
CREATE INDEX idx_audit_logs_operation ON audit_logs(operation);
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Works for any table
  • Flexible schema
  • Single place for all audits

Cons:

  • Less type safety
  • Complex queries for reconstruction
  • Can grow very large

Pattern 4: Event Store (Event Sourcing)

Concept: Store all changes as discrete events

CREATE TABLE events (
    id BIGSERIAL PRIMARY KEY,

    -- Event identification
    aggregate_type VARCHAR(100) NOT NULL, -- e.g., "Order"
    aggregate_id VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL, -- e.g., "OrderCreated", "OrderPaid"
    event_version INT NOT NULL,

    -- Event data
    event_data JSONB NOT NULL,

    -- Metadata
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    caused_by VARCHAR(100) NOT NULL,
    correlation_id VARCHAR(100),

    -- Ensure ordered sequence per aggregate
    CONSTRAINT unique_aggregate_version UNIQUE (aggregate_type, aggregate_id, event_version)
);

CREATE INDEX idx_events_aggregate ON events(aggregate_type, aggregate_id, event_version);
CREATE INDEX idx_events_occurred_at ON events(occurred_at);
Enter fullscreen mode Exit fullscreen mode

Example events:

{
  "event_type": "OrderCreated",
  "aggregate_type": "Order",
  "aggregate_id": "ORD-12345",
  "event_version": 1,
  "event_data": {
    "order_number": "ORD-12345",
    "customer_id": 567,
    "items": [...],
    "total": 99.99
  }
}

{
  "event_type": "OrderPaid",
  "aggregate_type": "Order",
  "aggregate_id": "ORD-12345",
  "event_version": 2,
  "event_data": {
    "payment_method": "credit_card",
    "transaction_id": "TXN-98765"
  }
}
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Complete history with business context
  • Can rebuild state by replaying events
  • Natural fit for CQRS
  • Excellent for complex domains

Cons:

  • High complexity
  • Schema evolution challenges
  • Event versioning required
  • Querying current state requires rebuilding

5. State Transition Tracking

Finite State Machine Pattern

Concept: Track valid state transitions and maintain history

-- Define valid state transitions
CREATE TABLE state_transitions (
    from_state VARCHAR(50) NOT NULL,
    to_state VARCHAR(50) NOT NULL,
    PRIMARY KEY (from_state, to_state)
);

-- Seed valid transitions for orders
INSERT INTO state_transitions (from_state, to_state) VALUES
    ('INITIAL', 'created'),
    ('created', 'payment_pending'),
    ('payment_pending', 'paid'),
    ('payment_pending', 'cancelled'),
    ('paid', 'processing'),
    ('processing', 'shipped'),
    ('shipped', 'delivered'),
    ('delivered', 'completed'),
    ('paid', 'refunded'),
    ('processing', 'cancelled');

-- Main entity table
CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    order_number VARCHAR(50) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    current_state VARCHAR(50) NOT NULL DEFAULT 'created',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- State history table
CREATE TABLE order_state_history (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    from_state VARCHAR(50) NOT NULL,
    to_state VARCHAR(50) NOT NULL,

    -- Enforce valid transitions
    FOREIGN KEY (from_state, to_state) 
        REFERENCES state_transitions(from_state, to_state),

    transitioned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    transitioned_by VARCHAR(100) NOT NULL,
    reason TEXT,
    metadata JSONB,

    -- Ensure transitions are sequential
    sort_key INT NOT NULL,
    is_current BOOLEAN NOT NULL DEFAULT true,

    CONSTRAINT unique_current_per_order 
        UNIQUE (order_id, is_current) WHERE is_current = true
);

-- Index for querying current state
CREATE INDEX idx_order_state_history_current 
    ON order_state_history(order_id, is_current) 
    WHERE is_current = true;

-- Index for timeline queries
CREATE INDEX idx_order_state_history_timeline 
    ON order_state_history(order_id, sort_key);
Enter fullscreen mode Exit fullscreen mode

State Transition Function

CREATE OR REPLACE FUNCTION transition_order_state(
    p_order_id BIGINT,
    p_to_state VARCHAR(50),
    p_user VARCHAR(100),
    p_reason TEXT DEFAULT NULL
) RETURNS BOOLEAN AS $$
DECLARE
    v_current_state VARCHAR(50);
    v_next_sort_key INT;
    v_is_valid BOOLEAN;
BEGIN
    -- Get current state
    SELECT current_state INTO v_current_state
    FROM orders
    WHERE id = p_order_id
    FOR UPDATE; -- Lock row

    IF NOT FOUND THEN
        RAISE EXCEPTION 'Order not found: %', p_order_id;
    END IF;

    -- Check if transition is valid
    SELECT EXISTS(
        SELECT 1 FROM state_transitions
        WHERE from_state = v_current_state
        AND to_state = p_to_state
    ) INTO v_is_valid;

    IF NOT v_is_valid THEN
        RAISE EXCEPTION 'Invalid state transition: % -> %', 
            v_current_state, p_to_state;
    END IF;

    -- Get next sort key
    SELECT COALESCE(MAX(sort_key), 0) + 1
    INTO v_next_sort_key
    FROM order_state_history
    WHERE order_id = p_order_id;

    -- Mark current state as no longer current
    UPDATE order_state_history
    SET is_current = false
    WHERE order_id = p_order_id
    AND is_current = true;

    -- Insert new state
    INSERT INTO order_state_history (
        order_id, from_state, to_state, transitioned_by, 
        reason, sort_key, is_current
    ) VALUES (
        p_order_id, v_current_state, p_to_state, p_user,
        p_reason, v_next_sort_key, true
    );

    -- Update current state on main table
    UPDATE orders
    SET current_state = p_to_state
    WHERE id = p_order_id;

    RETURN true;
END;
$$ LANGUAGE plpgsql;
Enter fullscreen mode Exit fullscreen mode

Querying State History

-- Get complete timeline for an order
SELECT 
    from_state,
    to_state,
    transitioned_at,
    transitioned_by,
    reason,
    sort_key
FROM order_state_history
WHERE order_id = 123
ORDER BY sort_key;

-- Find all orders in a specific state
SELECT o.id, o.order_number, o.current_state
FROM orders o
WHERE o.current_state = 'shipped';

-- Find orders that transitioned through a specific state
SELECT DISTINCT o.id, o.order_number
FROM orders o
JOIN order_state_history h ON o.id = h.order_id
WHERE h.to_state = 'processing';

-- Get duration in each state
SELECT 
    order_id,
    to_state,
    transitioned_at,
    LEAD(transitioned_at) OVER (PARTITION BY order_id ORDER BY sort_key) 
        - transitioned_at AS duration_in_state
FROM order_state_history
WHERE order_id = 123
ORDER BY sort_key;
Enter fullscreen mode Exit fullscreen mode

6. API Audit Logging Best Practices

Essential Elements for API Audit Logs

  1. Request Identification

    • Request ID (unique identifier)
    • Correlation ID (for distributed tracing)
    • Session ID
  2. Authentication/Authorization

    • User ID or service account
    • Authentication method (JWT, OAuth, API key)
    • Scopes/permissions used
  3. Request Details

    • HTTP method (GET, POST, PUT, DELETE)
    • Endpoint/route
    • Request parameters
    • Request body (sanitized of sensitive data)
  4. Response Details

    • Status code
    • Response time
    • Error messages (if applicable)
  5. Context

    • IP address
    • User agent
    • Geolocation
    • Timestamp (with timezone)

API Audit Log Schema

CREATE TABLE api_audit_logs (
    id BIGSERIAL PRIMARY KEY,

    -- Request identification
    request_id UUID NOT NULL UNIQUE,
    correlation_id UUID,
    session_id VARCHAR(100),

    -- Authentication
    user_id VARCHAR(100),
    service_account VARCHAR(100),
    auth_method VARCHAR(50),

    -- Request details
    http_method VARCHAR(10) NOT NULL,
    endpoint TEXT NOT NULL,
    route_pattern VARCHAR(200), -- e.g., "/api/v1/orders/:id"
    query_params JSONB,
    request_body JSONB,
    request_headers JSONB,

    -- Response details
    status_code INT NOT NULL,
    response_time_ms INT,
    response_body JSONB,
    error_message TEXT,

    -- Context
    ip_address INET,
    user_agent TEXT,
    geo_location JSONB,

    -- Timing
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Additional metadata
    metadata JSONB
);

-- Indexes for common query patterns
CREATE INDEX idx_api_audit_user ON api_audit_logs(user_id, created_at DESC);
CREATE INDEX idx_api_audit_endpoint ON api_audit_logs(route_pattern, created_at DESC);
CREATE INDEX idx_api_audit_status ON api_audit_logs(status_code, created_at DESC);
CREATE INDEX idx_api_audit_created ON api_audit_logs(created_at DESC);
CREATE INDEX idx_api_audit_correlation ON api_audit_logs(correlation_id);

-- GIN index for JSONB queries
CREATE INDEX idx_api_audit_metadata ON api_audit_logs USING GIN(metadata);
Enter fullscreen mode Exit fullscreen mode

API Audit Logging Best Practices

  1. Log Asynchronously

    • Don't block API responses
    • Use queues (RabbitMQ, Kafka)
    • Buffer and batch writes
  2. Sanitize Sensitive Data

    • Never log passwords, tokens, credit cards
    • Mask or redact PII
    • Use allowlist for safe fields
  3. Structured Logging

    • Use JSON format
    • Consistent field names
    • Enable easy parsing/analysis
  4. Implement Log Rotation

    • Archive old logs
    • Partition tables by date
    • Set retention policies
  5. Monitor and Alert

    • Set up alerts for anomalies
    • Track error rates
    • Monitor unusual access patterns
  6. Compliance Considerations

    • GDPR: Support right to erasure
    • HIPAA: Encrypt at rest and in transit
    • SOC 2: Tamper-proof logs

7. Go/PostgreSQL/GORM Implementation Patterns

Architecture Overview

┌─────────────────────────────────────────┐
│         HTTP Handler Layer              │
│  (Middleware: Context, Auth, Logging)   │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│         Service Layer                   │
│  (Business Logic, Validation)           │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│       Repository Layer                  │
│  (Data Access, GORM Operations)         │
└──────────────┬──────────────────────────┘
               │
┌──────────────▼──────────────────────────┐
│         Database Layer                  │
│  (PostgreSQL + Audit Tables)            │
└─────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Pattern 1: GORM Callbacks for Automatic Audit Logging

Concept: Use GORM's hook system to automatically log changes

package models

import (
    "context"
    "encoding/json"
    "time"

    "gorm.io/gorm"
)

// AuditLog represents the audit log table
type AuditLog struct {
    ID          uint           `gorm:"primaryKey"`
    TableName   string         `gorm:"index;not null"`
    RecordID    string         `gorm:"index;not null"`
    Operation   string         `gorm:"index;not null"` // CREATE, UPDATE, DELETE, READ
    OldValue    json.RawMessage `gorm:"type:jsonb"`
    NewValue    json.RawMessage `gorm:"type:jsonb"`
    ChangedBy   string         `gorm:"index"`
    ChangedAt   time.Time      `gorm:"index;not null"`
    IPAddress   string
    UserAgent   string
    SessionID   string         `gorm:"index"`
    RequestID   string         `gorm:"index"`
    Metadata    json.RawMessage `gorm:"type:jsonb"`
}

// Auditable interface for models that support audit logging
type Auditable interface {
    TableName() string
    GetID() string
}

// Order model with audit support
type Order struct {
    ID           uint      `gorm:"primaryKey"`
    OrderNumber  string    `gorm:"uniqueIndex;not null"`
    CustomerID   uint      `gorm:"not null"`
    TotalAmount  float64
    Status       string
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

func (Order) TableName() string {
    return "orders"
}

func (o Order) GetID() string {
    return fmt.Sprintf("%d", o.ID)
}

// RegisterAuditCallbacks registers GORM callbacks for audit logging
func RegisterAuditCallbacks(db *gorm.DB) {
    // After Create
    db.Callback().Create().After("gorm:after_create").Register("audit:after_create", func(db *gorm.DB) {
        if db.Statement.Schema == nil {
            return
        }

        // Check if model implements Auditable
        if auditable, ok := db.Statement.Dest.(Auditable); ok {
            createAuditLog(db, auditable, "CREATE", nil, db.Statement.Dest)
        }
    })

    // After Update
    db.Callback().Update().After("gorm:after_update").Register("audit:after_update", func(db *gorm.DB) {
        if db.Statement.Schema == nil {
            return
        }

        // Get old values before update (stored in context)
        oldValue := db.Statement.Context.Value("audit:old_value")

        if auditable, ok := db.Statement.Dest.(Auditable); ok {
            createAuditLog(db, auditable, "UPDATE", oldValue, db.Statement.Dest)
        }
    })

    // Before Update - capture old values
    db.Callback().Update().Before("gorm:before_update").Register("audit:before_update", func(db *gorm.DB) {
        if db.Statement.Schema == nil {
            return
        }

        // Fetch current values
        if auditable, ok := db.Statement.Dest.(Auditable); ok {
            var oldModel interface{}
            // Clone the model
            db.Session(&gorm.Session{}).
                Where(db.Statement.Schema.PrioritizedPrimaryField.Name+" = ?", auditable.GetID()).
                First(&oldModel)

            // Store in context for after_update
            ctx := context.WithValue(db.Statement.Context, "audit:old_value", oldModel)
            db.Statement.Context = ctx
        }
    })

    // After Delete
    db.Callback().Delete().After("gorm:after_delete").Register("audit:after_delete", func(db *gorm.DB) {
        if db.Statement.Schema == nil {
            return
        }

        if auditable, ok := db.Statement.Dest.(Auditable); ok {
            createAuditLog(db, auditable, "DELETE", db.Statement.Dest, nil)
        }
    })
}

func createAuditLog(db *gorm.DB, auditable Auditable, operation string, oldValue, newValue interface{}) {
    // Get audit context from request context
    ctx := db.Statement.Context

    var oldJSON, newJSON json.RawMessage

    if oldValue != nil {
        oldJSON, _ = json.Marshal(oldValue)
    }
    if newValue != nil {
        newJSON, _ = json.Marshal(newValue)
    }

    auditLog := AuditLog{
        TableName:  auditable.TableName(),
        RecordID:   auditable.GetID(),
        Operation:  operation,
        OldValue:   oldJSON,
        NewValue:   newJSON,
        ChangedBy:  getFromContext(ctx, "user_id"),
        ChangedAt:  time.Now(),
        IPAddress:  getFromContext(ctx, "ip_address"),
        UserAgent:  getFromContext(ctx, "user_agent"),
        SessionID:  getFromContext(ctx, "session_id"),
        RequestID:  getFromContext(ctx, "request_id"),
    }

    // Use a new session to avoid triggering callbacks recursively
    db.Session(&gorm.Session{SkipHooks: true}).Create(&auditLog)
}

func getFromContext(ctx context.Context, key string) string {
    if val := ctx.Value(key); val != nil {
        if str, ok := val.(string); ok {
            return str
        }
    }
    return ""
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Repository Pattern with Explicit Audit Logging

Concept: Repository layer explicitly logs changes

package repository

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "gorm.io/gorm"
)

// OrderRepository handles order data access
type OrderRepository interface {
    Create(ctx context.Context, order *Order) error
    Update(ctx context.Context, order *Order) error
    Delete(ctx context.Context, id uint) error
    FindByID(ctx context.Context, id uint) (*Order, error)
}

type orderRepository struct {
    db *gorm.DB
    auditRepo AuditRepository
}

func NewOrderRepository(db *gorm.DB, auditRepo AuditRepository) OrderRepository {
    return &orderRepository{
        db: db,
        auditRepo: auditRepo,
    }
}

func (r *orderRepository) Create(ctx context.Context, order *Order) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // Create order
        if err := tx.Create(order).Error; err != nil {
            return err
        }

        // Log audit
        newValue, _ := json.Marshal(order)
        return r.auditRepo.Log(ctx, AuditEntry{
            TableName: "orders",
            RecordID:  fmt.Sprintf("%d", order.ID),
            Operation: "CREATE",
            NewValue:  newValue,
        })
    })
}

func (r *orderRepository) Update(ctx context.Context, order *Order) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // Get old values
        var oldOrder Order
        if err := tx.First(&oldOrder, order.ID).Error; err != nil {
            return err
        }

        // Update order
        if err := tx.Save(order).Error; err != nil {
            return err
        }

        // Log audit
        oldValue, _ := json.Marshal(oldOrder)
        newValue, _ := json.Marshal(order)
        return r.auditRepo.Log(ctx, AuditEntry{
            TableName: "orders",
            RecordID:  fmt.Sprintf("%d", order.ID),
            Operation: "UPDATE",
            OldValue:  oldValue,
            NewValue:  newValue,
        })
    })
}

func (r *orderRepository) Delete(ctx context.Context, id uint) error {
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // Get current values before delete
        var order Order
        if err := tx.First(&order, id).Error; err != nil {
            return err
        }

        // Soft delete
        if err := tx.Delete(&order, id).Error; err != nil {
            return err
        }

        // Log audit
        oldValue, _ := json.Marshal(order)
        return r.auditRepo.Log(ctx, AuditEntry{
            TableName: "orders",
            RecordID:  fmt.Sprintf("%d", id),
            Operation: "DELETE",
            OldValue:  oldValue,
        })
    })
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: State Machine with Audit Trail

Concept: Explicit state transitions with validation and audit

package statemachine

import (
    "context"
    "fmt"
    "time"

    "gorm.io/gorm"
    "gorm.io/gorm/clause"
)

// OrderState represents valid order states
type OrderState string

const (
    StateCreated         OrderState = "created"
    StatePaymentPending  OrderState = "payment_pending"
    StatePaid            OrderState = "paid"
    StateProcessing      OrderState = "processing"
    StateShipped         OrderState = "shipped"
    StateDelivered       OrderState = "delivered"
    StateCompleted       OrderState = "completed"
    StateCancelled       OrderState = "cancelled"
    StateRefunded        OrderState = "refunded"
)

// StateTransition defines valid transitions
type StateTransition struct {
    FromState OrderState `gorm:"primaryKey"`
    ToState   OrderState `gorm:"primaryKey"`
}

// OrderStateHistory tracks state changes
type OrderStateHistory struct {
    ID              uint       `gorm:"primaryKey"`
    OrderID         uint       `gorm:"index;not null"`
    FromState       OrderState `gorm:"not null"`
    ToState         OrderState `gorm:"not null"`
    TransitionedAt  time.Time  `gorm:"index;not null"`
    TransitionedBy  string     `gorm:"index;not null"`
    Reason          string
    SortKey         int        `gorm:"not null"`
    IsCurrent       bool       `gorm:"not null;default:true"`
    Metadata        string     `gorm:"type:jsonb"`
}

// OrderStateMachine manages order state transitions
type OrderStateMachine struct {
    db *gorm.DB
}

func NewOrderStateMachine(db *gorm.DB) *OrderStateMachine {
    return &OrderStateMachine{db: db}
}

// TransitionTo attempts to transition order to new state
func (sm *OrderStateMachine) TransitionTo(
    ctx context.Context,
    orderID uint,
    toState OrderState,
    user string,
    reason string,
) error {
    return sm.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // Lock order row
        var order Order
        if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
            First(&order, orderID).Error; err != nil {
            return fmt.Errorf("order not found: %w", err)
        }

        currentState := OrderState(order.Status)

        // Validate transition
        if err := sm.validateTransition(tx, currentState, toState); err != nil {
            return err
        }

        // Get next sort key
        var maxSortKey int
        tx.Model(&OrderStateHistory{}).
            Where("order_id = ?", orderID).
            Select("COALESCE(MAX(sort_key), 0)").
            Scan(&maxSortKey)
        nextSortKey := maxSortKey + 1

        // Mark current state as not current
        tx.Model(&OrderStateHistory{}).
            Where("order_id = ? AND is_current = ?", orderID, true).
            Update("is_current", false)

        // Create new state history entry
        history := OrderStateHistory{
            OrderID:        orderID,
            FromState:      currentState,
            ToState:        toState,
            TransitionedAt: time.Now(),
            TransitionedBy: user,
            Reason:         reason,
            SortKey:        nextSortKey,
            IsCurrent:      true,
        }

        if err := tx.Create(&history).Error; err != nil {
            return fmt.Errorf("failed to create state history: %w", err)
        }

        // Update order status
        if err := tx.Model(&order).Update("status", string(toState)).Error; err != nil {
            return fmt.Errorf("failed to update order status: %w", err)
        }

        return nil
    })
}

func (sm *OrderStateMachine) validateTransition(tx *gorm.DB, from, to OrderState) error {
    var count int64
    tx.Model(&StateTransition{}).
        Where("from_state = ? AND to_state = ?", from, to).
        Count(&count)

    if count == 0 {
        return fmt.Errorf("invalid state transition: %s -> %s", from, to)
    }
    return nil
}

// GetStateHistory returns the complete state history for an order
func (sm *OrderStateMachine) GetStateHistory(ctx context.Context, orderID uint) ([]OrderStateHistory, error) {
    var history []OrderStateHistory
    err := sm.db.WithContext(ctx).
        Where("order_id = ?", orderID).
        Order("sort_key ASC").
        Find(&history).Error
    return history, err
}

// GetCurrentState returns the current state of an order
func (sm *OrderStateMachine) GetCurrentState(ctx context.Context, orderID uint) (*OrderStateHistory, error) {
    var history OrderStateHistory
    err := sm.db.WithContext(ctx).
        Where("order_id = ? AND is_current = ?", orderID, true).
        First(&history).Error
    return &history, err
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Middleware for API Audit Logging

Concept: HTTP middleware captures API request/response details

package middleware

import (
    "bytes"
    "context"
    "encoding/json"
    "io"
    "log"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "gorm.io/gorm"
)

// APIAuditLog represents API audit log entries
type APIAuditLog struct {
    ID             uint            `gorm:"primaryKey"`
    RequestID      string          `gorm:"uniqueIndex;not null"`
    CorrelationID  string          `gorm:"index"`
    SessionID      string          `gorm:"index"`
    UserID         string          `gorm:"index"`
    HTTPMethod     string          `gorm:"not null"`
    Endpoint       string          `gorm:"not null"`
    RoutePattern   string          `gorm:"index"`
    QueryParams    json.RawMessage `gorm:"type:jsonb"`
    RequestBody    json.RawMessage `gorm:"type:jsonb"`
    RequestHeaders json.RawMessage `gorm:"type:jsonb"`
    StatusCode     int             `gorm:"index"`
    ResponseTimeMs int
    ResponseBody   json.RawMessage `gorm:"type:jsonb"`
    ErrorMessage   string
    IPAddress      string
    UserAgent      string
    CreatedAt      time.Time       `gorm:"index"`
    Metadata       json.RawMessage `gorm:"type:jsonb"`
}

// APIAuditMiddleware logs all API requests and responses
func APIAuditMiddleware(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        startTime := time.Now()

        // Generate request ID if not present
        requestID := c.GetHeader("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
            c.Header("X-Request-ID", requestID)
        }

        // Store request ID in context
        ctx := context.WithValue(c.Request.Context(), "request_id", requestID)
        c.Request = c.Request.WithContext(ctx)

        // Capture request body
        var requestBody []byte
        if c.Request.Body != nil {
            requestBody, _ = io.ReadAll(c.Request.Body)
            // Restore body for handler
            c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
        }

        // Create custom response writer to capture response
        blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
        c.Writer = blw

        // Process request
        c.Next()

        // Calculate response time
        responseTime := time.Since(startTime).Milliseconds()

        // Prepare audit log (async to not block response)
        go func() {
            auditLog := APIAuditLog{
                RequestID:      requestID,
                CorrelationID:  c.GetHeader("X-Correlation-ID"),
                SessionID:      c.GetString("session_id"), // From auth middleware
                UserID:         c.GetString("user_id"),    // From auth middleware
                HTTPMethod:     c.Request.Method,
                Endpoint:       c.Request.URL.Path,
                RoutePattern:   c.FullPath(),
                QueryParams:    toJSON(c.Request.URL.Query()),
                RequestBody:    sanitizeRequestBody(requestBody),
                RequestHeaders: toJSON(sanitizeHeaders(c.Request.Header)),
                StatusCode:     c.Writer.Status(),
                ResponseTimeMs: int(responseTime),
                ResponseBody:   sanitizeResponseBody(blw.body.Bytes()),
                IPAddress:      c.ClientIP(),
                UserAgent:      c.Request.UserAgent(),
                CreatedAt:      startTime,
            }

            // Save to database
            if err := db.Create(&auditLog).Error; err != nil {
                // Log error but don't fail the request
                log.Printf("Failed to create audit log: %v", err)
            }
        }()
    }
}

type bodyLogWriter struct {
    gin.ResponseWriter
    body *bytes.Buffer
}

func (w bodyLogWriter) Write(b []byte) (int, error) {
    w.body.Write(b)
    return w.ResponseWriter.Write(b)
}

func toJSON(v interface{}) json.RawMessage {
    b, _ := json.Marshal(v)
    return b
}

func sanitizeRequestBody(body []byte) json.RawMessage {
    if len(body) == 0 {
        return nil
    }

    var data map[string]interface{}
    if err := json.Unmarshal(body, &data); err != nil {
        return nil
    }

    // Remove sensitive fields
    sensitiveFields := []string{"password", "token", "api_key", "secret", "credit_card"}
    for _, field := range sensitiveFields {
        if _, exists := data[field]; exists {
            data[field] = "[REDACTED]"
        }
    }

    return toJSON(data)
}

func sanitizeResponseBody(body []byte) json.RawMessage {
    // Similar to request sanitization
    return sanitizeRequestBody(body)
}

func sanitizeHeaders(headers http.Header) http.Header {
    sanitized := make(http.Header)
    for k, v := range headers {
        // Skip authorization headers
        if k == "Authorization" || k == "Cookie" {
            sanitized[k] = []string{"[REDACTED]"}
        } else {
            sanitized[k] = v
        }
    }
    return sanitized
}
Enter fullscreen mode Exit fullscreen mode

8. Complete Code Examples

Example 1: Complete Order Management System with Audit

// main.go
package main

import (
    "log"

    "github.com/gin-gonic/gin"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    // Database connection
    dsn := "host=localhost user=postgres password=postgres dbname=orders port=5432"
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }

    // Auto-migrate tables
    db.AutoMigrate(
        &Order{},
        &OrderStateHistory{},
        &StateTransition{},
        &AuditLog{},
        &APIAuditLog{},
    )

    // Seed valid state transitions
    seedStateTransitions(db)

    // Register audit callbacks
    RegisterAuditCallbacks(db)

    // Initialize repositories
    auditRepo := NewAuditRepository(db)
    orderRepo := NewOrderRepository(db, auditRepo)

    // Initialize state machine
    stateMachine := NewOrderStateMachine(db)

    // Initialize services
    orderService := NewOrderService(orderRepo, stateMachine)

    // Setup router
    router := gin.Default()

    // Global middleware
    router.Use(APIAuditMiddleware(db))
    router.Use(AuthMiddleware()) // Your auth middleware

    // Routes
    v1 := router.Group("/api/v1")
    {
        orders := v1.Group("/orders")
        {
            orders.POST("", orderService.CreateOrder)
            orders.GET("/:id", orderService.GetOrder)
            orders.PUT("/:id", orderService.UpdateOrder)
            orders.DELETE("/:id", orderService.DeleteOrder)
            orders.POST("/:id/transition", orderService.TransitionState)
            orders.GET("/:id/history", orderService.GetStateHistory)
        }
    }

    log.Fatal(router.Run(":8080"))
}

func seedStateTransitions(db *gorm.DB) {
    transitions := []StateTransition{
        {FromState: "created", ToState: "payment_pending"},
        {FromState: "payment_pending", ToState: "paid"},
        {FromState: "payment_pending", ToState: "cancelled"},
        {FromState: "paid", ToState: "processing"},
        {FromState: "processing", ToState: "shipped"},
        {FromState: "shipped", ToState: "delivered"},
        {FromState: "delivered", ToState: "completed"},
        {FromState: "paid", ToState: "refunded"},
        {FromState: "processing", ToState: "cancelled"},
    }

    for _, t := range transitions {
        db.FirstOrCreate(&t, t)
    }
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Service Layer with Context Propagation

// service/order_service.go
package service

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "strconv"
    "time"

    "github.com/gin-gonic/gin"
)

type OrderService struct {
    orderRepo    OrderRepository
    stateMachine *OrderStateMachine
}

func NewOrderService(orderRepo OrderRepository, stateMachine *OrderStateMachine) *OrderService {
    return &OrderService{
        orderRepo:    orderRepo,
        stateMachine: stateMachine,
    }
}

type CreateOrderRequest struct {
    CustomerID  uint    `json:"customer_id" binding:"required"`
    TotalAmount float64 `json:"total_amount" binding:"required"`
}

func (s *OrderService) CreateOrder(c *gin.Context) {
    var req CreateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    // Build context with audit information
    ctx := buildAuditContext(c)

    // Generate order number
    orderNumber := fmt.Sprintf("ORD-%d", time.Now().Unix())

    order := &Order{
        OrderNumber: orderNumber,
        CustomerID:  req.CustomerID,
        TotalAmount: req.TotalAmount,
        Status:      string(StateCreated),
    }

    if err := s.orderRepo.Create(ctx, order); err != nil {
        log.Printf("Failed to create order: %v", err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
        return
    }

    c.JSON(http.StatusCreated, order)
}

func (s *OrderService) TransitionState(c *gin.Context) {
    orderID := c.Param("id")

    var req struct {
        ToState string `json:"to_state" binding:"required"`
        Reason  string `json:"reason"`
    }

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    ctx := buildAuditContext(c)
    userID := c.GetString("user_id")

    id, _ := strconv.ParseUint(orderID, 10, 64)

    err := s.stateMachine.TransitionTo(
        ctx,
        uint(id),
        OrderState(req.ToState),
        userID,
        req.Reason,
    )

    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "State transitioned successfully"})
}

func (s *OrderService) GetStateHistory(c *gin.Context) {
    orderID := c.Param("id")
    id, _ := strconv.ParseUint(orderID, 10, 64)

    ctx := buildAuditContext(c)

    history, err := s.stateMachine.GetStateHistory(ctx, uint(id))
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
        return
    }

    c.JSON(http.StatusOK, history)
}

// buildAuditContext extracts audit info from request and builds context
func buildAuditContext(c *gin.Context) context.Context {
    ctx := c.Request.Context()

    // Add audit fields to context
    ctx = context.WithValue(ctx, "user_id", c.GetString("user_id"))
    ctx = context.WithValue(ctx, "session_id", c.GetString("session_id"))
    ctx = context.WithValue(ctx, "ip_address", c.ClientIP())
    ctx = context.WithValue(ctx, "user_agent", c.Request.UserAgent())
    ctx = context.WithValue(ctx, "request_id", c.GetHeader("X-Request-ID"))

    return ctx
}
Enter fullscreen mode Exit fullscreen mode

Example 3: Querying Audit Logs

// repository/audit_repository.go
package repository

import (
    "context"
    "encoding/json"
    "time"

    "gorm.io/gorm"
)

type AuditRepository interface {
    Log(ctx context.Context, entry AuditEntry) error
    GetByRecordID(ctx context.Context, tableName, recordID string) ([]AuditLog, error)
    GetByUser(ctx context.Context, userID string, limit int) ([]AuditLog, error)
    GetByDateRange(ctx context.Context, start, end time.Time) ([]AuditLog, error)
    GetByOperation(ctx context.Context, operation string, limit int) ([]AuditLog, error)
}

type auditRepository struct {
    db *gorm.DB
}

func NewAuditRepository(db *gorm.DB) AuditRepository {
    return &auditRepository{db: db}
}

type AuditEntry struct {
    TableName string
    RecordID  string
    Operation string
    OldValue  json.RawMessage
    NewValue  json.RawMessage
}

func (r *auditRepository) Log(ctx context.Context, entry AuditEntry) error {
    auditLog := AuditLog{
        TableName:  entry.TableName,
        RecordID:   entry.RecordID,
        Operation:  entry.Operation,
        OldValue:   entry.OldValue,
        NewValue:   entry.NewValue,
        ChangedBy:  getFromContext(ctx, "user_id"),
        ChangedAt:  time.Now(),
        IPAddress:  getFromContext(ctx, "ip_address"),
        UserAgent:  getFromContext(ctx, "user_agent"),
        SessionID:  getFromContext(ctx, "session_id"),
        RequestID:  getFromContext(ctx, "request_id"),
    }

    return r.db.WithContext(ctx).Create(&auditLog).Error
}

func (r *auditRepository) GetByRecordID(ctx context.Context, tableName, recordID string) ([]AuditLog, error) {
    var logs []AuditLog
    err := r.db.WithContext(ctx).
        Where("table_name = ? AND record_id = ?", tableName, recordID).
        Order("changed_at DESC").
        Find(&logs).Error
    return logs, err
}

func (r *auditRepository) GetByUser(ctx context.Context, userID string, limit int) ([]AuditLog, error) {
    var logs []AuditLog
    err := r.db.WithContext(ctx).
        Where("changed_by = ?", userID).
        Order("changed_at DESC").
        Limit(limit).
        Find(&logs).Error
    return logs, err
}

func (r *auditRepository) GetByDateRange(ctx context.Context, start, end time.Time) ([]AuditLog, error) {
    var logs []AuditLog
    err := r.db.WithContext(ctx).
        Where("changed_at BETWEEN ? AND ?", start, end).
        Order("changed_at DESC").
        Find(&logs).Error
    return logs, err
}

func (r *auditRepository) GetByOperation(ctx context.Context, operation string, limit int) ([]AuditLog, error) {
    var logs []AuditLog
    err := r.db.WithContext(ctx).
        Where("operation = ?", operation).
        Order("changed_at DESC").
        Limit(limit).
        Find(&logs).Error
    return logs, err
}
Enter fullscreen mode Exit fullscreen mode

9. Summary & Best Practices

Key Takeaways

  1. Choose the Right Pattern

    • Simple audit: Row versioning or shadow tables
    • Complex domains: Event sourcing
    • API tracking: Middleware-based logging
  2. GORM Integration

    • Use callbacks for automatic logging
    • Leverage context for audit metadata
    • Implement repository pattern for clean separation
  3. State Management

    • Use state machines for complex workflows
    • Enforce valid transitions at database level
    • Maintain complete history with sort keys
  4. API Audit Logging

    • Log asynchronously to avoid blocking
    • Sanitize sensitive data
    • Use correlation IDs for distributed tracing
  5. Performance

    • Index strategically (user_id, timestamp, table_name)
    • Partition large tables by date
    • Archive old logs to cold storage
    • Use JSONB for flexible metadata
  6. Compliance

    • Encrypt sensitive audit data
    • Implement log retention policies
    • Support right-to-erasure (GDPR)
    • Make logs tamper-proof (write-once)
  7. Testing

    • Unit test state transitions
    • Integration test audit log creation
    • Verify sensitive data is sanitized
    • Test concurrent state changes

Top comments (0)