Query-Only Better Auth

The Power of Interface-Based Design

In our refactored architecture, the UserRepository and OrganizationRepository are read-only interfaces. The business logic in Go microservices uses these interfaces to retrieve identity data, without knowing—or caring—that the data is managed by the Better Auth service on the Bun monolith.

Core Pattern: Direct PostgreSQL Queries

We have eliminated distributed data synchronization (NATS user sync) in favor of direct, read-only queries to the Better Auth PostgreSQL tables. This ensures absolute consistency and simplifies the system architecture.

1. User Repository Implementation

The UserRepository maps the Better Auth users table to our local domain entities using GORM.
// internal/modules/user/adapter/outbound/persistence/postgresql/user_repository.go

type userRepository struct {
    db *gorm.DB
}

func (r *userRepository) FindByID(ctx context.Context, id string) (*entity.User, error) {
    var a authUser
    // Query the Better Auth core 'users' table using GORM
    err := r.db.WithContext(ctx).Table("\"users\"").Where("id = ?", id).First(&a).Error
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, nil
        }
        return nil, err
    }
    return toUserDomain(&a), nil
}
Key Benefits:
  • Absolute Consistency: No lag between user creation in Bun and availability in Go.
  • Infrastructure Simplicity: No NATS topics or event handlers for user synchronization.
  • Performance: Direct DB queries with optimized indexes are extremely fast.

2. Organization & Membership Joins

To retrieve organization context, we join the organizations and members tables.
// internal/modules/organizations/adapter/outbound/persistence/postgresql/organization_repository.go

func (r *orgRepo) ListByUserID(ctx context.Context, userID string) ([]*entity.Organization, error) {
    var orgs []*entity.Organization
    err := r.db.WithContext(ctx).
        Table("organizations").
        Joins("INNER JOIN members ON organizations.id = members.organization_id").
        Where("members.user_id = ?", userID).
        Find(&orgs).Error
    return orgs, err
}

3. Performance Optimization with Redis

While direct DB queries are fast, we use a Permission Cache for frequently checked authorization rules. This layer wraps our database-backed permission service. Use Case: Checking leave:approve permission on every API request. Strategy:
  1. JWT Validation (Stateless): Signatures verified locally.
  2. Permission Check: Resolution from members table role field.
  3. Caching: Store resolved permission arrays in Redis with a 5-minute TTL.

Comparison: Old vs. New Architecture

FeatureOld (Clerk + NATS Sync)New (Better Auth + Direct Query)
Source of TruthClerk (Cloud)Better Auth (Local PostgreSQL)
ConsistencyEventual (Sync Lag)Absolute (Direct DB)
Go ResponsibilityWrite + ReadRead-Only
Tablesuser_sync (Local)users, members (Shared)
ComplexityHigh (Event handling)Low (Standard SQL)

Implementation Rules

  1. Read-Only: No Create, Update, or Delete methods in Go user/org repositories.
  2. Plural Naming: Always use plural table names (users, members, organizations, accounts, sessions).
  3. Write Forwarding: If a Go service needs to trigger a user mutation, it must call the Better Auth HTTP API or emit a high-level business event that the Bun monolith consumes.
  4. Interface Isolation: Keep domain entities isolated from the database schema using tags and mapping logic in the repository layer.

Conclusion

The Query-Only Repository Pattern leverages the fact that all our services share a single PostgreSQL cluster. By directly querying the Better Auth tables, we achieve a simpler, more robust, and more consistent identity system across our microservices architecture.