Padrão CQRS

Armazenamento do Azure

O CQRS significa Segregação de Responsabilidade de Comando e Consulta, um padrão que separa as operações de leitura e atualização de um armazenamento de dados. A implementação do CQRS em seu aplicativo pode maximizar o desempenho, a escalabilidade e a segurança. A flexibilidade criada pela migração para CQRS permite ao sistema evoluir melhor ao longo do tempo e impede que os comandos de atualização causem conflitos de mesclagem no nível de domínio.

Contexto e problema

Nas arquiteturas tradicionais, o mesmo modelo de dados é usado para consultar e atualizar um banco de dados. É simples e funciona bem para operações CRUD básicas. Em aplicativos mais complexos, no entanto, essa abordagem pode se tornar complicada. Por exemplo, no lado de leitura, o aplicativo pode executar muitas consultas diferentes, retornando objetos de transferência de dados (DTOs) com formas diferentes. O mapeamento de objetos pode se tornar complicado. No lado da gravação, o modelo pode implementar uma validação complexa e lógica de negócios. Como resultado, você pode terminar com um modelo excessivamente complexo que faz coisas em excesso.

As cargas de trabalho de leitura e a gravação geralmente são assimétricas, com diferentes requisitos de desempenho e escalabilidade.

Uma arquitetura CRUD tradicional

  • Com frequência, há uma incompatibilidade entre as representações de leitura e gravação dos dados, como colunas ou propriedades adicionais que devem ser atualizadas corretamente mesmo que não sejam necessárias como parte de uma operação.

  • A contenção de dados pode ocorrer quando as operações são executadas em paralelo no mesmo conjunto de dados.

  • A abordagem tradicional pode ter um efeito negativo no desempenho devido à carga no armazenamento de dados e na camada de acesso aos dados, e a complexidade das consultas necessárias para recuperar informações.

  • O gerenciamento de segurança e permissões pode se tornar mais complexo porque cada entidade está sujeita tanto a operações de leitura como de gravação, o que pode expor dados no contexto errado.

Solução

O CQRS separa as leituras e gravações em modelos separados, usando comandos para atualizar dados e consultas para ler dados.

  • Os comandos devem ser baseados em tarefas, em vez de centrados nos dados. ("Book hotel room", não "set ReservationStatus to Reserved"). Isso pode exigir algumas alterações correspondentes no estilo de interação do usuário. A outra parte é analisar a modificação da lógica de negócios que processa esses comandos para ter sucesso com mais frequência. Uma técnica que permite isso é a execução de algumas regras de validação no cliente antes mesmo de enviar o comando, possivelmente desabilitando botões, explicando o porquê na interface ("não há mais salas"). Dessa forma, a causa das falhas de comando do servidor pode ser reduzida a condições de corrida (dois usuários tentando reservar o último quarto), e mesmo elas podem, às vezes, ser resolvidas com mais dados e lógica (colocar um convidado em uma lista de espera).
  • Os comandos podem ser posicionados em uma fila para processamento assíncrono, em vez de ser processado de forma síncrona.
  • As consultas nunca modificam o banco de dados. Uma consulta retorna um DTO que não encapsula qualquer conhecimento de domínio.

Os modelos podem, então, serem isolados, conforme mostrado no diagrama a seguir, embora isso não seja um requisito absoluto.

Uma arquitetura CQRS básica

Ter modelos de consulta e atualização separados simplifica o design e a implementação. No entanto, uma desvantagem é que o código CQRS não pode ser gerado automaticamente a partir de um esquema de banco de dados usando mecanismos de scaffolding, como ferramentas O/RM (porém, você poderá criar sua personalização com base no código gerado).

Para maior isolamento, você pode separar fisicamente os dados de leitura de dados de gravação. Nesse caso, o banco de dados de leitura pode usar seu próprio esquema de dados, otimizado para consultas. Por exemplo, ele pode armazenar uma exibição materializada dos dados, para evitar mapeamentos de O/RM complexos ou junções complexas. Ele ainda pode usar um tipo diferente de armazenamento de dados. Por exemplo, o banco de dados de gravação pode ser relacional, enquanto o banco de dados de leitura é um banco de dados de documento.

