ADR-0006: Go backend as read-only consumer of Better Auth via shared PostgreSQL
Status
AcceptedTags
authentication, better-auth, postgresql, multi-tenancy, permissions, redis, natsDecision
Go microservices skip a synchronization layer and query the Better Auth PostgreSQL tables directly using read-onlySELECT 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
Go models must use the exact column names generated by Better Auth plugins (plural table names, snake_case).
Permission resolution (multi-tier)
- JWT claims (
orgRole,organizationId) — fast, no IO, used for coarse checks - Redis cache (
perm:<userId>:<orgId>, 5m TTL) — mid-tier, avoids repeated DB hits - PostgreSQL (
memberstable) — 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
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,DELETEbelong in the Bun backend only - Role changes must go through the Better Auth API on Bun, never direct SQL from Go — except the single carve-out recorded under Exceptions (E1); do not widen it
- Maintain all three required indexes; do not drop them
- When adding a new permission check, follow the three-tier lookup: JWT claim → Redis → PostgreSQL
Exceptions
Exactly one write exception is granted. Anything not listed here is still forbidden.E1 — Rental landlord self-promotion (users.role)
The rental module (orgless B2C, ADR-0055) promotes a signed-up account to the global
landlord role with one conditional UPDATE on users.role from Go, in
internal/modules/rental/adapter/outbound/persistence/postgresql/user_repository.go.
Scope of the exception — all of these hold, or the exception does not apply:
- One meaningful column,
users.role, on the session user’s own row. No other user’s row may be written. - The same statement also sets that row’s
updated_at. This is deliberate and is part of the exception:updated_atrecords when the row last changed, so a write that skipped it would leave every Better Auth consumer reading a timestamp that denies a change which did happen. It carries no authorization meaning of its own. No other Better Auth column may be written. - The write is guarded in SQL, not in Go: rows already holding
adminorsuperadminare excluded by theWHEREclause, so a staff role can never be overwritten. - Only the
landlordvalue may be assigned torole. Granting or revoking any staff role from Go stays forbidden. - Organization roles (
members.role) are not covered. They keep going through the Better Auth API on Bun, with the NATS cache invalidation described above.
buildRoles), which Bun mints at token-issue time. A promotion written
directly in Postgres does not retroactively change a token already in the caller’s
hands, and there is no session invalidation. The account therefore holds landlord in
the database but not in its current token until a new token is issued. The promotion
endpoint reports this to the client (token_refresh_required), and landlord endpoints
keep rejecting the stale token until it is refreshed — that refusal is correct
behaviour, not a bug.
Revisit when: Bun grows a platform-role API, or a second module needs to write a
Better Auth column. Either makes this exception a pattern, which it must not become.