Table of Contents
- Audit Log Paradigms
- Event Sourcing vs Audit Logging
- Real-World Use Cases
- Database Design Patterns for Audit Trails
- State Transition Tracking
- API Audit Logging Best Practices
- Go/PostgreSQL/GORM Implementation Patterns
- 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)
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
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
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
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
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
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)
);
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();
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);
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);
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"
}
}
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);
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;
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;
6. API Audit Logging Best Practices
Essential Elements for API Audit Logs
-
Request Identification
- Request ID (unique identifier)
- Correlation ID (for distributed tracing)
- Session ID
-
Authentication/Authorization
- User ID or service account
- Authentication method (JWT, OAuth, API key)
- Scopes/permissions used
-
Request Details
- HTTP method (GET, POST, PUT, DELETE)
- Endpoint/route
- Request parameters
- Request body (sanitized of sensitive data)
-
Response Details
- Status code
- Response time
- Error messages (if applicable)
-
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);
API Audit Logging Best Practices
-
Log Asynchronously
- Don't block API responses
- Use queues (RabbitMQ, Kafka)
- Buffer and batch writes
-
Sanitize Sensitive Data
- Never log passwords, tokens, credit cards
- Mask or redact PII
- Use allowlist for safe fields
-
Structured Logging
- Use JSON format
- Consistent field names
- Enable easy parsing/analysis
-
Implement Log Rotation
- Archive old logs
- Partition tables by date
- Set retention policies
-
Monitor and Alert
- Set up alerts for anomalies
- Track error rates
- Monitor unusual access patterns
-
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) │
└─────────────────────────────────────────┘
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 ""
}
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,
})
})
}
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
}
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
}
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)
}
}
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
}
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
}
9. Summary & Best Practices
Key Takeaways
-
Choose the Right Pattern
- Simple audit: Row versioning or shadow tables
- Complex domains: Event sourcing
- API tracking: Middleware-based logging
-
GORM Integration
- Use callbacks for automatic logging
- Leverage context for audit metadata
- Implement repository pattern for clean separation
-
State Management
- Use state machines for complex workflows
- Enforce valid transitions at database level
- Maintain complete history with sort keys
-
API Audit Logging
- Log asynchronously to avoid blocking
- Sanitize sensitive data
- Use correlation IDs for distributed tracing
-
Performance
- Index strategically (user_id, timestamp, table_name)
- Partition large tables by date
- Archive old logs to cold storage
- Use JSONB for flexible metadata
-
Compliance
- Encrypt sensitive audit data
- Implement log retention policies
- Support right-to-erasure (GDPR)
- Make logs tamper-proof (write-once)
-
Testing
- Unit test state transitions
- Integration test audit log creation
- Verify sensitive data is sanitized
- Test concurrent state changes
Top comments (0)