Se forem usados bancos de dados de leitura e gravação separados, eles deverão ser mantidos em sincronia. Normalmente, isso é feito fazendo com que o modelo de gravação publique um evento sempre que atualiza o banco de dados. Para obter mais informações sobre como usar eventos, confira Estilo de arquitetura controlado por eventos. Como os corretores de mensagens e os bancos de dados geralmente não podem ser incluídos em uma única transação distribuída, pode haver desafios para garantir a consistência ao atualizar o banco de dados e publicar eventos. Para obter mais informações, consulte as orientações sobre processamento de mensagens idempotentes.

Uma arquitetura CQRS com repositórios de leitura e gravação separados

O repositório de leitura pode ser uma réplica somente para leitura do repositório de gravação, ou repositórios de gravação e leitura podem ter uma estrutura completamente diferente. O uso de múltiplas réplicas somente leitura pode aumentar o desempenho da consulta, especialmente em cenários distribuídos onde as réplicas de somente para leitura estão localizadas próximas às instâncias do aplicativo.

A separação dos repositórios de gravação e leitura também permite que cada um seja dimensionado adequadamente para corresponder à carga. Por exemplo, os repositórios de leitura normalmente encontram uma carga muito maior do que os repositórios de gravação.

Algumas implementações do CQRS usam o padrão de Evento de Fornecimento. Com esse padrão, o estado do aplicativo é armazenado como uma sequência de eventos. Cada evento representa um conjunto de alterações nos dados. O estado atual foi criado pela repetição dos eventos. Em um contexto CQRS, um dos benefícios do fornecimento do evento é que os mesmos eventos podem ser usados para notificar outros componentes — em particular, para notificar o modelo de leitura. O modelo de leitura usa os eventos para criar um instantâneo do estado atual, que é mais eficiente para consultas. No entanto, o fornecimento de evento adiciona complexidade ao design.

Os benefícios de CQRS incluem:

  • Dimensionamento independente. O CQRS permite que as cargas de trabalho de leitura e gravação sejam dimensionadas de forma independente e pode resultar em menos contenções de bloqueio.
  • Esquemas de dados otimizados. O lado de leitura pode usar um esquema que é otimizado para consultas, enquanto o lado de gravação usa um esquema que é otimizado para atualizações.
  • Segurança. É mais fácil garantir que apenas as entidades do direito de domínio estejam executando gravações nos dados.
  • Divisão de problemas. Isolar os lados de leitura e gravação pode resultar em modelos mais flexíveis e sustentáveis. A maior parte da lógica de negócios complexa vai para o modelo de gravação. O modelo de leitura pode ser relativamente simples.
  • Consultas mais simples. Ao armazenar uma exibição materializada no banco de dados de leitura, o aplicativo poderá evitar junções complexas durante as consultas.

Questões e considerações sobre implementação

Alguns desafios para implementar esse padrão incluem:

  • Complexidade. A ideia básica do CQRS é simples. Mas isso poderá resultar em um design de aplicativo mais complexo, especialmente se eles incluírem o padrão Fornecimento de Eventos.

  • Mensagens. Embora o CQRS não necessite de mensagens, é comum usar mensagens para comandos de processo e publicar eventos de atualização. Neste caso, o aplicativo deve tratar as falhas de mensagem ou as mensagens duplicadas. Confira as diretrizes sobre Filas de Prioridade para lidar com comandos com prioridades diferentes.

  • Consistência eventual. Se você separar os bancos de dados de leitura e de gravação, os dados de leitura poderão ficar obsoletos. O repositório de modelos de leitura deve ser atualizado para refletir as alterações no repositório de gravação e pode ser difícil de detectar quando um usuário emitiu uma solicitação baseadas em dados de leitura obsoletos.

Quando usar o padrão CQRS

