ADR-0006: Go backend as read-only consumer of Better Auth via shared PostgreSQL

Status

Accepted

Tags

authentication, better-auth, postgresql, multi-tenancy, permissions, redis, nats

Decision

Go microservices skip a synchronization layer and query the Better Auth PostgreSQL tables directly using read-only SELECT operations. Go models must exactly match the schema generated by the Better Auth plugins. Cache invalidation is handled in real-time via NATS events.

Why

Adding a NATS sync layer to replicate Better Auth identity data into Go-owned tables introduces synchronization lag, dual-write complexity, and a second source of truth. Direct queries to a single shared schema are simpler, strictly consistent, and lower latency.

Table ownership

TableOwnerGo access
usersBetter Auth core + Admin pluginSELECT only
membersOrganization pluginSELECT only
organizationsOrganization pluginSELECT only
accountsBetter Auth coreNo access needed
Go models must use the exact column names generated by Better Auth plugins (plural table names, snake_case).

Permission resolution (multi-tier)

  1. JWT claims (orgRole, organizationId) — fast, no IO, used for coarse checks
  2. Redis cache (perm:<userId>:<orgId>, 5m TTL) — mid-tier, avoids repeated DB hits
  3. PostgreSQL (members table) — authoritative, queried only on cache miss

Consistency model

Role/membership changes in Bun trigger NATS events (member.role.changed, member.removed). The Go MemberRoleChangeConsumer immediately deletes the affected Redis cache key. The next request re-queries PostgreSQL. Sub-second consistency without synchronization overhead.

Required indexes

idx_users_role
idx_members_user_org                   -- compound: (user_id, organization_id)
idx_members_organization_id_role       -- for listing org members by role

Rules for agents

  • Go models that map to Better Auth tables must use exact plural table names: users, members, organizations
  • Never add write operations to Better Auth table repositories — INSERT, UPDATE, DELETE belong in the Bun backend only
  • Role changes must go through the Better Auth API on Bun, never direct SQL from Go
  • Maintain all three required indexes; do not drop them
  • When adding a new permission check, follow the three-tier lookup: JWT claim → Redis → PostgreSQL

Bad pattern (do not generate)

// Mutating a Better Auth table from Go
db.Table("members").Where(...).Update("role", newRole) // wrong — Bun owns this

// Replicating identity data into a Go-owned table
type GoUser struct { gorm.Model; BetterAuthID string } // wrong — single source of truth

Good pattern

// Read-only org role query
db.Table("members").
    Select("role").
    Where("user_id = ? AND organization_id = ?", userID, orgID).
    Scan(&role)

// Real-time cache invalidation
redis.Del(ctx, fmt.Sprintf("perm:%s:%s", event.UserID, event.OrgID))