ums

TE-06: CQRS Projection Rebuild

Field Value
TE ID TE-06
Status Approved
ADR Reference ADR-0034: CQRS Applicability
Satisfies FS-04 (Register System Topology), FS-07 (Visual Graph Resolver), FS-13 (Hierarchical Configuration)
Owner Platform Team
Date 2026-05-15

Problem

Read models for complex views — such as the authorization graph (FS-07) or hierarchical configuration (FS-13) — require aggregated data from multiple bounded contexts. Computing these on-the-fly from the write store is expensive and couples the query model to the domain model.

Solution: Shadow Read Store with Atomic Swap

Maintain a separate read store (projection) optimized for queries. When the write side changes, events trigger an incremental update to the projection. For full rebuilds (schema migration, recovery), build a shadow projection and atomically swap the pointer.

Write Side                         Read Side
──────────                         ─────────
Command                            
  │                                ┌──────────────────────────┐
  ▼                                │  Projection Store        │
Domain Event ──► Event Store ──────► (Redis / PostgreSQL view)│
                                   │                          │
                     ▲             │  auth_graph_v2 (shadow)  │
                     │             │         │                 │
              Projection           │  SWAP ──►auth_graph (live)│
              Rebuilder            │                          │
                                   └──────────────────────────┘

Projection Architecture

Read Model Interface

// application/queries/auth-graph.query.ts
export interface AuthGraphProjection {
  userId: string;
  tenantId: string;
  roles: string[];
  permissions: string[];
  effectiveAt: Date;
}

export abstract class IAuthGraphRepository {
  abstract findByUser(userId: string, tenantId: string): Promise<AuthGraphProjection | null>;
}

Projection Handler

// infrastructure/projections/auth-graph.projector.ts
@Injectable()
export class AuthGraphProjector {
  constructor(
    @InjectRepository(AuthGraphReadEntity)
    private readonly readRepo: Repository<AuthGraphReadEntity>,
  ) {}

  @OnEvent('ums.authorization.role.assigned')
  async onRoleAssigned(event: RoleAssignedEvent): Promise<void> {
    await this.readRepo.upsert(
      {
        userId: event.userId,
        tenantId: event.tenantId,
        roles: () => `array_append(roles, '${event.roleId}')`,
        effectiveAt: new Date(),
      },
      ['userId', 'tenantId'],
    );
  }

  @OnEvent('ums.authorization.role.revoked')
  async onRoleRevoked(event: RoleRevokedEvent): Promise<void> {
    await this.readRepo.upsert(
      {
        userId: event.userId,
        tenantId: event.tenantId,
        roles: () => `array_remove(roles, '${event.roleId}')`,
        effectiveAt: new Date(),
      },
      ['userId', 'tenantId'],
    );
  }
}

Full Rebuild Process (Shadow Swap)

// infrastructure/projections/auth-graph-rebuild.service.ts
@Injectable()
export class AuthGraphRebuildService {
  private readonly logger = new Logger(AuthGraphRebuildService.name);

  constructor(
    private readonly eventStore: EventStoreService,
    private readonly ds: DataSource,
  ) {}

  async rebuild(): Promise<void> {
    const shadowTable = `auth_graph_shadow_${Date.now()}`;
    this.logger.log(`Starting projection rebuild into ${shadowTable}`);

    await this.ds.query(`CREATE TABLE ${shadowTable} (LIKE auth_graph INCLUDING ALL)`);

    try {
      const events = await this.eventStore.getAllOrderedEvents(['role.assigned', 'role.revoked']);

      for (const event of events) {
        await this.applyToShadow(shadowTable, event);
      }

      await this.ds.transaction(async (em) => {
        await em.query(`ALTER TABLE auth_graph RENAME TO auth_graph_old`);
        await em.query(`ALTER TABLE ${shadowTable} RENAME TO auth_graph`);
        await em.query(`DROP TABLE auth_graph_old`);
      });

      this.logger.log('Projection rebuild complete — shadow swapped');
    } catch (err) {
      await this.ds.query(`DROP TABLE IF EXISTS ${shadowTable}`);
      throw err;
    }
  }

  private async applyToShadow(table: string, event: StoredEvent): Promise<void> {
    // apply event to shadow table using same projector logic
  }
}

Redis Cache Layer (for hot projections)

// infrastructure/projections/cached-auth-graph.repository.ts
@Injectable()
export class CachedAuthGraphRepository implements IAuthGraphRepository {
  constructor(
    @Inject(REDIS_CLIENT) private readonly redis: Redis,
    private readonly dbRepo: AuthGraphDbRepository,
  ) {}

  async findByUser(userId: string, tenantId: string): Promise<AuthGraphProjection | null> {
    const key = `auth:graph:${tenantId}:${userId}`;
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached);

    const projection = await this.dbRepo.findByUser(userId, tenantId);
    if (projection) {
      await this.redis.set(key, JSON.stringify(projection), 'EX', 300);
    }
    return projection;
  }

  async invalidate(userId: string, tenantId: string): Promise<void> {
    await this.redis.del(`auth:graph:${tenantId}:${userId}`);
  }
}

Rebuild Trigger Policy

Trigger Action
Schema migration Manual rebuild via admin API POST /admin/projections/auth-graph/rebuild
Event replay recovery Automated rebuild on startup if projection checksum mismatch
Performance degradation Incremental re-project last N events via DLQ replay

Acceptance Criteria


Back to Blueprints Index Back to Traceability Matrix