Considere o CQRS para os seguintes cenários:

  • Domínios colaborativos em que muitos usuários acessam os mesmos dados em paralelo. O CQRS permite que você defina comandos com granularidade suficiente para minimizar os conflitos de mesclagem no nível de domínio, e os conflitos que surgem podem ser mesclados pelo comando.

  • Interfaces de usuário baseadas em tarefas, onde os usuários são guiados por um processo complexo como uma série de etapas ou com modelos de domínio complexos. O modelo de gravação tem uma pilha completa de processamento de comandos com lógica de negócios, validação de entrada e validação de negócios. O modelo de gravação pode tratar um conjunto de objetos associados como uma única unidade para alterações de dados (uma agregação, na terminologia DDD) e garantir que esses objetos estejam sempre em um estado consistente. O modelo de leitura não possui lógica de negócios ou pilha de validação e apenas retorna um DTO para uso em um modelo de exibição. O modelo de leitura é, eventualmente, consistente com o modelo de gravação.

  • Os cenários em que o desempenho das leituras de dados deve ser ajustado separadamente do desempenho das gravações de dados, especialmente quando o número de leituras é muito maior do que o número de gravações. Nesse cenário, você pode escalar horizontalmente o modelo de leitura, mas executar o modelo de gravação em apenas algumas instâncias. Um pequeno número de instâncias de modelo de gravação também ajuda a minimizar a ocorrência de conflitos de mesclagem.

  • Cenários onde uma equipe de desenvolvedores pode se concentrar no modelo de domínio complexo que faz parte do modelo de gravação e outra equipe pode se concentrar no modelo de leitura e nas interfaces de usuário.

  • Cenários onde o sistema deve evoluir ao longo do tempo e pode conter várias versões do modelo, ou onde as regras de negócio mudam regularmente.

  • Integração com outros sistemas, especialmente em combinação com fornecimento de evento, onde a falha temporal de um subsistema não deve afetar a disponibilidade dos outros.

Este padrão não é recomendado:

  • O domínio ou as regras de negócios são simples.

  • Uma interface de usuário em estilo CRUD simples e as operações de acesso aos dados são suficientes.

Considere aplicar CQRS em seções limitadas do seu sistema, onde será mais valioso.

Design de carga de trabalho

Um arquiteto deve avaliar como o padrão CQRS pode ser usado no design de sua carga de trabalho para abordar as metas e os princípios abordados nos pilares do Azure Well-Architected Framework. Por exemplo:

Pilar Como esse padrão apoia os objetivos do pilar
A eficiência de desempenho ajuda sua carga de trabalho a atender com eficiência às demandas por meio de otimizações em dimensionamento, dados e código. A separação das operações de leitura e gravação em altas cargas de trabalho de leitura para gravação permite desempenho direcionado e otimizações de escala para a finalidade específica de cada operação.

- PE:05 Dimensionamento e particionamento
- PE:08 Desempenho de dados

Tal como acontece com qualquer decisão de design, considere quaisquer compensações em relação aos objetivos dos outros pilares que possam ser introduzidos com este padrão.

Fornecimento de eventos e padrão CQRS

O padrão CQRS é frequentemente utilizado juntamente com o padrão de Fornecimento de Evento. Os sistemas baseados em CQRS utilizam modelos de dados de gravação e leitura separados, cada um adaptado a tarefas relevantes e, muitas vezes, localizado em repositórios separados fisicamente. Quando utilizado com o padrão Fornecimento de Evento, o repositório de eventos é o modelo de gravação e é a fonte oficial de informações. O modelo de leitura de um sistema baseado em CQRS fornece exibições materializadas dos dados, geralmente como exibições altamente desnormalizadas. Essas exibições são adaptadas às interfaces e aos requisitos de exibição do aplicativo, o que ajuda a maximizar tanto o desempenho de consulta como exibição.

Utilizando o stream de eventos como o repositório de gravação, em vez dos dados reais em um ponto no tempo, evita conflitos de atualização em um único agregado e maximiza o desempenho e a escalabilidade. Os eventos podem ser utilizados para gerar de maneira assíncrona exibições materializadas dos dados que são utilizadas para preencher o repositório de leitura.

Como o repositório de evento é a fonte oficial de informações, é possível excluir as exibições materializadas e reproduzir todos os eventos passados para criar uma nova representação do estado atual quando o sistema evoluir ou quando o modelo de leitura precisar alterar. As exibições materializadas são efetivamente um cache somente leitura durável dos dados.

