Como usar o padrão read model para projeções customizadas por cliente

1. Fundamentos do padrão read model em sistemas orientados a eventos

1.1. Definição e propósito do read model: separação entre escrita e leitura

O padrão read model estabelece uma separação clara entre o modelo de escrita (command model) e o modelo de leitura (read model). Enquanto o command model é otimizado para validação de regras de negócio e persistência de eventos, o read model é construído especificamente para atender consultas de forma eficiente. Essa separação permite que cada lado evolua independentemente, com esquemas e tecnologias de armazenamento diferentes.

1.2. Diferença entre event store, materialized views e projeções customizadas

  • Event store: repositório imutável de todos os eventos ocorridos no sistema. Serve como fonte da verdade.
  • Materialized views: visões pré-computadas a partir dos eventos, geralmente padronizadas para todos os clientes.
  • Projeções customizadas: visões especializadas que aplicam transformações, filtros e agregações específicas por cliente.

1.3. Relação com o padrão CQRS e event sourcing

O read model é um dos pilares do CQRS (Command Query Responsibility Segregation). Quando combinado com event sourcing, os read models são reconstruídos a partir do stream de eventos, garantindo rastreabilidade total. As projeções customizadas por cliente surgem naturalmente nesse contexto, pois cada inquilino pode ter suas próprias regras de transformação.

2. Desafios de projeções customizadas por cliente

2.1. Necessidade de visões de dados específicas por inquilino ou usuário

Em sistemas multi-tenant, cada cliente pode exigir formatos de dados distintos: um cliente quer relatórios financeiros consolidados, outro quer histórico de interações detalhado, e um terceiro precisa de dashboards em tempo real. O read model precisa acomodar essas variações sem comprometer a performance.

2.2. Complexidade de manutenção quando cada cliente exige formatos diferentes

Manter dezenas ou centenas de projeções diferentes pode se tornar um pesadelo operacional. Cada nova projeção introduz lógica de transformação, armazenamento e testes específicos. Sem governança adequada, o sistema rapidamente se torna frágil.

2.3. Trade-offs entre armazenamento dedicado vs consulta dinâmica

  • Armazenamento dedicado: projeções pré-computadas por cliente — consultas rápidas, mas maior custo de armazenamento.
  • Consulta dinâmica: computa a projeção sob demanda a partir dos eventos — menor custo de armazenamento, mas maior latência e carga computacional.

A escolha depende do volume de dados, frequência de consulta e requisitos de latência de cada cliente.

3. Estrutura de um read model para múltiplos clientes

3.1. Componentes essenciais: event handlers, processadores de projeção e armazenamento de saída

  • Event handlers: escutam o event store e disparam processamento assíncrono.
  • Processadores de projeção: aplicam lógica de transformação específica por cliente.
  • Armazenamento de saída: banco de dados otimizado para consultas (relacional, NoSQL ou colunar).

3.2. Estratégias de chaveamento: client_id, tenant_id ou contexto de usuário

Cada evento deve ser roteado para o processador correto usando um identificador de cliente. As estratégias comuns incluem:

  • Partition key baseada em tenant_id
  • Fila de eventos separada por cliente
  • Filtro no event handler que verifica metadados do evento

3.3. Contratos de projeção: definindo o esquema por cliente sem acoplamento

Cada projeção customizada deve ter um contrato explícito (schema versionado) que define quais campos serão projetados. Esse contrato é registrado em um repositório de projeções, permitindo que novos clientes sejam adicionados sem modificar o código existente.

4. Implementação de projeções customizadas com event handlers

4.1. Exemplo de handler que filtra e transforma eventos por cliente

// Event handler para projeção customizada do Cliente A
function handleOrderPlacedForClientA(event, context) {
  const { clientId, orderId, items, total } = event.data;

  // Filtro: somente pedidos acima de R$ 1000
  if (total < 1000) return;

  // Transformação: calcular comissão específica do cliente
  const commission = total * 0.05;

  // Projeção: armazenar visão customizada
  const projection = {
    clientId,
    orderId,
    amount: total,
    commissionAmount: commission,
    processedAt: new Date().toISOString()
  };

  return projection;
}

4.2. Uso de funções de projeção parametrizadas por regras de negócio do cliente

// Função de projeção parametrizada
function createProjectionHandler(rules) {
  return function(event) {
    const { clientId, type, data } = event;

    // Aplica regras específicas do cliente
    if (rules.filter && !rules.filter(data)) return null;

    // Transformação baseada nas regras
    const transformed = rules.transform(data);

    return {
      clientId,
      projectionType: rules.projectionName,
      data: transformed,
      version: rules.version
    };
  };
}

// Uso para cliente específico
const clientARules = {
  filter: (data) => data.total >= 1000,
  transform: (data) => ({ amount: data.total, commission: data.total * 0.05 }),
  projectionName: 'high_value_orders',
  version: 1
};

const handlerForClientA = createProjectionHandler(clientARules);

4.3. Cache e atualização incremental: evitando reprocessamento total

// Atualização incremental com checkpoint
function processIncremental(clientId, projectionName, lastProcessedEventId) {
  const events = getEventsSince(clientId, lastProcessedEventId);
  const rules = getProjectionRules(clientId, projectionName);

  for (const event of events) {
    const projection = createProjectionHandler(rules)(event);
    if (projection) {
      upsertProjection(projection);
    }
  }

  updateCheckpoint(clientId, projectionName, events[events.length - 1].id);
}

5. Armazenamento e consulta das projeções

5.1. Modelos de banco: tabelas separadas por cliente vs coluna discriminadora

Opção 1 — Tabelas separadas por cliente:

CREATE TABLE projections_client_a (
  id UUID PRIMARY KEY,
  order_id VARCHAR(50),
  amount DECIMAL(10,2),
  commission_amount DECIMAL(10,2),
  processed_at TIMESTAMP
);

CREATE TABLE projections_client_b (
  id UUID PRIMARY KEY,
  order_id VARCHAR(50),
  customer_name VARCHAR(100),
  total_items INTEGER,
  delivery_estimate DATE
);

Opção 2 — Coluna discriminadora:

CREATE TABLE projections (
  id UUID PRIMARY KEY,
  client_id VARCHAR(20),
  projection_type VARCHAR(50),
  data JSONB,
  created_at TIMESTAMP
);

CREATE INDEX idx_client_projection ON projections (client_id, projection_type);

5.2. Estratégias de indexação para consultas frequentes e específicas

  • Índices compostos por (client_id, projection_type, created_at)
  • Índices parciais para projeções mais consultadas
  • Índices funcionais para consultas em campos JSONB (quando aplicável)

5.3. Exemplo de consulta de projeção customizada

-- Consulta para obter pedidos de alto valor do Cliente A
SELECT * FROM projections_client_a 
WHERE amount > 5000 
ORDER BY processed_at DESC 
LIMIT 50;

-- Consulta genérica usando coluna discriminadora
SELECT data->>'customer_name' AS name,
       data->>'total_items' AS items
FROM projections
WHERE client_id = 'B'
  AND projection_type = 'order_summary'
  AND created_at >= '2024-01-01';

6. Versionamento e evolução de projeções por cliente

6.1. Como lidar com mudanças de requisitos sem quebrar projeções existentes

  • Manter versões anteriores da projeção ativas até que todos os consumidores migrem
  • Usar versionamento semântico nos contratos de projeção (ex: v1, v2)
  • Registrar metadados de versão em cada registro de projeção

6.2. Estratégias de migração: reconstrução total vs migração por snapshot

Reconstrução total: reprocessa todos os eventos desde o início — garante consistência, mas é lenta.

Migração por snapshot: salva periodicamente o estado da projeção e aplica eventos incrementais a partir do snapshot mais recente.

6.3. Controle de compatibilidade entre versões de projeção e eventos

// Verificação de compatibilidade antes de aplicar evento
function canApplyEvent(eventVersion, projectionVersion) {
  const compatibilityMatrix = {
    'v1': { minEventVersion: '1.0', maxEventVersion: '2.0' },
    'v2': { minEventVersion: '1.5', maxEventVersion: '3.0' }
  };

  const rules = compatibilityMatrix[projectionVersion];
  return eventVersion >= rules.minEventVersion && 
         eventVersion <= rules.maxEventVersion;
}

7. Monitoramento e governança de projeções customizadas

7.1. Métricas de latência, consistência e custo de armazenamento por cliente

  • Latência: tempo entre evento ocorrer e projeção estar disponível
  • Consistência: número de eventos não processados por projeção
  • Custo: tamanho do armazenamento e custo computacional por cliente

7.2. Ferramentas para rastrear quais projeções estão em uso

  • Logs de acesso às projeções com metadados de cliente e consulta
  • Dashboard com contagem de consultas por projeção
  • Sistema de heartbeat que registra última consulta de cada projeção

7.3. Políticas de lifecycle: expurgo, descontinuação e auditoria de projeções

// Política de lifecycle para projeções customizadas
const lifecyclePolicy = {
  'active': { maxAge: 'unlimited', notifyBeforeExpiry: false },
  'deprecated': { maxAge: '90 days', notifyBeforeExpiry: 30 },
  'expired': { maxAge: '30 days', action: 'delete' }
};

function evaluateProjectionLifecycle(clientId, projectionName) {
  const status = getProjectionStatus(clientId, projectionName);
  const lastAccess = getLastAccessDate(clientId, projectionName);
  const daysSinceLastAccess = daysBetween(new Date(), lastAccess);

  if (daysSinceLastAccess > 180 && status === 'active') {
    setProjectionStatus(clientId, projectionName, 'deprecated');
    notifyClient(clientId, projectionName, 'deprecation_warning');
  }

  if (daysSinceLastAccess > 270 && status === 'deprecated') {
    archiveProjection(clientId, projectionName);
  }
}

Referências