Ao utilizar CQRS combinado com o padrão Fornecimento de Evento, considere o seguinte:

  • Como em qualquer sistema onde os repositórios de gravação e leitura são separados, os sistemas baseados nesse padrão são eventualmente consistentes. Haverá algum atraso entre o evento que estiver sendo gerado e o armazenamento de dados sendo atualizado.

  • O padrão adiciona complexidade porque o código deve ser criado para iniciar e tratar eventos e montar ou atualizar as exibições ou objetos apropriados exigidos por consultas ou um modelo de leitura. A complexidade do padrão CQRS quando utilizado com o padrão de Fornecimento de Evento pode dificultar a implementação bem-sucedida e requer uma abordagem diferente para a concepção de sistemas. No entanto, o fornecimento de evento pode tornar mais fácil para modelar o domínio e facilitar para recompilar exibições ou criar novas porque a intenção das alterações nos dados é preservada.

  • A geração de exibições materializadas para uso no modelo de leitura ou projeções dos dados, reproduzindo e manipulando os eventos para entidades específicas ou coleções de entidades pode exigir um tempo de processamento significativo e uso de recursos. Isto é especialmente verdadeiro se requer soma ou análise de valores em longos períodos, pois poderá ser necessário examinar todos os eventos associados. Resolva isso implementando instantâneos dos dados em intervalos agendados, como uma contagem total do número de uma ação específica que ocorreu ou o estado atual de uma entidade.

Exemplo de padrão CQRS

O código a seguir mostra alguns extratos de um exemplo de uma implementação CQRS que utiliza diferentes definições para os modelos de leitura e gravação. As interfaces modelo não ditarão quaisquer recursos dos repositórios de dados subjacentes, e elas podem evoluir e terem ajustes finos de maneira independente porque essas interfaces estão separadas.

O código a seguir mostra a definição do modelo de leitura.

// Query interface
namespace ReadModel
{
  public interface ProductsDao
  {
    ProductDisplay FindById(int productId);
    ICollection<ProductDisplay> FindByName(string name);
    ICollection<ProductInventory> FindOutOfStockProducts();
    ICollection<ProductDisplay> FindRelatedProducts(int productId);
  }

  public class ProductDisplay
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal UnitPrice { get; set; }
    public bool IsOutOfStock { get; set; }
    public double UserRating { get; set; }
  }

  public class ProductInventory
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CurrentStock { get; set; }
  }
}

O sistema permite aos usuários avaliar produtos. O código do aplicativo faz isso utilizando o comando RateProduct mostrado no código a seguir.

public interface ICommand
{
  Guid Id { get; }
}

public class RateProduct : ICommand
{
  public RateProduct()
  {
    this.Id = Guid.NewGuid();
  }
  public Guid Id { get; set; }
  public int ProductId { get; set; }
  public int Rating { get; set; }
  public int UserId {get; set; }
}

O sistema utiliza a classe ProductsCommandHandler para lidar com comandos enviados pelo aplicativo. Normalmente, os clientes enviam comandos para o domínio através de um sistema de mensagens, como uma fila. O manipulador de comando aceita esses comandos e invoca métodos da interface de domínio. A granularidade de cada comando é projetada para reduzir a chance de solicitações conflitantes. O código a seguir mostra uma estrutura de tópicos da classe ProductsCommandHandler.

public class ProductsCommandHandler :
    ICommandHandler<AddNewProduct>,
    ICommandHandler<RateProduct>,
    ICommandHandler<AddToInventory>,
    ICommandHandler<ConfirmItemShipped>,
    ICommandHandler<UpdateStockFromInventoryRecount>
{
  private readonly IRepository<Product> repository;

  public ProductsCommandHandler (IRepository<Product> repository)
  {
    this.repository = repository;
  }

  void Handle (AddNewProduct command)
  {
    ...
  }

  void Handle (RateProduct command)
  {
    var product = repository.Find(command.ProductId);
    if (product != null)
    {
      product.RateProduct(command.UserId, command.Rating);
      repository.Save(product);
    }
  }

  void Handle (AddToInventory command)
  {
    ...
  }

  void Handle (ConfirmItemsShipped command)
  {
    ...
  }

  void Handle (UpdateStockFromInventoryRecount command)
  {
    ...
  }
}

Próximas etapas

Os seguintes padrões e diretrizes serão úteis ao implementar esse padrão:

Postagens no blog de Martin Fowler: